CI/CDCI/CD
Thuộc bộ tài liệu DevOps Roadmap.
Tổng quan
CI/CD (Continuous Integration / Continuous Delivery / Continuous Deployment) là thực hành tự động hóa toàn bộ con đường từ một thay đổi code đến phần mềm chạy trên production. Thay vì tích hợp code theo từng đợt lớn đầy rủi ro và deploy thủ công vài lần mỗi năm, team merge các thay đổi nhỏ thường xuyên, kiểm chứng từng thay đổi bằng pipeline tự động, và release theo cách lặp lại được, ít “kịch tính”. CI/CD chính là động cơ giúp các thực hành DevOps khác — trunk-based development, infrastructure as code, rollout dựa trên observability — thực sự phát huy giá trị.
Lợi ích kinh doanh nằm ở chỗ đạt được cả tốc độ lẫn độ an toàn cùng lúc. Nghiên cứu DORA liên tục chỉ ra rằng các team hiệu suất cao deploy thường xuyên hơn, với tỷ lệ lỗi khi thay đổi (change failure rate) thấp hơn và khôi phục nhanh hơn — chính vì mọi thay đổi đều đi qua cùng một chuỗi build, test và scan tự động. Một pipeline tốt biến câu hỏi “mình release được chưa?” từ một cuộc họp thành một dấu tick xanh.
Với một DevOps engineer, CI/CD là trách nhiệm cốt lõi hàng ngày: thiết kế pipeline, giữ cho pipeline nhanh và ổn định, bảo mật pipeline (pipeline nắm chìa khóa vào production), và chọn chiến lược deployment để giới hạn phạm vi ảnh hưởng khi có sự cố.
Mô hình tư duy: một “con đường đã trải nhựa”
Hãy hình dung CI/CD như một con đường đã trải nhựa (paved road) cho thay đổi. Mỗi commit đi vào cùng một làn đường, qua cùng các chốt kiểm tra, và đi ra hoặc là một artifact sẵn sàng deploy, hoặc là một thất bại nhanh và cụ thể. Giá trị không nằm ở bất kỳ công cụ đơn lẻ nào — nó nằm ở tính chất: chỉ có một cách duy nhất để ship, và cách đó là tự động, quan sát được, và đảo ngược được. Khi việc ship là một lối đi quen thuộc thay vì một sự kiện đặc biệt, chi phí biên cho mỗi lần release giảm về gần 0 — và chi phí thấp cho mỗi lần release chính là thứ mở khóa cho batch nhỏ, thứ mở khóa cho mọi lời hứa còn lại của DevOps.
Hai lực mà một pipeline phải cân bằng là tốc độ phản hồi (developer biết thay đổi của mình tốt hay xấu nhanh đến đâu) và độ tin cậy (bạn chắc chắn đến mức nào rằng “màu xanh” nghĩa là “an toàn để ship”). Hầu như mọi quyết định thiết kế pipeline đều là sự đánh đổi giữa hai lực này.
Kiến thức nền tảng
CI vs Delivery vs Deployment
| Thực hành | Ý nghĩa | Cần con người duyệt trước prod? |
|---|---|---|
| Continuous Integration | Developer merge vào nhánh chung ít nhất mỗi ngày; mỗi lần merge kích hoạt build + test tự động | Không áp dụng (trước release) |
| Continuous Delivery | Mọi thay đổi qua được pipeline đều tạo ra artifact sẵn sàng deploy; release chỉ là một nút bấm | Có — duyệt thủ công |
| Continuous Deployment | Mọi thay đổi pass pipeline được deploy lên production tự động | Không — hoàn toàn tự động |
Ba mức này mang tính tích lũy: không thể làm Continuous Deployment nếu chưa vững Continuous Delivery, và không thể làm Delivery nếu chưa có CI thực sự (merge nhỏ, test nhanh và đáng tin).
Một phân biệt cực kỳ quan trọng nhưng thường bị bỏ sót nằm bên dưới tất cả những điều trên: deploy không giống release. Deploy nghĩa là code mới đang chạy trên hạ tầng production; release nghĩa là người dùng được tiếp cận với nó. Feature flag, canary, và traffic shifting tồn tại chính là để tách hai sự kiện này, để bạn có thể deploy liên tục (hoạt động kỹ thuật, rủi ro thấp) trong khi release theo lịch kinh doanh (quyết định sản phẩm, blast radius kiểm soát được).
Continuous Integration thực sự cần những gì
“Bọn em dùng GitHub Actions” không đồng nghĩa với “bọn em làm CI.” CI thực thụ có các điều kiện tiên quyết:
- Merge nhỏ, thường xuyên vào một trunk chung. Nhánh sống hàng tuần là điều ngược lại với continuous integration — đó là tích hợp bị trì hoãn, và nỗi đau khi merge chỉ bị dời lại và cộng dồn.
- Bộ test nhanh và đáng tin. Nếu test chậm hoặc flaky, developer sẽ ngừng tin vào màu đỏ và bắt đầu phớt lờ nó. Test flaky còn tệ hơn không có test, vì nó dạy cả team ghi đè lên tín hiệu.
- Trunk xanh là chuẩn mực của team. Main hỏng chặn tất cả mọi người. Khế ước xã hội là: ai làm hỏng thì fix hoặc revert ngay lập tức.
Cấu trúc một pipeline
Một pipeline điển hình chạy các stage sau, “fail fast” ở từng bước:
- Build — biên dịch code, resolve dependency, tạo binary/container image.
- Test — unit test trước (nhanh), rồi integration/contract test; thu thập coverage.
- Scan — phân tích tĩnh (SAST), quét dependency/CVE (SCA), phát hiện secret, quét container image, kiểm tra license.
- Package — đánh version cho artifact (image tag, semver, git SHA) và đẩy lên registry/artifact store.
- Deploy — triển khai artifact ra môi trường (staging → production), lý tưởng là luôn qua cùng một cơ chế.
- Verify — smoke test, health check, và kiểm chứng dựa trên monitoring sau khi deploy.
Nguyên tắc then chốt: build một lần, deploy nhiều lần. Chính artifact đã test ở staging được promote lên production — không bao giờ build lại theo từng môi trường.
Kim tự tháp test bên trong pipeline
Sắp xếp test theo chi phí và tốc độ là thứ giữ cho vòng phản hồi luôn ngắn:
| Tầng | Tốc độ | Số lượng | Chạy ở đâu |
|---|---|---|---|
| Unit | Mili-giây | Hàng nghìn | Mỗi lần push, chạy đầu tiên |
| Integration / contract | Vài giây | Hàng trăm | Mỗi lần push, sau unit |
| End-to-end (e2e) | Vài phút | Vài chục | Khi merge / trước deploy |
| Thủ công / khám phá | Thời gian người | Rất ít | Hiếm, chỉ cho luồng giá trị cao |
Một kim tự tháp ngược — nhiều e2e chậm, ít unit test — là nguyên nhân phổ biến nhất của một pipeline 45 phút mà ai cũng ghét. Hãy đẩy các assertion xuống tầng rẻ nhất có thể kiểm chứng được chúng.
Từ vựng pipeline
Các tool khác nhau dùng từ khác nhau cho cùng một hình dạng. Học bản đồ ánh xạ này tránh được nhiều nhầm lẫn:
| Khái niệm | GitHub Actions | GitLab CI | Jenkins |
|---|---|---|---|
| Toàn bộ automation | Workflow | Pipeline | Pipeline |
| Giai đoạn / nhóm | Job | Stage | Stage |
| Đơn vị công việc | Step | Job | Step |
| Máy thực thi | Runner | Runner | Agent / Node |
| Cơ chế tái sử dụng | Composite / reusable workflow | include / template | Shared library |
Promote artifact giữa các môi trường
Promotion nghĩa là di chuyển một artifact bất biến, có version qua các môi trường thay vì build lại:
- 01git push
- 02CI build imagemyapp:sha-a1b2c3
- push lên registry03registry
- tự động04deploy sha-a1b2c3 lên dev
- tự động, chạy e2e test05deploy sha-a1b2c3 lên staging
- duyệt hoặc tự động06deploy sha-a1b2c3 lên prod
Khác biệt cấu hình giữa các môi trường đến từ config được inject (env var, config map, parameter store), không bao giờ đến từ một bản build khác. Các registry thường mô hình hóa việc này bằng các tầng repository (ví dụ repo dev → staging → release) hoặc bằng immutable tag kèm metadata theo dõi môi trường.
Vì sao không bao giờ build lại theo môi trường: nếu bạn docker build lại cho prod, bạn có thể kéo về package base-layer mới hơn, một transitive dependency khác, hay một compiler khác — nên artifact prod không phải là artifact bạn đã test. “Build một lần, promote đúng những byte đó” là cách duy nhất để câu “nó pass ở staging” thực sự có ý nghĩa đối với production.
Khái niệm chính
So sánh công cụ
| Công cụ | Hosting | Định dạng config | Điểm mạnh | Cần lưu ý |
|---|---|---|---|---|
| GitHub Actions | SaaS (+ self-hosted runner) | YAML trong .github/workflows/ | Marketplace khổng lồ, tích hợp chặt với GitHub, OIDC lên cloud | Rủi ro supply-chain từ action bên thứ ba; nên pin action theo SHA |
| GitLab CI | SaaS hoặc self-managed | .gitlab-ci.yml | Nền tảng DevOps all-in-one, registry tích hợp, environments/review apps | Quản lý runner ở quy mô lớn (self-managed) |
| Jenkins | Self-hosted | Jenkinsfile (Groovy) | Linh hoạt vô hạn, hệ sinh thái plugin khổng lồ, thân thiện on-prem | Plugin phình to, gánh nặng bảo trì, controller “snowflake” |
| CircleCI | SaaS (+ self-hosted runner) | .circleci/config.yml | Nhanh, caching/parallelism tốt, orbs để tái sử dụng | Chi phí ở quy mô lớn; hệ sinh thái nhỏ hơn GHA |
| Azure DevOps Pipelines | SaaS hoặc Server | azure-pipelines.yml | Tích hợp sâu với Azure/doanh nghiệp, boards+repos+artifacts trong một | UI phức tạp; tồn tại song song pipeline classic và YAML |
Cũng nên biết: Argo CD / Flux (CD theo kiểu GitOps cho Kubernetes — pipeline chỉ build và push, controller sẽ pull và sync), Tekton (các khối xây pipeline native trên Kubernetes), và Buildkite / Drone (mô hình self-hosted agent, phổ biến khi bạn muốn control plane SaaS nhưng dùng compute riêng).
Deploy kiểu CI-push vs GitOps-pull
Có hai triết lý cho chữ “D” trong CD:
- Push (pipeline điều khiển). Pipeline nắm credentials vào target và chạy
kubectl apply/terraform apply/ một script deploy. Đơn giản, tuyến tính, dễ suy luận. Nhược điểm: hệ thống CI giữ credentials production, và trạng thái cluster có thể âm thầm drift khỏi thứ pipeline đã push lần cuối. - Pull (GitOps). Một controller trong cluster (Argo CD, Flux) theo dõi một git repo chứa manifest và liên tục reconcile cluster cho khớp. Nhiệm vụ của pipeline kết thúc ở “commit image tag mới vào config repo.” Lợi ích: không có credentials cluster trong CI, git là nguồn chân lý duy nhất, drift được tự sửa, và rollback chỉ là
git revert. Đây là pattern chủ đạo cho Kubernetes ở quy mô lớn.
Ví dụ: GitHub Actions workflow
# .github/workflows/ci.yml
name: ci
on:
push:
branches: [main]
pull_request:
permissions:
contents: read # mặc định least privilege
concurrency: # hủy các run cũ bị vượt trên cùng một ref
group: ci-${{ github.ref }}
cancel-in-progress: true
jobs:
build-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- run: npm ci
- run: npm test -- --coverage
- run: npm run lint
package-deploy:
needs: build-test
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
environment: staging # kích hoạt required reviewers / protection rules
permissions:
contents: read
id-token: write # OIDC federation lên cloud — không cần lưu key
packages: write
steps:
- uses: actions/checkout@v4
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789012:role/gha-deploy
aws-region: ap-southeast-1
- run: |
docker build -t $ECR_REPO:${GITHUB_SHA::7} .
docker push $ECR_REPO:${GITHUB_SHA::7}
- run: ./scripts/deploy.sh staging ${GITHUB_SHA::7}
Hai điểm bổ sung đáng chú ý: concurrency ngăn một hàng đợi các run cũ chất đống khi ai đó push liên tục, và khóa environment: gắn job deploy với protection rules của GitHub environment (required reviewers, wait timers, giới hạn branch).
Ví dụ: cấu hình GitLab CI
# .gitlab-ci.yml
stages: [build, test, scan, deploy]
default:
tags: [docker] # nhắm tới một lớp runner
build:
stage: build
image: docker:27
services: [docker:27-dind]
script:
- docker build -t $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA .
- docker push $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA
unit-test:
stage: test
image: node:22
script:
- npm ci
- npm test
coverage: '/Lines\s*:\s*(\d+\.\d+)%/' # hiển thị coverage trên UI
sast:
stage: scan
# hoặc dùng template SAST có sẵn của GitLab:
# include: - template: Jobs/SAST.gitlab-ci.yml
script: [ "echo chạy scanner tại đây" ]
deploy-staging:
stage: deploy
environment: staging
script: ./scripts/deploy.sh staging $CI_COMMIT_SHORT_SHA
rules:
- if: '$CI_COMMIT_BRANCH == "main"'
deploy-prod:
stage: deploy
environment: production
when: manual # cổng duyệt Continuous Delivery
script: ./scripts/deploy.sh prod $CI_COMMIT_SHORT_SHA
rules:
- if: '$CI_COMMIT_BRANCH == "main"'
Lưu ý sự chuyển từ từ khóa cũ only: sang rules: — cách hiện đại, biểu đạt mạnh hơn để điều khiển khi nào job chạy trong GitLab.
Ví dụ: Jenkins declarative pipeline
Jenkins vẫn phổ biến trong doanh nghiệp và on-prem, nên nhận diện được hình dạng của nó là điều quan trọng:
// Jenkinsfile
pipeline {
agent { label 'docker' }
options { timeout(time: 20, unit: 'MINUTES') }
stages {
stage('Build') { steps { sh 'make build' } }
stage('Test') { steps { sh 'make test' }
post { always { junit 'reports/**/*.xml' } } }
stage('Deploy') {
when { branch 'main' }
steps {
withCredentials([string(credentialsId: 'deploy-token', variable: 'TOKEN')]) {
sh './scripts/deploy.sh staging'
}
}
}
}
}
Chiến lược deployment
| Chiến lược | Cách hoạt động | Ưu điểm | Nhược điểm | Rollback |
|---|---|---|---|---|
| Recreate | Tắt hết bản cũ, khởi động bản mới | Cực đơn giản; không chồng version | Downtime trong lúc chuyển | Deploy lại bản cũ |
| Rolling | Thay thế các instance từng nhóm một | Không cần thêm tài nguyên, mặc định trong Kubernetes | Hai version chạy song song; rollback giữa chừng chậm | Roll ngược tiếp |
| Blue-green | Hai môi trường giống hệt nhau; chuyển toàn bộ traffic một lần | Cutover và rollback tức thì; test được green trước khi chuyển | Chi phí hạ tầng gấp đôi; schema DB phải phục vụ cả hai | Chuyển traffic ngược |
| Canary | Đưa một % nhỏ traffic sang version mới, theo dõi metrics, tăng dần | Kiểm chứng bằng traffic thật, giới hạn phạm vi ảnh hưởng | Cần traffic splitting + metrics tốt; rollout chậm hơn | Đưa traffic về 0% |
| Feature flags | Deploy “ngầm”, bật theo user/segment lúc runtime | Tách deploy khỏi release; kill switch tức thì; A/B testing | Nợ flag tích tụ; ma trận test phình to; cần dịch vụ quản lý flag | Tắt flag |
Trong thực tế các chiến lược này kết hợp với nhau: continuous deployment code “ngầm” sau feature flag, rollout bằng canary, và tự động rollback khi error budget bị đốt quá nhanh.
Chọn giữa chúng như thế nào:
- Service stateless, nhạy chi phí, mặc định Kubernetes → rolling.
- Cần rollback tức thì và sạch, có thể chịu gấp đôi tài nguyên → blue-green.
- Blast radius lớn, muốn độ tin cậy thống kê từ traffic thật → canary (lý tưởng là tự động hóa với phân tích metric, ví dụ Argo Rollouts + Prometheus).
- Kinh doanh muốn kiểm soát ai thấy tính năng và khi nào, độc lập với deploy → feature flags.
Cái bẫy schema database
Mỗi chiến lược trên đều chạy hai version code cùng lúc (kể cả blue-green, dù chỉ trong khoảnh khắc). Điều đó nghĩa là schema database phải tương thích với cả code cũ lẫn code mới trong lúc chuyển đổi. Kỹ thuật an toàn là expand/contract (hay parallel change):
- Expand — thêm cột/bảng mới trong một migration tương thích ngược. Code cũ bỏ qua nó.
- Migrate + deploy — ship code ghi vào cả cũ lẫn mới, đọc mới với fallback.
- Contract — khi tất cả instance đã chạy code mới và dữ liệu đã backfill, một release sau sẽ xóa cột cũ.
Đừng bao giờ gộp một thay đổi schema mang tính phá hủy với một lần deploy code trong cùng một bước — điều đó ghép hai thao tác rủi ro độc lập lại và khiến rollback trở nên bất khả thi.
Bảo mật pipeline
Pipeline là mục tiêu giá trị cao — nó nắm credentials vào registry, cloud và production. Một pipeline bị xâm nhập là một production bị xâm nhập.
- Token least-privilege. Giới hạn scope token CI theo từng job (
permissions:trong GitHub Actions, protected variables trong GitLab). Job lint không cần quyềnpackages: write. Mặc định cả workflow ởcontents: readvà chỉ cấp thêm ở nơi cần. - OIDC federation lên cloud. Thay vì lưu key cloud dài hạn dưới dạng secret, CI provider phát hành token OIDC ngắn hạn để cloud (AWS IAM, GCP Workload Identity, Azure) đổi lấy credentials tạm thời, giới hạn theo repo/branch/environment cụ thể qua claim
subcủa token. Không còn static secret để rò rỉ hay phải xoay vòng. - Xử lý secret. Dùng secret store của nền tảng hoặc vault bên ngoài; mask secret trong log; không bao giờ echo secret; không bao giờ đưa secret vào workflow được kích hoạt bởi PR không tin cậy.
pull_request_targetvà self-hosted runner trên repo public là những cái bẫy kinh điển vì chúng chạy với ngữ cảnh đặc quyền trên code do kẻ tấn công viết. - Supply chain. Pin action/orb bên thứ ba theo full commit SHA (một tag như
@v4là mutable và có thể bị trỏ lại bởi kẻ tấn công kiểm soát repo action), verify checksum của tool tải về, sinh SBOM, và cân nhắc provenance/ký artifact (Sigstore cosign, các mức SLSA). - Protected environments. Yêu cầu review cho deploy production; hạn chế ai được sửa file workflow và cấu hình runner. Coi thư mục
.github/workflowsvà config CI như code production với review bắt buộc. - Cô lập build khỏi deploy. Job chạy code test không tin cậy (dependency, đóng góp từ PR) không nên nắm credentials deploy. Tách “build/test” (tin cậy thấp) khỏi “deploy” (tin cậy cao, trigger giới hạn) thành các job riêng với quyền riêng.
Ví dụ trust OIDC (AWS)
Trust policy của IAM role ghim chính xác repo và branch nào được phép assume nó — đây chính là thứ làm OIDC an toàn:
{
"Effect": "Allow",
"Principal": { "Federated": "arn:aws:iam::123456789012:oidc-provider/token.actions.githubusercontent.com" },
"Action": "sts:AssumeRoleWithWebIdentity",
"Condition": {
"StringEquals": { "token.actions.githubusercontent.com:aud": "sts.amazonaws.com" },
"StringLike": { "token.actions.githubusercontent.com:sub": "repo:acme/myapp:ref:refs/heads/main" }
}
}
Một cấu hình sai phổ biến là điều kiện sub kiểu repo:acme/myapp:*, cho phép bất kỳ branch hay PR nào từ repo đó assume role — kể cả một PR từ fork của kẻ tấn công. Luôn ghim ref chặt nhất có thể theo tình huống sử dụng.
Các cạm bẫy thường gặp
- Coi test flaky là bình thường. Khi “chạy lại thử xem” trở thành văn hóa, bộ test đã ngừng là một tín hiệu. Cách ly và fix test flaky một cách quyết liệt.
- Pipeline 45 phút. Phản hồi chậm khiến người ta gom thay đổi thành batch và chuyển ngữ cảnh đi nơi khác. Song song hóa, cache, shard test, và cắt bớt số lượng e2e.
- Build lại theo môi trường. Đã bàn ở trên — bạn ship thứ mình chưa từng test.
- Deploy thủ công kiểu “chỉ lần này thôi”. Bản hotfix khẩn cấp bỏ qua pipeline lại chính là thay đổi cần các kiểm tra của pipeline nhất.
- Secret trong log / env dump. Một lệnh
set -xhoặc inenvtrong bước bị lỗi có thể rò rỉ mọi secret cho bất kỳ ai đọc được log build. - Deploy chiều thứ Sáu mà không ai theo dõi. Không phải quy tắc kỹ thuật mà là vận hành: hãy deploy khi bạn có thể quan sát và phản ứng.
Best Practices
- Giữ pipeline nhanh (< 10 phút cho tín hiệu đầu tiên). Pipeline chậm phá hủy vòng phản hồi mà CI sinh ra; chạy test song song, cache dependency triệt để, và chỉ chạy các bộ test đắt khi các bộ rẻ đã pass.
- Build một lần, promote cùng một artifact ở mọi nơi. Build lại theo từng môi trường nghĩa là bạn chưa bao giờ thực sự test thứ mình ship; promote đúng những byte bất biến, inject config lúc deploy.
- Giữ nhánh main luôn ở trạng thái release được. Main bị hỏng là sự kiện “dừng dây chuyền”; fix hoặc revert ngay lập tức, vì mọi người khác đang bị chặn phía sau.
- Chạy các check rẻ nhất trước. Lint và unit test trước integration test, trước e2e — fail nhanh, tiết kiệm tài nguyên, và cho developer lỗi cụ thể nhất có thể.
- Đánh version bất biến cho mọi thứ. Tag artifact bằng git SHA; không bao giờ dựa vào tag ghi đè được như
latestđể quyết định deploy, vì bạn mất khả năng nói chính xác cái gì đang chạy. - Dùng OIDC thay vì lưu credentials cloud. Ngắn hạn, giới hạn scope, audit được — loại bỏ thảm họa rò rỉ secret CI phổ biến nhất và bỏ đi công việc xoay vòng key.
- Pin và audit dependency bên thứ ba của pipeline. Action/orb/plugin là code thực thi trong vùng tin cậy của bạn; pin theo full commit SHA và review khi cập nhật như mọi dependency khác.
- Tách deploy khỏi release. Dùng flag/canary để ship code và phơi bày nó cho người dùng là hai quyết định độc lập với blast radius độc lập.
- Gác cổng production bằng tự động hóa, không phải bằng họp. Mã hóa các quality gate (test, scan, error budget) vào pipeline; chỉ giữ duyệt thủ công ở nơi quy định hoặc rủi ro thực sự đòi hỏi — một cái duyệt cho có là diễn kịch.
- Deploy nhỏ và thường xuyên. Diff nhỏ dễ review, dễ test, dễ rollback; tần suất deploy và độ an toàn cùng cải thiện, không mâu thuẫn.
- Biến rollback thành con đường hạng nhất, được diễn tập. Một lệnh duy nhất (hoặc tự động khi health check fail) để quay về artifact trước đó — và luyện tập nó, để nó hoạt động cả khi căng thẳng.
- Thiết kế thay đổi database theo kiểu expand/contract. Giữ mọi migration tương thích ngược với code đang chạy để rollout và rollback luôn an toàn.
- Coi cấu hình pipeline là code. Review nó, test nó, giữ nó trong repo mà nó phục vụ; tránh click cấu hình trên UI mà không ai tái tạo hay audit lại được.
- Cô lập code không tin cậy khỏi credentials đặc quyền. Job chạy code dependency và đóng góp từ PR không được phép chạm tới production.
- Làm cho thất bại rõ ràng và cụ thể. Một build đỏ nên chỉ vào đúng test bị fail, không đổ ra một bức tường log; đầu tư vào output test tốt và annotation.
- Đo lường pipeline bằng DORA metrics. Deployment frequency, lead time for changes, change failure rate và MTTR cho biết đầu tư CI/CD của bạn có thực sự hiệu quả không — và nút thắt nằm ở đâu.
Tài liệu tham khảo
- roadmap.sh — DevOps Roadmap
- Tài liệu GitHub Actions
- GitHub Actions — security hardening
- Tài liệu GitLab CI/CD
- Tài liệu Jenkins
- Jenkins Pipeline syntax
- Tài liệu CircleCI
- Tài liệu Azure Pipelines
- OpenID Connect trong GitHub Actions
- Tài liệu Argo CD
- Tài liệu Flux
- Argo Rollouts (progressive delivery)
- Nghiên cứu DORA — dora.dev
- SLSA — framework bảo mật supply chain
- Sigstore / cosign
- Martin Fowler — Continuous Integration
- Martin Fowler — BlueGreenDeployment
- Martin Fowler — CanaryRelease
- Sách: Continuous Delivery — Jez Humble & David Farley
- Sách: Accelerate — Nicole Forsgren, Jez Humble, Gene Kim
Part of the DevOps Roadmap knowledge base.
Overview
CI/CD (Continuous Integration / Continuous Delivery / Continuous Deployment) is the practice of automating the path from a code change to running software. Instead of integrating work in large, risky batches and deploying manually a few times a year, teams merge small changes frequently, verify each one with an automated pipeline, and ship to production in a repeatable, low-drama way. CI/CD is the engine that makes the other DevOps practices — trunk-based development, infrastructure as code, observability-driven rollouts — actually pay off.
The business case is speed and safety at the same time. The DORA research program consistently shows that elite performers deploy more often, with lower change-failure rates and faster recovery — precisely because every change flows through the same automated gauntlet of builds, tests, and scans. A good pipeline turns “can we release?” from a meeting into a green checkmark.
For a DevOps engineer, CI/CD is a core daily responsibility: designing pipelines, keeping them fast and reliable, securing them (pipelines hold the keys to production), and choosing deployment strategies that limit blast radius when things go wrong.
The mental model: a paved road
Think of CI/CD as a paved road for change. Every commit enters the same lane, passes the same checkpoints, and exits as either a deployable artifact or a fast, specific failure. The value is not any single tool — it is the property that there is only one way to ship, and it is automated, observable, and reversible. When shipping is a well-worn path rather than a bespoke event, the marginal cost of a release drops toward zero, and low cost per release is what unlocks small batches, which is what unlocks everything else DevOps promises.
The two forces a pipeline balances are feedback speed (how fast a developer learns their change is good or bad) and confidence (how sure you are that “green” means “safe to ship”). Almost every pipeline design decision is a trade between these two.
Fundamentals
CI vs Delivery vs Deployment
| Practice | What it means | Human gate before prod? |
|---|---|---|
| Continuous Integration | Developers merge to a shared branch at least daily; every merge triggers automated build + tests | N/A (pre-release) |
| Continuous Delivery | Every change that passes the pipeline produces a deployable artifact; releasing is a button press | Yes — manual approval |
| Continuous Deployment | Every passing change is deployed to production automatically | No — fully automated |
The progression is cumulative: you cannot do Continuous Deployment without solid Continuous Delivery, and you cannot do Delivery without real CI (small merges, fast trustworthy tests).
A crucial and often-missed distinction sits underneath all of this: deploy is not the same as release. Deploy means the new code is running on production infrastructure; release means users are exposed to it. Feature flags, canaries, and traffic shifting exist precisely to separate these two events, so you can deploy continuously (technical activity, low risk) while releasing on a business schedule (product decision, controllable blast radius).
What real Continuous Integration requires
“We use GitHub Actions” is not the same as “we do CI.” Genuine CI has preconditions:
- Small, frequent merges to a shared trunk. Branches that live for weeks are the opposite of continuous integration — they are deferred integration, and the merge pain is just postponed and compounded.
- A fast, trustworthy test suite. If tests are slow or flaky, developers stop trusting red and start ignoring it. Flaky tests are worse than no tests because they train the team to override the signal.
- A green trunk as a team norm. Broken
mainblocks everyone. The social contract is: if you break it, you fix it or revert immediately.
Anatomy of a pipeline
A typical pipeline runs these stages, failing fast at each step:
- Build — compile code, resolve dependencies, produce a binary/container image.
- Test — unit tests first (fast), then integration/contract tests; collect coverage.
- Scan — static analysis (SAST), dependency/CVE scanning (SCA), secret detection, container image scanning, license checks.
- Package — version the artifact (image tag, semver, git SHA) and push it to a registry/artifact store.
- Deploy — roll the artifact out to an environment (staging → production), ideally via the same mechanism every time.
- Verify — smoke tests, health checks, and monitoring-based validation after deploy.
Key principle: build once, deploy many. The exact artifact tested in staging is the one promoted to production — never rebuilt per environment.
The test pyramid inside the pipeline
Ordering tests by cost and speed is what keeps the feedback loop tight:
| Layer | Speed | Quantity | Runs where |
|---|---|---|---|
| Unit | Milliseconds | Thousands | Every push, first |
| Integration / contract | Seconds | Hundreds | Every push, after unit |
| End-to-end (e2e) | Minutes | Dozens | On merge / pre-deploy |
| Manual / exploratory | Human time | Few | Rare, high-value paths only |
An inverted pyramid — many slow e2e tests, few unit tests — is the most common cause of a 45-minute pipeline that everyone hates. Push assertions down to the cheapest layer that can make them.
Pipeline vocabulary
Different tools use different words for the same shapes. Learning the mapping saves confusion:
| Concept | GitHub Actions | GitLab CI | Jenkins |
|---|---|---|---|
| Whole automation | Workflow | Pipeline | Pipeline |
| Phase / grouping | Job | Stage | Stage |
| Unit of work | Step | Job | Step |
| Execution machine | Runner | Runner | Agent / Node |
| Reuse mechanism | Composite / reusable workflow | include / template | Shared library |
Artifact promotion between environments
Promotion means moving an immutable, versioned artifact through environments rather than rebuilding:
- 01git push
- 02CI builds imagemyapp:sha-a1b2c3
- push to registry03registry
- automatic04deploy sha-a1b2c3 to dev
- automatic, run e2e tests05deploy sha-a1b2c3 to staging
- approval or automatic06deploy sha-a1b2c3 to prod
Configuration differences between environments come from injected config (env vars, config maps, parameter stores), never from a different build. Registries often model this with repository tiers (e.g., dev → staging → release repos) or with immutable tags plus environment-tracking metadata.
Why never rebuild per environment: if you docker build again for prod, you may pull newer base-layer packages, a different transitive dependency, or a different compiler — so the prod artifact is not the artifact you tested. “Build once, promote the same bytes” is the only way “it passed in staging” is a meaningful statement about production.
Key Concepts
Tool comparison
| Tool | Hosting | Config format | Strengths | Watch out for |
|---|---|---|---|---|
| GitHub Actions | SaaS (+ self-hosted runners) | YAML in .github/workflows/ | Huge marketplace, tight GitHub integration, OIDC to clouds | Third-party action supply-chain risk; pin actions by SHA |
| GitLab CI | SaaS or self-managed | .gitlab-ci.yml | Single-application DevOps platform, built-in registry, environments/review apps | Runner management at scale (self-managed) |
| Jenkins | Self-hosted | Jenkinsfile (Groovy) | Infinitely flexible, massive plugin ecosystem, on-prem friendly | Plugin sprawl, maintenance burden, snowflake controllers |
| CircleCI | SaaS (+ self-hosted runners) | .circleci/config.yml | Fast, good caching/parallelism, orbs for reuse | Cost at scale; smaller ecosystem than GHA |
| Azure DevOps Pipelines | SaaS or Server | azure-pipelines.yml | Deep Azure/enterprise integration, boards+repos+artifacts in one | UI complexity; classic vs YAML pipeline duality |
Also worth knowing: Argo CD / Flux (GitOps-style CD for Kubernetes — the pipeline builds and pushes, a controller pulls and syncs), Tekton (Kubernetes-native pipeline building blocks), and Buildkite / Drone (self-hosted-agent models popular where you want SaaS control plane but your own compute).
CI-push vs GitOps-pull deployment
There are two philosophies for the “D” in CD:
- Push (pipeline-driven). The pipeline has credentials to the target and runs
kubectl apply/terraform apply/ a deploy script. Simple, linear, easy to reason about. Downside: the CI system holds production credentials, and cluster state can silently drift from what the pipeline last pushed. - Pull (GitOps). An in-cluster controller (Argo CD, Flux) watches a git repo of manifests and continuously reconciles the cluster to match. The pipeline’s job ends at “commit the new image tag to the config repo.” Benefits: no cluster creds in CI, git is the single source of truth, drift is auto-corrected, and rollback is
git revert. This is the dominant pattern for Kubernetes at scale.
Example: GitHub Actions workflow
# .github/workflows/ci.yml
name: ci
on:
push:
branches: [main]
pull_request:
permissions:
contents: read # least privilege by default
concurrency: # cancel superseded runs on the same ref
group: ci-${{ github.ref }}
cancel-in-progress: true
jobs:
build-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- run: npm ci
- run: npm test -- --coverage
- run: npm run lint
package-deploy:
needs: build-test
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
environment: staging # enables required reviewers / protection rules
permissions:
contents: read
id-token: write # OIDC federation to cloud — no stored keys
packages: write
steps:
- uses: actions/checkout@v4
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789012:role/gha-deploy
aws-region: ap-southeast-1
- run: |
docker build -t $ECR_REPO:${GITHUB_SHA::7} .
docker push $ECR_REPO:${GITHUB_SHA::7}
- run: ./scripts/deploy.sh staging ${GITHUB_SHA::7}
Two additions worth calling out: concurrency prevents a queue of stale runs from piling up when someone pushes rapidly, and the environment: key ties the deploy job to GitHub’s environment protection rules (required reviewers, wait timers, branch restrictions).
Example: GitLab CI config
# .gitlab-ci.yml
stages: [build, test, scan, deploy]
default:
tags: [docker] # target a runner class
build:
stage: build
image: docker:27
services: [docker:27-dind]
script:
- docker build -t $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA .
- docker push $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA
unit-test:
stage: test
image: node:22
script:
- npm ci
- npm test
coverage: '/Lines\s*:\s*(\d+\.\d+)%/' # surface coverage in the UI
sast:
stage: scan
# use GitLab's built-in SAST template:
# include: - template: Jobs/SAST.gitlab-ci.yml
script: [ "echo run scanner here" ]
deploy-staging:
stage: deploy
environment: staging
script: ./scripts/deploy.sh staging $CI_COMMIT_SHORT_SHA
rules:
- if: '$CI_COMMIT_BRANCH == "main"'
deploy-prod:
stage: deploy
environment: production
when: manual # Continuous Delivery gate
script: ./scripts/deploy.sh prod $CI_COMMIT_SHORT_SHA
rules:
- if: '$CI_COMMIT_BRANCH == "main"'
Note the shift from the older only: keyword to rules:, which is the modern, more expressive way to control when jobs run in GitLab.
Example: Jenkins declarative pipeline
Jenkins remains ubiquitous in enterprises and on-prem, so recognizing its shape matters:
// Jenkinsfile
pipeline {
agent { label 'docker' }
options { timeout(time: 20, unit: 'MINUTES') }
stages {
stage('Build') { steps { sh 'make build' } }
stage('Test') { steps { sh 'make test' }
post { always { junit 'reports/**/*.xml' } } }
stage('Deploy') {
when { branch 'main' }
steps {
withCredentials([string(credentialsId: 'deploy-token', variable: 'TOKEN')]) {
sh './scripts/deploy.sh staging'
}
}
}
}
}
Deployment strategies
| Strategy | How it works | Pros | Cons | Rollback |
|---|---|---|---|---|
| Recreate | Stop all old, start all new | Dead simple; no version overlap | Downtime during the swap | Redeploy old |
| Rolling | Replace instances a few at a time | No extra capacity needed, default in Kubernetes | Two versions coexist; slow rollback mid-roll | Roll back forward |
| Blue-green | Two identical environments; switch traffic all at once | Instant cutover and rollback; test green before switch | 2x infrastructure cost; DB schema must serve both | Flip traffic back |
| Canary | Send a small % of traffic to the new version, watch metrics, ramp up | Real-traffic validation, limited blast radius | Needs traffic splitting + good metrics; slower rollout | Shift traffic to 0% |
| Feature flags | Deploy dark, enable per user/segment at runtime | Decouples deploy from release; instant kill switch; A/B testing | Flag debt; testing matrix grows; needs a flag service | Toggle flag off |
In practice these combine: continuous deployment of dark code behind flags, rolled out via canary, with automated rollback on error-budget burn.
Choosing between them:
- Stateless service, cost-sensitive, Kubernetes default → rolling.
- Need instant, clean rollback and can afford double capacity → blue-green.
- Large blast radius, you want statistical confidence from real traffic → canary (ideally automated with metric analysis, e.g. Argo Rollouts + Prometheus).
- Business wants to control who sees a feature and when, independent of deploys → feature flags.
The database schema trap
Every one of these strategies runs two versions of your code at once (even blue-green, momentarily). That means your database schema must be compatible with both the old and new code during the transition. The safe technique is expand/contract (a.k.a. parallel change):
- Expand — add the new column/table in a backward-compatible migration. Old code ignores it.
- Migrate + deploy — ship code that writes to both old and new, reads new with fallback.
- Contract — once all instances run the new code and data is backfilled, a later release drops the old column.
Never combine a destructive schema change with a code deploy in the same step — that couples two independently risky operations and makes rollback impossible.
Pipeline security
The pipeline is a high-value target — it has credentials to your registry, cloud, and production. A compromised pipeline is a compromised production.
- Least-privilege tokens. Scope CI tokens per job (
permissions:in GitHub Actions, protected variables in GitLab). A lint job does not needpackages: write. Default the whole workflow tocontents: readand grant more only where needed. - OIDC federation to cloud. Instead of long-lived cloud keys stored as secrets, the CI provider issues a short-lived OIDC token that the cloud (AWS IAM, GCP Workload Identity, Azure) exchanges for temporary credentials, restricted to a specific repo/branch/environment via the token’s
subclaim. No static secrets to leak or rotate. - Secret handling. Use the platform’s secret store or an external vault; mask secrets in logs; never echo them; never pass secrets to workflows triggered by untrusted PRs.
pull_request_targetand self-hosted runners on public repos are classic footguns because they run with elevated context against attacker-authored code. - Supply chain. Pin third-party actions/orbs by full commit SHA (a tag like
@v4is mutable and can be repointed by an attacker who controls the action repo), verify checksums of downloaded tools, generate SBOMs, and consider provenance/signing (Sigstore cosign, SLSA levels). - Protected environments. Require reviews for production deploys; restrict who can modify workflow files and runner configuration. Treat the
.github/workflowsdirectory and CI config as production code with mandatory review. - Isolate build from deploy. The job that runs untrusted test code (dependencies, PR contributions) should not hold deploy credentials. Split “build/test” (low trust) from “deploy” (high trust, restricted trigger) into separate jobs with separate permissions.
OIDC trust example (AWS)
The IAM role’s trust policy pins exactly which repo and branch may assume it — this is what makes OIDC safe:
{
"Effect": "Allow",
"Principal": { "Federated": "arn:aws:iam::123456789012:oidc-provider/token.actions.githubusercontent.com" },
"Action": "sts:AssumeRoleWithWebIdentity",
"Condition": {
"StringEquals": { "token.actions.githubusercontent.com:aud": "sts.amazonaws.com" },
"StringLike": { "token.actions.githubusercontent.com:sub": "repo:acme/myapp:ref:refs/heads/main" }
}
}
A common misconfiguration is a sub condition of repo:acme/myapp:*, which lets any branch or PR from that repo assume the role — including an attacker’s fork-based PR. Always pin the ref as tightly as the use case allows.
Common pitfalls
- Flaky tests treated as normal. Once “just re-run it” becomes the culture, the suite has stopped being a signal. Quarantine and fix flaky tests aggressively.
- The 45-minute pipeline. Slow feedback means people batch changes and context-switch away. Parallelize, cache, shard tests, and cut the e2e count.
- Rebuilding per environment. Discussed above — you ship something you never tested.
- Manual snowflake deploys “just this once.” The emergency hotfix that bypasses the pipeline is exactly the change that most needs the pipeline’s checks.
- Secrets in logs / env dumps. A
set -xor anenvprintout in a failing step can leak every secret to anyone who can read build logs. - Deploying on Friday afternoon with no one watching. Not a technical rule but an operational one: deploy when you can observe and respond.
Best Practices
- Keep the pipeline fast (< 10 minutes to first signal). Slow pipelines destroy the feedback loop that CI exists to provide; parallelize tests, cache dependencies aggressively, and run the expensive suites only when the cheap ones pass.
- Build once, promote the same artifact everywhere. Rebuilding per environment means you never actually tested what you shipped; promote immutable bytes, inject config at deploy time.
- Make the main branch always releasable. Broken main is a stop-the-line event; fix or revert immediately, because everyone else is blocked behind it.
- Run the cheapest checks first. Lint and unit tests before integration tests before e2e — fail fast, save compute, and give the developer the most specific error possible.
- Version everything immutably. Tag artifacts with the git SHA; never overwrite tags like
latestfor deployment decisions, because you lose the ability to say exactly what is running. - Use OIDC instead of stored cloud credentials. Short-lived, scoped, auditable — it eliminates the single most common CI secret-leak disaster and removes rotation toil.
- Pin and audit third-party pipeline dependencies. Actions/orbs/plugins are executable code in your trust boundary; pin by full commit SHA and review updates like any other dependency.
- Separate deploy from release. Use flags/canaries so shipping code and exposing it to users are independent decisions with independent blast radius.
- Gate production with automation, not meetings. Encode quality gates (tests, scans, error budgets) in the pipeline; keep manual approval only where regulation or genuine risk requires it — a rubber-stamp approval is theater.
- Deploy small and often. Small diffs are easier to review, test, and roll back; deployment frequency and safety improve together, not in tension.
- Make rollback a first-class, rehearsed path. One command (or automatic on failed health checks) to return to the previous artifact — and practice it, so it works under stress.
- Design database changes as expand/contract. Keep every migration backward-compatible with the currently running code so rollout and rollback are always safe.
- Treat pipeline config as code. Review it, test it, keep it in the repo it serves; avoid clicking configuration into a UI that nobody can reproduce or audit.
- Isolate untrusted code from privileged credentials. The job running dependency code and PR contributions must not be able to reach production.
- Make failures loud and specific. A red build should point at the one failing test, not dump a wall of logs; invest in good test output and annotations.
- Measure your pipeline with DORA metrics. Deployment frequency, lead time for changes, change-failure rate, and MTTR tell you whether your CI/CD investment is actually working — and where the bottleneck is.
References
- roadmap.sh — DevOps Roadmap
- GitHub Actions documentation
- GitHub Actions — security hardening
- GitLab CI/CD documentation
- Jenkins documentation
- Jenkins Pipeline syntax
- CircleCI documentation
- Azure Pipelines documentation
- OpenID Connect in GitHub Actions
- Argo CD documentation
- Flux documentation
- Argo Rollouts (progressive delivery)
- DORA research — dora.dev
- SLSA supply-chain security framework
- Sigstore / cosign
- Martin Fowler — Continuous Integration
- Martin Fowler — BlueGreenDeployment
- Martin Fowler — CanaryRelease
- Book: Continuous Delivery — Jez Humble & David Farley
- Book: Accelerate — Nicole Forsgren, Jez Humble, Gene Kim