Mở đầu — vì sao bài này quan trọng
Hãy tưởng tượng bạn vừa được nhận vào một công ty fintech ở TP.HCM. Ngày đầu đi làm, bạn clone repo về máy và giật mình: một repository duy nhất chứa 50 service — payment, KYC, notification, ledger, mobile-backend, admin-portal, và hàng chục thư viện dùng chung. Đây gọi là monorepo (một kho code duy nhất chứa nhiều dự án).
Bạn sửa đúng một dòng comment trong file README.md của service notification, tạo Pull Request, rồi ngồi chờ. Và chờ. Và chờ tiếp. Sau hơn một tiếng, CI mới chạy xong, vì pipeline đã ngoan ngoãn build và test lại toàn bộ 50 service — kể cả những service chẳng liên quan gì đến thay đổi của bạn.
Đây chính là "nỗi đau" kinh điển của monorepo khi CI/CD làm sai cách. Khi đội nhóm còn 5 người với 3 service, việc build lại tất cả mỗi lần chẳng sao cả — vài phút là xong. Nhưng khi công ty phình to lên 50 service và 40 kỹ sư cùng push code mỗi ngày, cách làm "build tất cả" biến pipeline thành một cái cổ chai khổng lồ: developer chờ đợi mòn mỏi, chi phí máy chủ CI đội lên, và tinh thần cả team đi xuống.
Bài học hôm nay tập trung vào giải pháp cốt lõi cho vấn đề này: các công cụ monorepo như Nx và Turborepo, cùng với pattern quan trọng nhất mà chúng mang lại — "affected only" (chỉ xử lý những gì thực sự bị ảnh hưởng). Nắm được nó, bạn có thể biến pipeline một tiếng thành pipeline vài phút.
Khái niệm cốt lõi
Monorepo là gì và tại sao nó phổ biến
Monorepo là kiểu tổ chức mã nguồn trong đó nhiều ứng dụng và thư viện cùng nằm trong một repository. Đối lập với nó là polyrepo — mỗi service một repo riêng.
Các công ty lớn như Google, Meta, Uber dùng monorepo vì nó mang lại nhiều lợi ích: chia sẻ code dễ dàng (một thư viện utils dùng chung cho mọi service), refactor xuyên service trong một PR duy nhất, và mọi người luôn thấy toàn cảnh hệ thống. Nhưng đổi lại, monorepo đặt một gánh nặng khổng lồ lên CI/CD. Nếu không xử lý khéo, mỗi commit nhỏ sẽ kích hoạt cả một cơn bão build.
Bài toán gốc: đồ thị phụ thuộc (dependency graph)
Để hiểu tại sao "affected only" hoạt động, bạn cần hiểu khái niệm dependency graph — đồ thị mô tả cái nào phụ thuộc vào cái nào.
Giả sử trong monorepo của bạn:
lib-moneylà thư viện xử lý tiền tệ.service-paymentdùnglib-money.service-notificationkhông dùnglib-money.
lib-money, rõ ràng service-payment có thể bị ảnh hưởng và cần test lại, nhưng service-notification thì không. Ngược lại, nếu bạn chỉ sửa file trong service-notification, thì payment hoàn toàn "vô can".Con người nhìn vào có thể suy luận được điều này. Vấn đề là làm sao để máy tính tự động biết được, một cách chính xác và nhanh chóng, với 50 service và hàng trăm thư viện chồng chéo? Đó chính là việc mà Nx và Turborepo làm.
Pattern "affected only" hoạt động thế nào
Các công cụ monorepo hoạt động theo ba bước:
- Xây dựng dependency graph: Bằng cách đọc file cấu hình (
package.json,import,project.json...), công cụ dựng lên bản đồ đầy đủ về việc project nào phụ thuộc project nào.
- Tính toán "cái gì đã thay đổi": Công cụ so sánh code hiện tại với một điểm mốc (thường là nhánh
main) bằnggit diffđể biết những file nào bị đổi, từ đó suy ra những project nào bị đổi trực tiếp.
- Lan truyền theo đồ thị (affected): Từ những project bị đổi trực tiếp, công cụ đi ngược đồ thị để tìm tất cả project phụ thuộc vào chúng. Tập hợp này gọi là affected — những thứ thực sự cần build/test lại. Mọi project ngoài tập này được bỏ qua hoàn toàn.
npx nx affected --target=test --base=main --head=HEAD
Nghĩa là: "So với nhánh main, hãy chạy target test chỉ trên những project bị ảnh hưởng." Nếu bạn chỉ sửa comment trong service-notification, Nx có thể chỉ chạy test cho đúng một service đó — thay vì cả 50.
Caching: đừng làm lại việc đã làm
Ngoài "affected only", cả Nx và Turborepo còn có một vũ khí thứ hai: caching (bộ nhớ đệm kết quả). Ý tưởng đơn giản: nếu một project và các phụ thuộc của nó không đổi so với lần chạy trước, thì kết quả build/test lần trước vẫn còn nguyên giá trị — cứ lấy lại dùng, không cần chạy lại.
Công cụ tính một "hash" (mã băm) dựa trên toàn bộ input: mã nguồn, dependency, biến môi trường, cấu hình. Nếu hash trùng với một kết quả đã lưu trong cache, nó trả về ngay lập tức — Nx gọi đây là "cache hit". Điều tuyệt vời hơn là remote cache (cache dùng chung trên server): nếu đồng nghiệp của bạn đã build project X trên máy họ và đẩy kết quả lên cache chung, thì khi CI của bạn cần build X với cùng input, nó tải kết quả về trong tích tắc thay vì build lại.
Nx và Turborepo khác nhau ra sao
Cả hai đều giải quyết cùng bài toán, nhưng có triết lý khác nhau:
- Turborepo (do Vercel phát triển): nhẹ, tối giản, dễ bắt đầu. Cấu hình chỉ gói gọn trong file
turbo.json. Nó tập trung vào ba việc: chạy task song song, caching, và affected. Hợp với đội muốn "cắm là chạy" mà không cần học nhiều.
- Nx: mạnh mẽ và giàu tính năng hơn. Ngoài affected và caching, Nx còn có hệ thống generator (tạo code tự động), plugin cho nhiều framework, phân tích đồ thị trực quan (
nx graph), và khả năng phân phối task ra nhiều máy (Nx Agents). Đổi lại, Nx có đường cong học tập dốc hơn một chút.
Tình huống thực tế
Ví dụ 1: Startup fintech ở TP.HCM — từ 70 phút xuống 8 phút
Một startup fintech Việt Nam (gọi là VíXanh) có monorepo với 32 service Node.js và 15 thư viện dùng chung. Ban đầu pipeline GitHub Actions của họ rất "ngây thơ": mỗi PR chạy npm run test và npm run build ở thư mục gốc, khiến toàn bộ 32 service được test lại. Thời gian trung bình mỗi PR: 70 phút. Với khoảng 40 PR mỗi ngày, đội DevOps đốt một lượng lớn phút chạy runner, hóa đơn CI tháng đó lên tới hàng chục triệu đồng.
Đội quyết định áp dụng Turborepo. Họ định nghĩa turbo.json với các task build, test, lint, rồi đổi câu lệnh CI thành turbo run test build --filter='...[origin/main]' — nghĩa là chỉ chạy trên các project bị ảnh hưởng so với main. Họ cũng bật remote cache của Vercel.
Kết quả sau hai tuần: thời gian PR trung bình rơi xuống 8 phút. Những PR chỉ sửa một service thì thậm chí xong trong 3-4 phút. Với các commit không đổi gì đáng kể (ví dụ chỉ đổi tài liệu), cache hit khiến pipeline gần như "chạy tức thì". Hóa đơn CI giảm khoảng 65%.
Bài học rút ra: "Affected only" không phải tối ưu vặt — nó là thay đổi mang tính bậc thang. Chuyển từ "build tất cả" sang "build cái bị ảnh hưởng" có thể cắt giảm thời gian pipeline gấp gần 10 lần.
Ví dụ 2: Sàn thương mại điện tử — cạm bẫy cấu hình affected sai
Một công ty e-commerce ở Đông Nam Á dùng Nx cho monorepo 48 project. Họ đã cấu hình nx affected từ đầu, và mọi thứ chạy nhanh. Nhưng rồi một sự cố xảy ra: một PR sửa file tsconfig.base.json (cấu hình TypeScript gốc, ảnh hưởng toàn bộ repo) lại không kích hoạt test cho các service quan trọng. Kết quả là một lỗi biên dịch lọt qua CI và làm hỏng service checkout trên production trong 20 phút giờ cao điểm.
Nguyên nhân: pipeline của họ dùng --base trỏ sai. Trong GitHub Actions, họ dùng --base=HEAD~1 (chỉ so với commit ngay trước). Với PR nhiều commit hoặc khi merge, HEAD~1 không phản ánh đúng điểm rẽ nhánh, khiến Nx tính nhầm tập affected và bỏ sót project.
Họ sửa lại bằng cách dùng action chính thức nrwl/nx-set-shas để xác định đúng base SHA (điểm commit chung gần nhất với main), và thêm cấu hình namedInputs để đảm bảo file cấu hình gốc như tsconfig.base.json được coi là input toàn cục — hễ nó đổi thì mọi project đều bị coi là affected.
Bài học rút ra: "Affected only" chỉ đúng khi bạn xác định base chính xác và khai báo đầy đủ các input toàn cục. Cấu hình sai còn nguy hiểm hơn không dùng, vì nó tạo cảm giác an toàn giả trong khi âm thầm bỏ sót test.
Ví dụ 3: Agency phần mềm — remote cache tiết kiệm cho cả team
Một agency ở Hà Nội có 12 lập trình viên làm chung một monorepo React. Vấn đề của họ không phải CI, mà là máy dev cá nhân: mỗi lần git pull về, developer phải build lại toàn bộ các thư viện dùng chung, mất 6-7 phút trước khi có thể chạy app.
Họ bật remote cache của Turborepo. Bây giờ, khi CI đã build một phiên bản thư viện và đẩy kết quả lên cache chung, thì developer nào pull code về cũng chỉ cần tải kết quả từ cache thay vì build lại. Thời gian "từ pull đến chạy được app" giảm từ 7 phút xuống dưới 1 phút cho phần lớn trường hợp.
Bài học rút ra: Giá trị của công cụ monorepo không chỉ nằm ở pipeline CI trên server, mà còn ở vòng lặp phát triển hằng ngày trên máy dev. Remote cache biến công sức build của một người thành lợi ích cho cả đội.
Hướng dẫn từng bước
Dưới đây là quy trình áp dụng "affected only" cho một monorepo dùng Turborepo trên GitHub Actions. Với Nx, các bước tương tự nhưng đổi câu lệnh.
Bước 1 — Cài đặt và định nghĩa task. Cài Turborepo và tạo file turbo.json ở gốc repo, khai báo các "pipeline" task và quan hệ phụ thuộc giữa chúng:
{
"$schema": "https://turbo.build/schema.json",
"tasks": {
"build": { "dependsOn": ["^build"], "outputs": ["dist/**"] },
"test": { "dependsOn": ["build"] },
"lint": {}
}
}
Ký hiệu ^build nghĩa là "build tất cả phụ thuộc trước, rồi mới build project này". Trường outputs cho Turborepo biết cần cache những file nào.
Bước 2 — Xác định base SHA chính xác. Đây là bước quan trọng nhất và cũng dễ sai nhất. Bạn cần biết "so với đâu" để tính affected. Trong GitHub Actions, đảm bảo checkout đủ lịch sử git:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # lấy toàn bộ lịch sử để git diff hoạt động đúng
Bước 3 — Chạy task chỉ trên project bị ảnh hưởng. Dùng --filter với cú pháp so sánh nhánh:
- run: npx turbo run lint test build --filter='...[origin/main]'
Cú pháp ...[origin/main] nghĩa là "các project thay đổi so với origin/main, cộng thêm các project phụ thuộc vào chúng".
Bước 4 — Bật remote cache. Kết nối tài khoản Vercel (hoặc dựng cache server riêng), rồi cung cấp token qua secret trong CI. Khi bật, cache hit sẽ hiển thị rõ trong log, và bạn sẽ thấy nhiều task báo "cache hit, replaying output".
Bước 5 — Khai báo input toàn cục. Nếu có file như tsconfig.base.json, .env, hay package-lock.json mà thay đổi của chúng ảnh hưởng mọi project, hãy khai báo chúng trong globalDependencies để không bị bỏ sót:
{ "globalDependencies": ["tsconfig.base.json", ".env"] }
Bước 6 — Quan sát và tinh chỉnh. Chạy npx turbo run build --dry để xem trước những gì sẽ được thực thi mà không thực sự chạy. Với Nx, dùng nx graph để mở giao diện trực quan xem đồ thị phụ thuộc và tập affected.
Lỗi thường gặp & mẹo
Lỗi 1: Không lấy đủ lịch sử git. Đây là lỗi phổ biến nhất. Mặc định actions/checkout chỉ lấy một commit (shallow clone), khiến git diff không tính được affected và công cụ thường "phòng thủ" bằng cách build tất cả. Luôn đặt fetch-depth: 0 hoặc dùng action xác định base SHA đúng chuẩn.
Lỗi 2: Base SHA sai làm bỏ sót test. Như ví dụ 2, dùng HEAD~1 một cách máy móc là rất nguy hiểm. Trên nhánh main, hãy so với commit thành công trước đó; trên PR, hãy so với điểm rẽ nhánh (merge base). Nx có nrwl/nx-set-shas lo việc này giúp bạn.
Lỗi 3: Quên input toàn cục. File cấu hình gốc, biến môi trường, phiên bản Node — nếu đổi mà không được tính là input, công cụ sẽ trả về cache cũ hoặc bỏ qua project cần test. Hãy liệt kê đầy đủ globalDependencies / namedInputs.
Lỗi 4: Cache "đầu độc" (cache poisoning). Nếu output của build không xác định (ví dụ chứa timestamp, số random), hash có thể trùng nhưng kết quả sai. Đảm bảo build của bạn deterministic (cùng input luôn cho cùng output).
Mẹo: Bắt đầu đơn giản. Đừng cố cấu hình hoàn hảo ngay. Trước tiên bật affected, đo lại thời gian, rồi mới thêm remote cache. Đo lường trước và sau để chứng minh giá trị với sếp — con số "70 phút xuống 8 phút" thuyết phục hơn mọi lời giải thích.
Mẹo: Kết hợp affected với chạy song song (parallelism). Turborepo và Nx đều tự động chạy các task độc lập song song, tận dụng nhiều nhân CPU của runner.
Bài tập thực hành
- Vẽ đồ thị: Cho một monorepo giả định gồm
lib-auth,lib-ui,app-web(dùng cả hai lib),app-admin(chỉ dùnglib-auth). Nếu bạn sửalib-ui, hãy liệt kê tập affected. Nếu sửalib-auththì sao?
- Cấu hình thực tế: Tạo một monorepo nhỏ với 3 package dùng Turborepo. Viết
turbo.jsonvà thử lệnhnpx turbo run build --dryđể xem đồ thị task.
- Đo lường: Trong một dự án monorepo (hoặc dự án giả lập), chạy build "tất cả" và ghi lại thời gian. Sau đó dùng
--filter='...[origin/main]'sau khi sửa một package, và so sánh thời gian. Tính phần trăm cải thiện.
- Tình huống gỡ lỗi: CI của đội bạn báo rằng một PR chỉ sửa tài liệu nhưng vẫn build lại toàn bộ. Hãy liệt kê 3 nguyên nhân khả dĩ và cách kiểm tra từng cái.
Tóm tắt
Monorepo mang lại lợi ích lớn về chia sẻ code và refactor, nhưng nếu để CI "build tất cả" mỗi lần commit, pipeline sẽ biến thành cổ chai kéo cả team đi xuống. Giải pháp cốt lõi là pattern "affected only": dùng công cụ như Nx hoặc Turborepo để dựng dependency graph, so sánh với một base chính xác, rồi chỉ build/test những project thực sự bị ảnh hưởng. Kết hợp với caching (đặc biệt là remote cache dùng chung), bạn có thể cắt thời gian pipeline từ hàng chục phút xuống vài phút, đồng thời giảm mạnh chi phí CI.
Ba điều cần khắc cốt ghi tâm: (1) luôn lấy đủ lịch sử git và xác định base SHA đúng, nếu không affected sẽ tính sai; (2) khai báo đầy đủ input toàn cục để không bỏ sót test; (3) đảm bảo build deterministic để cache đáng tin cậy. Làm đúng ba điều này, monorepo 50 service của bạn sẽ chạy CI nhanh và gọn như thể chỉ có một service.