Tối Ưu CI/CD Monorepo: Giải quyết 3 bài toán thực tế khi scale team
Mới đây có tư vấn (chém gió nói thế cho oách các bác) cho ông bạn công ty có hệ thống cũng cổ cổ. Có vấn đề đáng chia sẻ nghĩ rằng giúp ích được các anh em chưa nhiều kinh nghiệm setup CI/CD đang vận hành dự án Monorepo nên bác nào thấy cần và phù hợp thì có thể xem thử nhé.
Mô hình Monorepo thì rất quen thuộc rồi đơn giản là toàn bộ dự án (Frontend, Backend, Shared Libraries) nằm trong một repository duy nhất, cấu trúc mã nguồn ban đầu trông nhỏ gọn. Việc quản lý mã nguồn dùng chung trở nên thuận tiện, chia sẻ package nội bộ dễ dàng mà không cần phân tách ra nhiều repository riêng biệt.

Nhưng mà mọi chuyện bắt đầu phức tạp hơn khi team đông lên. Khi có khoảng 5-10 PR cùng merge một lúc, hệ thống CI/CD truyền thống sẽ nhanh chóng trở thành một vấn đề đáng nói:
- Sửa 1 dòng CSS ở Frontend nhưng pipeline vẫn trigger build lại cả Backend.
- Chạy lại toàn bộ Unit Test của cả 20 dự án con dù chúng không hề thay đổi.
- Build lại hàng chục Docker image từ đầu, làm nghẽn băng thông mạng và disk I/O của runner.
Kết quả là quy trình CI/CD bị chậm, thời gian nhận feedback kéo dài hơn rất nhiều.
Thực tế, vấn đề thường không nằm ở cấu hình phần cứng của runner, mà nằm ở cách thiết kế pipeline: hệ thống CI/CD thông thường không tự hiểu được mối liên hệ phụ thuộc giữa các dự án con để đưa ra quyết định chỉ build những gì thực sự thay đổi.
Vậy làm sao để pipeline tự hiểu được mối liên kết này để chỉ chạy đúng những gì thay đổi? Cách thì có rất nhiều nên tùy vào tài nguyên và bối cảnh dưới đây là một trong những cách mọi người có thể cân nhắc nhé.
Tại sao CI/CD truyền thống khó xử lý Monorepo?
Khi chạy Monorepo, các pipeline thông thường (GitLab CI, GitHub Actions) sẽ gặp 5 điểm nghẽn chính:
- Không hiểu được dependencies graph: Các rule lọc thay đổi tĩnh (như
rules:changes) không tự hiểu mối quan hệ giữa các dự án. Khi một shared library thay đổi, CI không biết tự động build lại các app phụ thuộc. - Không có Remote Caching: Việc tái sử dụng kết quả build/test giữa các nhánh khác nhau gặp nhiều hạn chế. CI bắt buộc phải chạy lại mọi thứ từ đầu, dù phần lớn code không đổi.
- Pipeline Stage-Based cứng nhắc: Bắt buộc chạy tuần tự từng stage (
lint>test>build) khiến các dự án độc lập phải chờ đợi nhau vô ích thay vì chạy song song. - Nghẽn Disk I/O do cache quá nặng: Thư mục
node_modulescủa Monorepo rất lớn. Việc nén/giải nén cache sau mỗi job ngốn nhiều thời gian và làm nghẽn disk I/O của runner. - Trùng lặp tác vụ kiểm thử: Code đã test và build thành công ở nhánh Merge Request (PR) vẫn bị chạy lại toàn bộ khi gộp vào nhánh main, gây lãng phí tài nguyên của runner.
Giải pháp: Task-Based Pipeline và Remote Caching
Để tối ưu, chúng ta cần chuyển đổi quy trình CI/CD từ mô hình Stage-Based (chạy tuần tự từng giai đoạn) sang Task-Based (chạy độc lập dựa trên đồ thị tác vụ) bằng cách kết hợp với Nx hoặc Turborepo:
- Stage-Based (Cũ): Bắt buộc đi qua
Lint -> Test -> Build. App A chạy test xong trong 10 giây vẫn phải đợi App B test mất 10 phút mới được sang stage Build. - Task-Based (Mới): Tác vụ độc lập. App A test xong 10 giây sẽ tự động chuyển sang build ngay, không cần chờ đợi App B.
Giải pháp này dựa trên hai trụ cột chính:
1. Cơ chế Affected (Chỉ build/test những dự án bị ảnh hưởng)
Bình thường, hệ thống CI không biết file nào liên quan đến dự án nào. Nx và Turborepo giải quyết bằng cách dựng một bản đồ liên kết (Dependency Graph) giữa các dự án.
Để dễ hình dung, giả sử Monorepo của anh em có cấu trúc như sau:
├── apps/
│ ├── admin-portal/ # Có sử dụng shared-ui
│ └── landing-page/ # Có sử dụng shared-ui
└── libs/
└── shared-ui/ # Thư viện UI dùng chung
Kịch bản hoạt động:
- Sửa code ở
landing-page: Chỉ chạy build/test cholanding-page. Dự ánadmin-portalđược bỏ qua hoàn toàn. - Sửa code ở
shared-ui: Tự động kích hoạt build/test cho cảadmin-portalvàlanding-pagevì cả hai đều dùng chung thư viện này.
Lệnh chạy thực tế:
Với Nx:
# Chỉ chạy test cho các dự án bị ảnh hưởng bởi thay đổi so với main
npx nx affected -t test --base=origin/main
Với Turborepo:
# Dấu ... giúp tự động chạy test cho cả các dự án phụ thuộc phía sau
pnpm turbo run test --filter=...[origin/main]
2. Tận dụng Remote Caching (Bộ nhớ đệm dùng chung)
Remote Caching giúp lưu lại kết quả của các task đã chạy thành công (file build và log) lên một server cache chung dưới dạng mã hash (được tính từ source code, cấu hình và biến môi trường).
- Lần 1 (CI/Local): Chạy build thành công -> Đẩy cache lên Cloud.
- Lần 2 (Nhánh main/Dev khác): Nếu code không đổi -> Tải cache về trong 1-2 giây, bỏ qua bước build.
Log hiển thị khi hit cache:
- Với Turborepo:
admin-portal:build - cache hit, replaying output - Với Nx:
admin-portal:build [existing outputs match the cache, left as is]
Lưu ý thực tế khi triển khai Remote Cache:
- Bảo mật: Nếu công ty cấm đẩy artifacts lên Cloud ngoài, anh em có thể tự dựng Self-hosted Remote Cache (dùng AWS S3 hoặc MinIO) qua plugin
nx-remotecache-s3(với Nx) hoặc customTURBO_API(với Turborepo). - Tránh Cache Miss: Đồng nhất phiên bản Node.js giữa các máy (dùng
.nvmrc) và khai báo đầy đủ các biến môi trường ảnh hưởng đến build trongnx.jsonhoặcturbo.json.
Cấu hình GitLab CI Example cho Monorepo
Dưới đây là một cấu hình mẫu thu gọn trên GitLab CI giúp anh em hình dung cách tự động xác định commit mốc và chạy test cho các dự án bị ảnh hưởng:
variables:
# Lấy đầy đủ lịch sử git để so sánh thay đổi
GIT_DEPTH: 0
PNPM_CACHE_DIR: "$CI_PROJECT_DIR/.pnpm-store"
test-affected:
stage: test
image: node:18-alpine
before_script:
- npm i -g pnpm
- pnpm config set store-dir $PNPM_CACHE_DIR
- pnpm install --frozen-lockfile
# Tải nhánh đích để tìm commit mốc và đối chiếu thay đổi
- |
if [ -n "$CI_MERGE_REQUEST_TARGET_BRANCH_NAME" ]; then
git fetch origin $CI_MERGE_REQUEST_TARGET_BRANCH_NAME
export BUILD_BASE="origin/$CI_MERGE_REQUEST_TARGET_BRANCH_NAME"
else
git fetch origin main
export BUILD_BASE="HEAD~1"
fi
script:
# Chạy test cho các dự án bị ảnh hưởng (Ví dụ cho Nx)
- pnpm nx affected -t test --base=$BUILD_BASE
# Hoặc với Turborepo:
# - pnpm turbo run test --filter=...[$BUILD_BASE]
cache:
key:
files:
- pnpm-lock.yaml
paths:
- .pnpm-store/
3 vấn đề thực tế thường gặp và giải pháp
Vấn đề 1: node_modules quá nặng làm chậm cache CI
- Vấn đề: Thư mục
node_modulestrong Monorepo thường nặng vài GB. Nén và giải nén cachenode_modulesgiữa các job sẽ gây nghẽn đĩa và làm chậm pipeline. - Giải pháp: Không cache
node_modules. Hãy dùngpnpmvà chỉ cache thư mục.pnpm-store(như cấu hình GitLab CI ở trên).pnpmdùng link cứng (hard link) nên cài đặt cực nhanh mà không cần lưu thư mụcnode_modulescồng kềnh.
Vấn đề 2: Docker build mất cache layer
- Vấn đề: Lệnh
COPY . .mang toàn bộ code Monorepo vào Docker. Chỉ cần một app khác thay đổi, Docker sẽ bị mất cache ở bước cài đặt dependency của app hiện tại. - Giải pháp: Copy các file
package.jsoncủa toàn bộ dự án trong workspace để chạypnpm installtrước, sau đó mớiCOPY . .và chạy build riêng cho app cần đóng gói.
Dockerfile minh họa cơ bản:
FROM node:18-alpine AS builder
WORKDIR /app
# 1. Copy file package.json của root và toàn bộ các dự án con
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
COPY apps/admin-portal/package.json ./apps/admin-portal/
COPY apps/landing-page/package.json ./apps/landing-page/
COPY libs/shared-ui/package.json ./libs/shared-ui/
RUN npm install -g pnpm && pnpm install --frozen-lockfile
# 2. Copy toàn bộ source code và build riêng admin-portal
COPY . .
RUN pnpm --filter=admin-portal build
# Stage runner gọn nhẹ
FROM node:18-alpine AS runner
WORKDIR /app
COPY --from=builder /app/apps/admin-portal/dist ./dist
COPY --from=builder /app/apps/admin-portal/package.json .
EXPOSE 3000
CMD ["node", "dist/main.js"]
(Với Nx, anh em có thể dùng plugin
@nx/dependency-checkshoặc custom script để lọcpackage.jsontương tự).
Vấn đề 3: Lỗi ghi đè cache lỗi (Cache Poisoning)
- Vấn đề: Khi dùng chung Remote Cache, một commit bị lỗi hoặc chứa mã độc từ Merge Request (PR) có thể vô tình đẩy cache sai lệch lên server, khiến nhánh
mainkhi kéo cache về bị lỗi theo. - Giải pháp: Phân quyền Token Remote Cache dạng chỉ đọc cho các nhánh phụ, và chỉ cấp quyền ghi trên các nhánh được bảo vệ (
main,release) thông qua biến môi trường ẩn.
Ví dụ script cấu hình động trên CI:
# Mặc định sử dụng token chỉ đọc (Read-only)
export TURBO_TOKEN=$TURBO_TOKEN_READ_ONLY
export NX_CLOUD_ACCESS_TOKEN=$NX_CLOUD_READ_ONLY
# Nếu là nhánh được bảo vệ (Protected Branch), chuyển sang token có quyền ghi
if [ "$CI_COMMIT_REF_PROTECTED" = "true" ]; then
export TURBO_TOKEN=$TURBO_TOKEN_READ_WRITE
export NX_CLOUD_ACCESS_TOKEN=$NX_CLOUD_READ_WRITE
fi
Kết luận
Tóm lại, Theo mình thì Monorepo cần chúng ta phải thay đổi cả cách thiết kế CI/CD. Thay vì giải quyết bài toán hiệu năng bằng cách nâng cấp cấu hình phần cứng runner, giải pháp bền vững hơn là làm cho pipeline trở nên thông minh hơn: chỉ chạy các tác vụ thực sự cần thiết và tận dụng Remote Cache.
Hy vọng những chia sẻ trên sẽ giúp anh em được phần nào tối ưu pipeline, giữ cho runner hoạt động ổn định và giảm thiểu thời gian chờ đợi khi release.