CI/CD: từ git push tới production, không chạm tay

Nhìn lại chặng này: Helm đóng gói (Bài 01), ArgoCD để git điều khiển cluster (Bài 02), Rancher quản hạm đội (Bài 03), Observability cho bạn đôi mắt (Bài…

13 tháng 7, 2026 · 30 phút đọc

Nhìn lại chặng này: Helm đóng gói (Bài 01), ArgoCD để git điều khiển cluster (Bài 02), Rancher quản hạm đội (Bài 03), Observability cho bạn đôi mắt (Bài 04). Dây chuyền gần như tự động hoàn toàn.

Gần như. Vì vẫn còn một đoạn bạn làm bằng tay — và nó nằm ngay đầu dây chuyền:

   Dev sửa code → ??? → image mới trong registry → ??? → tag mới trong repo manifest
                   ▲                                 ▲
                   │                                 │
              build tay trên laptop           sửa tag tay bằng vim
              docker push tay                  git commit tay

Đoạn ??? đó chính là CI/CD. Bài này tự động hóa nó: test → build → scan → push → bump tag — rồi ArgoCD lo phần còn lại (nó đã lo rồi, từ Bài 02). Và bạn sẽ thấy vì sao tag latest (pitfall Bài 08 chặng K8s) khiến toàn bộ dây chuyền này sụp đổ.


Vấn đề: "chạy trên máy em ổn mà"

   ❌ Build thủ công trên laptop dev:

   • Dev A build → image dính node_modules cũ trên máy A → prod lỗi lạ.
   • Dev B quên chạy test trước khi build → bug lọtn prod.
   • Ai đó build từ nhánh feature chưa merge → prod chạy code không tn tại trong main.
   • docker push bằng tài khoản cá nhân → người đó nghỉ việc, không ai push được.
   • Sửa tag trong manifest bằng vim → gõ nhầm 1.4.2 thành 1.42 → ImagePullBackOff.

   ❌ Và nếu dùng tag :latest —
   • Cluster kéo "latest"... nhưng latest LÀ CÁI GÌ? Không ai biết.
   • Rollback? Không rollback được — bản cũ cũng từng tên"latest".
   • Hai node kéo "latest" ở hai thời điểm → CHẠY HAI PHIÊN BẢN KHÁC NHAU. 💀

Vấn đề gốc: build là một tiến trình cần lặp lại được, nhưng laptop con người thì không. Máy mỗi người mỗi khác, không ai kiểm chứng, không có dấu vết. CI giải quyết bằng cách đưa build vào một môi trường sạch, tự động, có log.


CI và CD — chia đôi rõ ràng trong GitOps

Đây là điểm nhiều người rối. Với GitOps, ranh giới cực rõ:

   ┌─────────────────── CI (Continuous Integration) ────────────────────┐
   │  Chạy ở: GitHub Actions / GitLab CI / Jenkins                       │
   │  Nhiệm vụ: tCODEtạo ra ARTIFACT (image) + cập nhật manifest    │
   │                                                                      │
   │   git push code                                                      │
   │      → lint & test                                                   │
   │      → build image, tag = git SHA                                    │
   │      → scan lỗ hổng (Trivy)                                          │
   │      → push lên registry                                             │
   │      → CẬP NHẬT TAG trong repo manifest (commit)                     │
   └───────────────────────────────┬──────────────────────────────────────┘
                                   │  (git — ranh giới)
   ┌───────────────────────────────▼──────────────────────────────────────┐
   │  CD (Continuous Deployment) = ArgoCD (Bài 02)                        │
   │  Cluster TỰ KÉO manifest mới về và apply. Bạn KHÔNG cần làm gì.      │
   └──────────────────────────────────────────────────────────────────────┘

⚠️ Điểm mấu chốt của GitOps: CI KHÔNG deploy. CI không cầm kubeconfig, không gõ kubectl apply, không chạy helm upgrade. Nó chỉ đẩy image lên registry và commit tag mới vào git. ArgoCD thấy git đổi → tự sync.

Vì sao điều này quan trọng?

   CI kiểu cũ (push):        CI runner giữ kubeconfig prod
                             → CI bị hack = cluster prod bị hack.
                             → Runner là mục tiêu tấn công béo bở.

   CI kiểu GitOps (pull):    CI chỉ có quyền: push image + push git.
                             → KHÔNG có credential nào của cluster.
                             → CI bị hack cũng không chạm được prod trực tiếp
                               (và mọi thay đổi vẫn phải qua git, có review).

Hai repo, hai vai trò

   Repo CODE (myapp)                Repo MANIFEST (myapp-manifests)
   ─────────────────                ───────────────────────────────
   src/, Dockerfile, tests          charts/myapp/  (Helm — Bài 01)
   .github/workflows/ci.yml         values-dev.yaml
                                    values-staging.yaml
                                    values-prod.yaml

   CI đọc repo này...               ...và COMMIT tag mới vào repo này.
                                    ArgoCD theo dõi repo này (Bài 02).

Vì sao tách? (Đúng pitfall số 6 của Bài 02):

  • CI commit vào repo manifest → không kích hoạt lại chính pipeline CI (tránh vòng lặp vô hạn).
  • Repo manifest có vòng đời riêng: ai được merge vào values-prod.yaml = ai được deploy prod. Phân quyền deploy = phân quyền merge.
  • Lịch sử deploy sạch sẽ, tách khỏi lịch sử code.

Tag image: latest là kẻ thù

Đây là quy tắc quan trọng nhất của cả bài:

   ❌ myapp:latest        "latest" là cái gì? Không ai biết.
                          Rollback được không? KHÔNG — bản cũ cũng tên latest.
                          Hai node kéo latest lúc khác nhau → chạy 2 phiên bản.
                          GitOps sụp đổ: git nói "latest", cluster chạy... gì đó.

   ✅ myapp:a3f9c21       tag = GIT SHA (commit tạo ra nó)
                          → Bất biến. Mỗi image ↔ đúng một commit code.
                          → Truy vết được: prod đang chạy → biết CHÍNH XÁC dòng code.
                          → Rollback = trỏ về SHA cũ. Chắc chắn 100%.

   ✅ myapp:1.4.2         semver, cho release chính thức (kèm SHA càng tt)
   ✅ myapp:1.4.2-a3f9c21 cả hai — dễ đọc và truy vết được

💡 Vì sao latest phá GitOps? GitOps dựa trên tiền đề "git mô tả chính xác trạng thái mong muốn". Nếu git ghi image: myapp:latest, nó không mô tả gì cả — trạng thái thật phụ thuộc vào việc registry đang trỏ latest vào đâu, và node kéo image lúc nào. Git không còn là nguồn sự thật. Toàn bộ Bài 02 vô hiệu.


Pipeline hoàn chỉnh — GitHub Actions

# .github/workflows/ci.yml  (trong repo CODE)
name: CI
on:
  push:
    branches: [main]

jobs:
  build:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write
    steps:
      - uses: actions/checkout@v4

      # 1. TEST — không qua test thì không có image. Cổng chất lượng đầu tiên.
      - name: Chạy test
        run: |
          npm ci
          npm run lint
          npm test

      # 2. BUILD — tag = git SHA (BẤT BIẾN, không bao giờ latest)
      - name: Đăng nhập registry
        uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Build & push
        uses: docker/build-push-action@v5
        with:
          push: true
          tags: ghcr.io/cty/myapp:${{ github.sha }}
          cache-from: type=gha              # cache layer → build nhanh hơn nhiều
          cache-to: type=gha,mode=max

      # 3. SCAN lỗ hổng — nhớ Bài 08 chặng K8s ("image sạch")
      - name: Quét bảo mật image
        uses: aquasecurity/trivy-action@master
        with:
          image-ref: ghcr.io/cty/myapp:${{ github.sha }}
          severity: 'CRITICAL,HIGH'
          exit-code: '1'                    # có lỗ hổng nặng → DỪNG pipeline

      # 4. CẬP NHẬT MANIFEST — đây là "deploy" trong thế giới GitOps
      - name: Bump tag trong repo manifest
        run: |
          git clone https://x-access-token:${{ secrets.MANIFEST_TOKEN }}@github.com/cty/myapp-manifests.git
          cd myapp-manifests
          # sửa image tag trong values (yq = sed cho YAML)
          yq -i '.image.tag = "${{ github.sha }}"' values-dev.yaml
          git config user.name "ci-bot"
          git config user.email "ci@cty.vn"
          git commit -am "deploy myapp ${{ github.sha }} lên dev"
          git push
      # → XONG. ArgoCD (Bài 02) thấy git đổi → tự sync → Pod mới lên.
      #   CI KHÔNG hề chạm vào cluster. Không có kubeconfig ở đây.

Bản GitLab CI tương đương

# .gitlab-ci.yml
stages: [test, build, deploy]

test:
  stage: test
  script: [npm ci, npm test]

build:
  stage: build
  image: docker:latest
  services: [docker:dind]
  script:
    - docker build -t $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA .
    - docker push $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA

update-manifest:
  stage: deploy
  script:
    - git clone https://ci-bot:$MANIFEST_TOKEN@git.cty.vn/team/myapp-manifests.git
    - cd myapp-manifests
    - yq -i ".image.tag = \"$CI_COMMIT_SHA\"" values-dev.yaml
    - git commit -am "deploy $CI_COMMIT_SHA" && git push

Thăng cấp môi trường (promotion)

Đây là nơi GitOps tỏa sáng — quyền deploy = quyền merge:

   dev       CI tự bump values-dev.yaml mỗi commit vào main
             → ArgoCD auto-sync. Deploy liên tục, không cần ai duyệt.

   staging   Mở PR: values-staging.yaml, đổi tag sang SHA đã chạy ổn ở dev
             → 1 người review → merge → ArgoCD sync.

   prod      Mở PR: values-prod.yaml, đổi tag sang SHA đã qua staging
             → BẮT BUỘC review (branch protection) → merge → ArgoCD sync.
             → Toàn bộ deploy prod nằm trong git log: ai, khi nào, duyệt bởi ai.

⚠️ Cùng một image SHA đi từ dev → staging → prod. Đừng bao giờ build lại image cho từng môi trường — "build một lần, thăng cấp artifact đó". Build lại nghĩa là thứ bạn test ở staging không phải thứ chạy ở prod.

💡 Rollback prod giờ là git revert cái PR đó (Bài 02). Một lệnh, có lịch sử, ai cũng làm được.

ArgoCD Image Updater (tùy chọn)

Không muốn CI commit vào repo manifest? ArgoCD Image Updater tự canh registry, thấy tag mới khớp mẫu thì tự cập nhật. Tiện cho dev, nhưng kém tường minh hơn (thay đổi không đến từ một PR có review) — tôi khuyên dùng cho dev/staging, còn prod thì giữ luồng PR.


Progressive delivery — deploy an toàn hơn nữa

Rolling update (Bài 03 chặng K8s) đã zero-downtime, nhưng nó vẫn đẩy bản mới cho 100% người dùng. Bản mới có bug logic thì 100% khách dính.

   Canary        Cho 5% traffic vào bản mới → theo dõi metric (Bài 04!)
                 → lỗi tăng? TỰ ĐỘNG rollback. Ổn? tăng dần 25%50%100%.

   Blue-Green    Dựng song song bản mới (green) cạnh bản cũ (blue)
                 → test xong, gạt toàn bộ traffic sang green trong 1 giây
                 → có sự cố: gạt ngược lại tức thì.

   Công cụ: Argo Rollouts (cùng nhà ArgoCD) hoặc Flagger.
   → Chúng ĐỌC METRIC từ Prometheus (Bài 04) để quyết định tiến hay lùi.

→ Đây là lúc bốn bài của chặng ăn khớp: Helm đóng gói, CI tạo artifact, ArgoCD đồng bộ, Observability làm trọng tài quyết định deploy thành hay bại.


🚀 Lab — pipeline end-to-end

1. Chuẩn bị hai repo

   github.com/ban/myapp             ← code + Dockerfile + .github/workflows/ci.yml
   github.com/ban/myapp-manifests   ← charts/ + values-dev.yaml (ArgoCD theo dõi)

2. Dockerfile chuẩn (nhớ Bài 08 chặng K8s — non-root, pin version)

FROM node:20-alpine AS build        # PIN version, không dùng :latest
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY . .

FROM node:20-alpine
WORKDIR /app
COPY --from=build /app .
USER 1000                            # ⭐ non-root (SecurityContext sẽ ép điều này)
EXPOSE 3000
CMD ["node", "server.js"]

3. Mô phỏng pipeline tại chỗ (không cần CI server)

SHA=$(git rev-parse --short HEAD)

# CI làm gì: test → build (tag = SHA) → scan → push
npm test
docker build -t ghcr.io/ban/myapp:$SHA .
trivy image --severity CRITICAL,HIGH ghcr.io/ban/myapp:$SHA
docker push ghcr.io/ban/myapp:$SHA

# "Deploy" = cập nhật manifest (KHÔNG chạm cluster!)
cd ../myapp-manifests
yq -i ".image.tag = \"$SHA\"" values-dev.yaml
git commit -am "deploy $SHA" && git push

# → Mở UI ArgoCD: Application chuyển OutOfSync → tự Sync → Pod mới lên.
#   Bạn KHÔNG gõ một lệnh kubectl nào. ✨

4. Rollback bằng git

git revert HEAD && git push
# → ArgoCD kéo về image SHA cũ. Rollback xong. Có lịch sử. Ai cũng làm được.

→ Khoảnh khắc "à há": từ lúc git push code tới lúc Pod mới chạy, bạn không hề chạm vào cluster. Đó là toàn bộ đích đến của cả chặng.


Câu chuyện thực tế

"Chạy trên máy em ổn mà." Thời chưa có CI, mỗi dev tự build image từ laptop rồi push. Một chiều, prod lỗi lạ: một thư viện chạy sai phiên bản. Điều tra ba tiếng mới ra: image đó được build từ laptop của một bạn có node_modules cũ trong cache, và bạn ấy build từ nhánh feature chưa merge. Nghĩa là prod đang chạy code không tồn tại trong main. Không ai truy vết được, vì image chỉ tag latest.

Chúng tôi dựng CI trong hai ngày. Luật cứng: image chỉ được sinh ra từ CI, tag = git SHA, không ai push tay. Kết quả đo được:

  • Truy vết: từ "không biết prod chạy code nào" → xem tag là ra đúng commit. Ba tiếng điều tra giờ là ba giây git show.
  • Bug lọt prod giảm rõ rệt — vì test là cổng bắt buộc; không qua test thì không tồn tại image để mà deploy.
  • Deploy: từ ~20 phút thao tác tay (build, push, sửa tag, apply, cầu trời) → git push rồi đi pha cà phê. ArgoCD lo hết.
  • Rollback: git revert — một lệnh, ai cũng làm được, kể cả bạn intern lúc 2 giờ sáng.

Cú tát: latest và cú rollback bất khả thi. Trước khi bỏ latest, chúng tôi có một đêm sự cố: bản mới lỗi, cần rollback gấp. Nhưng manifest ghi image: myapp:latest. Rollback về đâu? Registry chỉ có một cái latest — chính là bản hỏng. Bản tốt trước đó đã bị ghi đè, không còn tag nào trỏ tới nó. Chúng tôi phải build lại từ commit cũ giữa lúc production đang cháy — mất 40 phút thay vì 30 giây. Đêm đó tôi hiểu: latest không phải sự tiện lợi, nó là việc tự tay xóa đường lui của mình.

Cú tát thứ hai: CI giữ kubeconfig prod. Pipeline đầu tiên của chúng tôi chạy kubectl apply trực tiếp, nên runner phải giữ kubeconfig prod trong biến bí mật. Một audit chỉ ra: bất kỳ ai merge được một dòng vào file CI đều có thể in kubeconfig đó ra log và chiếm cả cluster. Chuyển sang mô hình GitOps (CI chỉ commit git), credential cluster biến mất khỏi CI hoàn toàn. Bề mặt tấn công co lại đáng kể — và đó là lợi ích của GitOps mà tôi không nghĩ tới lúc đầu.

Bài học:

  1. latest là tự xóa đường lui. Tag bất biến = git SHA. Truy vết được, rollback được, GitOps mới có nghĩa.

  2. Build một lần, thăng cấp artifact đó. Cùng SHA đi dev → staging → prod. Build lại cho từng môi trường = thứ bạn test không phải thứ bạn chạy.

  3. CI không được cầm kubeconfig. Trong GitOps, CI chỉ push image + commit git. Cluster tự kéo. CI bị hack cũng không chạm được prod.

  4. Test là cổng, không phải nghi thức. Không qua test = không có image. Đơn giản, tàn nhẫn, hiệu quả.

  5. Quyền deploy = quyền merge. Branch protection trên values-prod.yaml chính là chính sách deploy của bạn — và nó tự có audit trail miễn phí.

  6. Deploy nên nhàm chán. Mục tiêu cuối của cả chặng: git push, rồi đi pha cà phê. Nếu deploy vẫn còn hồi hộp, còn chỗ để tự động hóa.


Pitfalls

  1. Dùng tag latest → không truy vết, không rollback, hai node chạy hai phiên bản, GitOps vô hiệu. Tag = git SHA.

  2. CI giữ kubeconfig prod → runner thành mục tiêu tấn công. GitOps: CI chỉ commit git.

  3. Build lại image cho từng môi trường → thứ test ở staging khác thứ chạy ở prod. Thăng cấp cùng một artifact.

  4. CI commit vào chính repo code → kích hoạt lại pipeline → vòng lặp vô hạn. Tách repo manifest (hoặc dùng [skip ci]).

  5. Không scan image → đưa lỗ hổng lên prod. Trivy trong pipeline, fail nếu có CRITICAL.

  6. Không pin base image (FROM node:latest) → build hôm nay khác build mai. Pin node:20-alpine.

  7. In secret ra log CI → registry token, git token lộ trong log công khai. Dùng masked secret, không echo.

  8. Không cache layer → build 15 phút mỗi lần, dev nản, người ta bắt đầu tìm cách đi tắt.

  9. Auto-deploy thẳng prod mỗi commit → nguy hiểm. Prod nên qua PR có review (branch protection).

  10. Quên rằng rollback không cứu data (Bài 03 chặng K8s) → git revert lùi image, nhưng migration DB đã chạy thì vẫn ở trạng thái mới.

  11. Chạy docker build bên trong cluster mà không cần thiết (docker-in-docker phức tạp, quyền cao). Dùng runner riêng, hoặc Kaniko/Buildah nếu bắt buộc build trong K8s.


Tóm tắt

  • CI = code → artifact (test, build, scan, push image, bump tag trong repo manifest). CD = ArgoCD tự kéo git về (Bài 02).
  • CI KHÔNG deploy, KHÔNG cầm kubeconfig. Ranh giới giữa CI và CD chính là git. Bề mặt tấn công co lại.
  • Tag = git SHA (bất biến). latest phá GitOps — git không còn mô tả được trạng thái thật, và rollback trở nên bất khả thi.
  • Hai repo: repo code (CI đọc) và repo manifest (ArgoCD theo dõi, CI commit vào). Tránh vòng lặp CI, tách quyền deploy.
  • Thăng cấp: cùng một SHA đi dev → staging → prod. Quyền deploy = quyền merge (branch protection = chính sách deploy + audit trail miễn phí).
  • Rollback = git revert.
  • Progressive delivery (Argo Rollouts/Flagger): canary/blue-green, dùng metric từ Prometheus (Bài 04) làm trọng tài tự động rollback.
  • Pipeline chuẩn: lint/test → build (SHA) → scan (Trivy) → push → bump manifest. Test là cổng, không phải nghi thức.

🎓 Kết thúc Chặng — Helm & GitOps

Đi qua 5 bài:

  1. Bài 01 — Helm: trình quản lý gói cho K8s. Chart/Values/Release, một Chart cho mọi môi trường, helm rollback lùi cả stack.
  2. Bài 02 — GitOps với ArgoCD: git là nguồn sự thật; selfHeal giết configuration drift; deploy = commit, rollback = revert.
  3. Bài 03 — Rancher: quản trị nhiều cluster, xác thực tập trung, và luật vàng UI để nhìn — git để thay đổi.
  4. Bài 04 — Observability: Prometheus + Grafana + Loki; cảnh báo trên triệu chứng, có runbook; biết trước khách hàng.
  5. Bài 05 — CI/CD: test → build (tag = SHA) → scan → push → bump manifest; CI không chạm cluster.

Cộng lại, bạn có một dây chuyền hoàn chỉnh:

   git push codeCI: test, build, scan, push image (tag = SHA)
        → CI: commit tag mi vào repo manifestArgoCD: thy git đổi, tsync vào clusterK8s: rolling update zero-downtime (readiness probe canh cổng)
        → Prometheus: theo dõi; li tăngcnh báo (hoặc canary tự rollback)
        → Có scố? git revert. Xong.

   Con người chm tay vào: ĐÚNG MT LNlúc mPR.

Bạn đã sẵn sàng cho

  • Argo Rollouts / Flagger — canary & blue-green tự động, dùng metric làm trọng tài.
  • Service Mesh (Istio/Linkerd) — mTLS giữa mọi service, traffic splitting, observability sâu.
  • Policy as Code (OPA/Kyverno) — ép luật lên cluster ("mọi Pod phải non-root", "cấm tag latest") — tự động hóa chính Bài 08 chặng K8s.
  • Multi-tenancy & FinOps — chia cluster cho nhiều team, đo và tối ưu chi phí.
  • DR & chaos engineering — chủ động phá để kiểm chứng khả năng phục hồi.

Lời khuyên đi tiếp

  1. Bắt đầu từ nỗi đau, không từ công cụ. Đừng cài ArgoCD vì nó hot; cài vì bạn đã thấy drift cắn mình. Công cụ giải nỗi đau bạn chưa có sẽ thành gánh nặng.

  2. Tự động hóa từng bước, đừng làm một phát. Helm trước (bỏ copy-paste), rồi ArgoCD (bỏ deploy tay), rồi CI (bỏ build tay), rồi observability. Mỗi bước phải chạy ổn rồi mới đi tiếp.

  3. Bỏ latest ngay hôm nay. Đây là thay đổi rẻ nhất và có lợi nhất trong cả chặng. Năm phút, cứu bạn một đêm.

  4. Deploy nên nhàm chán. Nếu tim bạn còn đập nhanh lúc deploy, còn chỗ để tự động hóa. Mục tiêu: git push, đi pha cà phê.

  5. Cảnh báo ít mà tin cậy. 12 cảnh báo có runbook > 200 cảnh báo bị mute.

  6. Git là nguồn sự thật — sống chết với nguyên tắc đó. Mọi ngoại lệ ("chỉ lần này thôi, sửa tay cho nhanh") đều là hạt giống của sự cố tiếp theo.

Hết chặng Docker: một máy. Hết chặng Kubernetes: một cụm tự sống sót. Hết chặng này: một dây chuyền tự vận hành, nơi con người chỉ cần đưa ra quyết định — còn máy móc lo phần thực thi.

Chúc bạn có những lần deploy nhàm chán nhất đời. Đó là lời khen cao nhất trong nghề này. 🚀

Minh Hưng

← Bài trước
Observability: nhìn thấy trước khi khách hàng gọi
Zalo tư vấn