Chuyển tới nội dung chính

15.7 — 6. GitHub Actions CI/CD

Tóm tắt

Một pipeline tốt làm ba việc: chặn code hỏng trước khi vào nhánh chính, build một lần rồi triển khai cùng một artifact qua mọi môi trường, và triển khai có thể rollback. Ba điều về bảo mật mà rất nhiều pipeline bỏ qua. GITHUB_TOKEN mặc định có quyền ghi ở nhiều repo cũ — một action bị xâm nhập có thể push code; luôn khai permissions tối thiểu ở đầu workflow. Action ghim bằng tag có thể bị đổi: @v4 là một tag di động, và người sở hữu action trỏ lại nó bất cứ lúc nào — ghim bằng SHA đầy đủ cho action của bên thứ ba. Và pull_request_target chạy với secret của repo trên code từ fork — đó là lỗ hổng leo thang quyền kinh điển.

Mục tiêu bài học​

Sau bài này bạn có thể:

  • Viết pipeline build, test, đóng gói và triển khai.
  • Khai quyền tối thiểu và ghim action an toàn.
  • Cache để build nhanh.
  • Dùng environment với phê duyệt thủ công.
  • Triển khai mà không lưu khoá SSH dài hạn.

Nội dung bài học​

15.7.1 — Build và test​

name: CI/CD

on:
push:
branches: [main]
pull_request:
branches: [main]

permissions:
contents: read # TOI THIEU o cap workflow

concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true # huy lan chay cu khi push moi

jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- uses: actions/setup-dotnet@v4
with:
dotnet-version: "9.0.x"

- name: Cache NuGet
uses: actions/cache@v4
with:
path: ~/.nuget/packages
key: nuget-${{ hashFiles('**/packages.lock.json', '**/*.csproj') }}
restore-keys: nuget-

- run: dotnet restore
- run: dotnet build --no-restore -c Release
- run: dotnet test --no-build -c Release
--logger "trx;LogFileName=results.trx"
--collect:"XPlat Code Coverage"

- uses: actions/upload-artifact@v4
if: always() # tải lên CẢ KHI test thất bại
with:
name: test-results
path: "**/*.trx"

Bốn chi tiết:

permissions: contents: read ở cấp workflow. Mặc định của repo cũ là write cho mọi phạm vi — nghĩa là một action bị xâm nhập push được code, tạo release, sửa issue. Khai tối thiểu ở đầu, rồi nâng riêng cho job cần.

concurrency với cancel-in-progress huỷ lần chạy cũ khi có push mới — tiết kiệm phút CI và tránh hai lần deploy chạy đè nhau.

if: always() ở bước upload: không có nó, kết quả test không được tải lên khi test thất bại — đúng lúc bạn cần nó nhất.

Cache key dựa trên hashFiles của file dự án, không phải github.sha — nếu không, cache không bao giờ trúng.

15.7.2 — Ghim action​

# RỦI RO — tag di động, chủ sở hữu trỏ lại bất cứ lúc nào
- uses: some-org/some-action@v1

# AN TOÀN — ghim theo SHA đầy đủ
- uses: some-org/some-action@a1b2c3d4e5f6... # v1.2.3

@v1 và @v4 là tag, và tag di chuyển được. Nếu tài khoản của người sở hữu action bị xâm nhập, họ trỏ @v4 sang code độc hại và mọi workflow dùng nó chạy code đó trong lần chạy tiếp theo — với quyền và secret của bạn.

Đây không phải giả thuyết; đã có sự cố thật theo đúng kịch bản này.

Với action của GitHub (actions/checkout, actions/cache), rủi ro thấp hơn nhiều. Với action của bên thứ ba, ghim SHA. Dependabot cập nhật SHA tự động nếu bạn bật.

pull_request_target là lỗ hổng riêng:

# RAT NGUY HIEM
on: pull_request_target
jobs:
build:
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha }} # code TU FORK
- run: dotnet build # chay code la voi SECRET

pull_request_target chạy trong ngữ cảnh của repo gốc — có secret. Checkout code từ fork rồi chạy nó nghĩa là bất kỳ ai mở pull request cũng thực thi được code tuỳ ý với secret của bạn.

Dùng pull_request thường; nó chạy không có secret cho fork.

15.7.3 — Build một lần, triển khai nhiều nơi​

  build-image:
needs: test
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
permissions:
contents: read
packages: write # NANG rieng cho job nay
outputs:
digest: ${{ steps.push.outputs.digest }}
steps:
- uses: actions/checkout@v4

- uses: docker/setup-buildx-action@v3

- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}

- id: meta
uses: docker/metadata-action@v5
with:
images: ghcr.io/${{ github.repository }}/crm-api
tags: |
type=sha,prefix=sha-,format=long
type=semver,pattern={{version}}

- id: push
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
provenance: true

cache-from/cache-to: type=gha dùng cache của GitHub Actions cho layer Docker — thường giảm thời gian build từ vài phút xuống vài chục giây.

Không gắn thẻ latest cho artifact triển khai (bài 15.2). Dùng sha-<commit> để biết chính xác đang chạy gì và rollback được.

provenance: true sinh chứng thực nguồn gốc — cho phép xác minh image được build từ commit nào, bằng workflow nào.

Nguyên tắc build một lần: cùng một image đi qua staging rồi production. Build lại cho production nghĩa là bạn triển khai một artifact chưa từng được test.

15.7.4 — Triển khai với environment​

  deploy-production:
needs: build-image
runs-on: ubuntu-latest
environment:
name: production # yêu cầu phê duyệt nếu cấu hình
url: https://api.example.com
permissions:
contents: read
id-token: write # cho OIDC
steps:
- name: Deploy
uses: appleboy/ssh-action@<sha>
with:
host: ${{ secrets.DEPLOY_HOST }}
username: ${{ secrets.DEPLOY_USER }}
key: ${{ secrets.DEPLOY_SSH_KEY }}
script: |
cd /opt/crm
./deploy.sh "sha-${{ github.sha }}"

Environment cho bạn ba thứ: secret riêng theo môi trường, người phê duyệt bắt buộc, và giới hạn nhánh được deploy.

Với production, bật "Required reviewers" — mọi lần deploy phải có người bấm duyệt.

Khoá SSH dài hạn trong secret là điểm yếu. Ba cách tốt hơn:

CáchƯu
OIDC với cloudKhông có secret nào — token tạm thời
Self-hosted runner trong mạng nội bộKhông cần mở SSH ra Internet
Pull-based (GitOps)Server tự kéo image; CI không cần quyền vào server

Cách thứ ba đáng chú ý cho VPS: một script trên server kiểm tra registry mỗi phút và tự cập nhật khi có tag mới. CI chỉ push image, không cần khoá SSH nào.

Với cloud, OIDC là mẫu chuẩn:

      - uses: azure/login@<sha>
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
# KHÔNG có client-secret — dùng OIDC

GitHub cấp một token ngắn hạn cho lần chạy đó; không có bí mật dài hạn nào để lộ.

15.7.5 — Chất lượng và bảo mật​

      - name: Scan secrets
uses: gitleaks/gitleaks-action@<sha>

- name: Scan image
uses: aquasecurity/trivy-action@<sha>
with:
image-ref: ghcr.io/${{ github.repository }}/crm-api:sha-${{ github.sha }}
severity: CRITICAL,HIGH
exit-code: "1"

- name: Vulnerable packages
run: dotnet list package --vulnerable --include-transitive

exit-code: "1" làm build thất bại khi có lỗ hổng nghiêm trọng — nếu chỉ báo cáo mà không chặn, không ai đọc.

Nhưng cẩn thận: image cơ sở có CVE mới hằng tuần, và chặn tuyệt đối làm pipeline đỏ liên tục. Mẫu thực dụng là chặn ở bước build image với danh sách bỏ qua có thời hạn, cộng một job theo lịch quét lại image đang chạy:

on:
schedule:
- cron: "0 6 * * 1" # quét lại mỗi thứ Hai

Job theo lịch quan trọng hơn nhiều người nghĩ: image build hôm qua không có CVE nào, nhưng tuần sau có — và không ai build lại nên không ai biết.

15.7.6 — Rà lại code của bạn​

Danh sách rà soát pipeline

  • •permissions khai tối thiểu ở cấp workflow, nâng riêng cho job cần.
  • •Action của bên thứ ba được ghim theo SHA đầy đủ.
  • •Không dùng pull_request_target để build code từ fork.
  • •concurrency với cancel-in-progress được bật.
  • •Upload kết quả test có if: always().
  • •Cache key dựa trên hash file dự án, không phải commit sha.
  • •Build một lần; cùng image đi qua mọi môi trường.
  • •Image gắn thẻ theo commit sha, không dùng latest.
  • •Environment production có người phê duyệt bắt buộc.
  • •Deploy dùng OIDC hoặc pull-based thay vì khoá SSH dài hạn.
  • •Có quét secret và quét lỗ hổng, và chúng chặn build khi nghiêm trọng.
  • •Có job theo lịch quét lại image đang chạy.

Bài tập áp dụng​

Bài 1 — Quyền của GITHUB_TOKEN​

Xoá khối permissions và kiểm tra cài đặt repo xem mặc định là read hay write. Đặt tối thiểu và xác nhận workflow vẫn chạy.

Tiêu chí hoàn thành: bạn nêu được kẻ tấn công làm gì được với một GITHUB_TOKEN có quyền ghi, và biết cách xác định tập quyền tối thiểu.

Gợi ý và lời giải — Bài 1

Gợi ý. GITHUB_TOKEN được cấp tự động cho mọi job. Nếu một action bên thứ ba đọc được nó thì sao?

Lời giải — kiểm tra mặc định:

Settings -> Actions -> General -> Workflow permissions
( ) Read and write permissions <- mặc định CŨ, vẫn còn ở nhiều repo
(•) Read repository contents and packages permissions

Kiểm tra bằng API:

gh api /repos/:owner/:repo/actions/permissions/workflow
{ "default_workflow_permissions": "write", "can_approve_pull_request_reviews": false }

Xem quyền thật của một lần chạy: mở log job, mục Set up job:

GITHUB_TOKEN Permissions
Actions: write
Checks: write
Contents: write
Deployments: write
Issues: write
Metadata: read
Packages: write
PullRequests: write
...

Kẻ tấn công làm gì được với token có quyền ghi:

Contents: write       -> push code vào nhánh mặc định, sửa lịch sử, tạo tag
Packages: write -> đẩy image độc hại đè lên tag đang dùng ở production
Actions: write -> sửa workflow, tạo backdoor cho các lần chạy sau
Deployments: write -> kích hoạt deploy
PullRequests: write -> tự duyệt và merge PR của chính mình
Issues: write -> xoá bằng chứng, đóng issue báo lỗi bảo mật

Dòng thứ hai đáng sợ nhất: đẩy một image có cùng tag nghĩa là lần deploy tiếp theo — hoặc chỉ một lần pod restart — sẽ chạy mã của kẻ tấn công, và không có commit nào trong lịch sử git cho thấy điều đó.

Và kịch bản này không cần ai xâm nhập vào tài khoản của bạn:

- uses: some-org/some-action@v1      # không ghim SHA

Tác giả action đó — hoặc bất kỳ ai chiếm được tài khoản của họ — có thể đẩy một bản v1 mới đọc GITHUB_TOKEN từ môi trường. Tag v1 là con trỏ có thể di chuyển, nên bạn nhận mã mới mà không đổi gì trong repo của mình.

Đây chính là hình dạng của các vụ tấn công chuỗi cung ứng gần đây.

Đặt quyền tối thiểu:

name: CI/CD

# Mặc định cho MỌI job trong workflow
permissions: {}

jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: read # đủ để checkout
steps:
- uses: actions/checkout@v4

publish:
needs: build
runs-on: ubuntu-latest
permissions:
contents: read
packages: write # chỉ job này cần đẩy image
steps:
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}

permissions: {} ở cấp workflow đặt mọi quyền về none, rồi từng job mở đúng thứ nó cần. Đây là mẫu nên dùng, vì nó khiến việc thêm quyền là một hành động tường minh mà người review nhìn thấy.

Cách xác định tập quyền tối thiểu — quy trình:

  1. Đặt permissions: {} cho toàn workflow.
  2. Chạy, xem job nào thất bại và vì sao.
  3. Thêm một quyền, chạy lại.
  4. Lặp cho tới khi xanh.

Bảng tra nhanh cho các action phổ biến:

Việc cần làmQuyền
actions/checkout trên repo công khaikhông cần gì
actions/checkout trên repo riêng tưcontents: read
Đẩy image lên GHCRpackages: write
Tạo releasecontents: write
Bình luận vào PRpull-requests: write
Đẩy kết quả quét bảo mậtsecurity-events: write
Xác thực OIDC với cloudid-token: write
Cập nhật trạng thái commitstatuses: write

Ghim action theo SHA — biện pháp quan trọng ngang với quyền tối thiểu:

# Tag di chuyển được — tác giả có thể đổi nội dung
- uses: actions/checkout@v4

# SHA bất biến — nội dung không thể đổi
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1

Giữ comment # v4.1.1 để người đọc biết đây là phiên bản nào, và để Dependabot cập nhật được:

# .github/dependabot.yml
version: 2
updates:
- package-ecosystem: "github-actions"
directory: "/"
schedule: { interval: "weekly" }

Dependabot sẽ mở PR nâng SHA khi có bản mới — bạn vẫn được cập nhật, nhưng qua một PR có người xem, không phải tự động và im lặng.

Ba biện pháp khác:

1. Không dùng pull_request_target trừ khi thật sự hiểu nó. Trigger này chạy workflow với quyền ghi đầy đủ và quyền truy cập secret, trong bối cảnh của repo gốc, nhưng với code từ PR:

on: pull_request_target        # NGUY HIỂM nếu checkout code của PR

Bất kỳ ai mở một PR đều có thể chạy mã tuỳ ý với secret của bạn. Nếu buộc phải dùng, không bao giờ checkout github.event.pull_request.head.sha.

2. Dùng environment với phê duyệt cho deploy:

jobs:
deploy-production:
environment:
name: production
url: https://crm.example.com
Settings -> Environments -> production
[x] Required reviewers: @tech-lead
[x] Wait timer: 5 minutes
[x] Deployment branches: chỉ nhánh main

Secret gắn với environment chỉ được cấp sau khi phê duyệt — nên ngay cả workflow bị chiếm cũng không lấy được khoá production mà không có người bấm duyệt.

3. OIDC thay cho khoá dài hạn — cách tốt nhất cho deploy lên cloud:

permissions:
id-token: write
contents: read

steps:
- uses: azure/login@v2
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
# KHÔNG có client-secret

GitHub cấp một token ngắn hạn cho đúng lần chạy này; Azure xác minh nó qua OIDC. Không có khoá nào được lưu trong GitHub Secrets, nên không có gì để lộ và không có gì phải xoay.

Rà lại toàn bộ workflow:

# Workflow nào chưa khai báo permissions?
grep -L "permissions:" .github/workflows/*.yml

# Action nào chưa ghim SHA?
grep -hoE "uses: [^@]+@[^ ]+" .github/workflows/*.yml \
| grep -vE "@[0-9a-f]{40}" | sort -u

Hai lệnh này mất năm giây và thường cho ra kết quả đáng chú ý ngay trong lần chạy đầu tiên.


Bài 2 — Cache key không bao giờ trúng​

Đặt cache key là ${{ github.sha }} và chạy hai lần. Xác nhận cache không bao giờ trúng. Đổi sang hashFiles và so sánh thời gian.

Tiêu chí hoàn thành: bạn nêu được nguyên tắc thiết kế cache key, và hiểu vai trò của restore-keys.

Gợi ý và lời giải — Bài 2

Gợi ý. Cache được lưu với key của lần chạy này, và được tìm bằng key của lần chạy sau. Hai key đó có bằng nhau không?

Lời giải — key sai:

- uses: actions/cache@v4
with:
path: ~/.nuget/packages
key: nuget-${{ github.sha }}
Lần chạy 1 (commit abc123):
Cache not found for input keys: nuget-abc123
... build ...
Cache saved with key: nuget-abc123

Lần chạy 2 (commit def456):
Cache not found for input keys: nuget-def456 <- KHÔNG BAO GIỜ trúng
Cache saved with key: nuget-def456

Mỗi commit có SHA khác nhau, nên key luôn mới. Cache được lưu mỗi lần nhưng không bao giờ được đọc — bạn trả chi phí nén và tải lên mà không nhận lại gì, và còn nhanh chóng lấp đầy hạn mức 10 GB của repo.

Key đúng:

- uses: actions/cache@v4
with:
path: ~/.nuget/packages
key: nuget-${{ runner.os }}-${{ hashFiles('**/packages.lock.json', '**/*.csproj') }}
restore-keys: |
nuget-${{ runner.os }}-
Lần chạy 1:  Cache not found  -> restore 62 s
Lần chạy 2: Cache restored from key: nuget-Linux-a3f8... -> restore 4 s

Nguyên tắc thiết kế cache key:

Key phải là hàm của những gì quyết định NỘI DUNG cache, và chỉ của những thứ đó.

Áp dụng:

Cache NuGet phụ thuộc vào:  danh sách gói và phiên bản  -> hashFiles trên csproj/lock
hệ điều hành runner -> runner.os
KHÔNG phụ thuộc vào: commit SHA, số lần chạy, thời gian, tên nhánh

Bảng key đúng cho các loại cache:

CacheKey
NuGetnuget-${{ runner.os }}-${{ hashFiles('**/packages.lock.json') }}
npmnpm-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}
Docker layerDùng type=gha của buildx, không dùng actions/cache
Kết quả buildbuild-${{ runner.os }}-${{ hashFiles('src/**/*.cs') }}

Dòng cuối ít khi đáng làm: hash mọi file .cs nghĩa là bất kỳ thay đổi code nào cũng làm cache trượt, tức nó gần như không bao giờ trúng trên một repo đang phát triển.

restore-keys — cơ chế cứu vãn khi key chính trượt:

key: nuget-${{ runner.os }}-${{ hashFiles('**/packages.lock.json') }}
restore-keys: |
nuget-${{ runner.os }}-
nuget-

Khi key chính không khớp, GitHub tìm cache có key bắt đầu bằng một trong các restore-keys, và lấy cái mới nhất:

Thêm một gói NuGet -> lock file đổi -> key chính trượt
-> restore-keys "nuget-Linux-" khớp với cache của lần chạy trước
-> khôi phục 311 gói cũ, chỉ tải 1 gói mới
-> restore mất 8 s thay vì 62 s

Không có restore-keys, mỗi lần đổi phụ thuộc là một lần restore từ đầu.

Ba điều cần biết về restore-keys:

  1. Nó khớp theo tiền tố, và lấy cache khớp mới nhất.
  2. Cache khôi phục bằng restore-keys vẫn được lưu lại dưới key chính ở cuối job — nên lần sau sẽ trúng chính xác.
  3. Thứ tự quan trọng: liệt kê từ cụ thể tới tổng quát.

Ba lỗi khác về cache trong CI:

1. Cache thư mục sai. .NET có hai vị trí, và chúng dùng cho hai việc khác nhau:

path: |
~/.nuget/packages # gói đã tải về
~/.local/share/NuGet/http-cache # cache HTTP của nuget.org

Thiếu cái thứ hai, NuGet vẫn gọi mạng để kiểm tra phiên bản dù gói đã có sẵn.

2. Không dùng setup-dotnet với cache: true — nó làm sẵn mọi thứ ở trên:

- uses: actions/setup-dotnet@v4
with:
dotnet-version: '9.0.x'
cache: true
cache-dependency-path: '**/packages.lock.json'

Đây là cách nên dùng mặc định. Chỉ tự cấu hình actions/cache khi bạn cần cache thứ gì đó mà nó không lo.

3. Cache không chia sẻ được giữa các nhánh. Đây là một quy tắc bảo mật của GitHub, và nó gây bất ngờ:

Cache tạo trên nhánh feature/abc
-> nhánh feature/xyz KHÔNG đọc được
-> nhánh main KHÔNG đọc được

Cache tạo trên nhánh main (nhánh mặc định)
-> MỌI nhánh đọc được

Lý do: nếu nhánh đọc được cache của nhau, một PR độc hại có thể đầu độc cache mà các nhánh khác sẽ dùng.

Hệ quả thực tế: PR đầu tiên từ một nhánh mới luôn có cache lạnh, trừ khi nhánh mặc định đã có cache phù hợp. Nên hãy chắc rằng workflow chạy trên main cũng lưu cache:

on:
push:
branches: [main] # để main sinh cache cho mọi nhánh khác dùng
pull_request:

Quan sát cache — đừng đoán:

gh api /repos/:owner/:repo/actions/caches --jq \
'.actions_caches[] | "\(.size_in_bytes/1048576|floor)MB \(.ref) \(.key)"' \
| sort -rn | head -10
487MB refs/heads/main nuget-Linux-a3f8b2c1
412MB refs/heads/main npm-Linux-7d9e4f2a
198MB refs/pull/142/merge nuget-Linux-c8b1a5d3

Hạn mức là 10 GB cho mỗi repository, và khi vượt, GitHub xoá cache cũ nhất — kể cả cache quan trọng. Nếu bạn thấy cache liên tục trượt dù key đúng, hãy kiểm tra tổng dung lượng.

Dọn cache không còn dùng:

gh api /repos/:owner/:repo/actions/caches --jq '.actions_caches[].id' \
| head -20 | xargs -I{} gh api -X DELETE /repos/:owner/:repo/actions/caches/{}

Và cuối cùng: đo xem cache có đáng không. Cache không miễn phí — nén, tải lên, tải về đều tốn thời gian:

Restore không cache:  62 s
Restore có cache: 4 s + tải cache 8 s + lưu cache 6 s = 18 s
Tiết kiệm thật: 44 s

Với một cache chỉ tiết kiệm vài giây, chi phí tải lên và tải về có thể lớn hơn lợi ích. Hãy xem thời gian của các bước Post trong log job — chúng thường bị bỏ qua khi tính toán.


Bài 3 — Rollback bằng tag ảnh​

Deploy một image fail health check, xác nhận script rollback chạy, rồi deploy lại bằng tag sha- của commit trước đó.

Tiêu chí hoàn thành: bạn giải thích được vì sao tag latest khiến rollback không thực hiện được, và thiết kế được chiến lược gắn tag phục vụ rollback.

Gợi ý và lời giải — Bài 3

Gợi ý. Để quay về phiên bản trước, bạn cần trỏ tới nó. Tag latest trỏ tới cái gì?

Lời giải — vì sao latest khiến rollback không thực hiện được:

- uses: docker/build-push-action@v6
with:
push: true
tags: ghcr.io/cty/crm-api:latest # CHỈ có latest
Deploy #41:  build -> đẩy :latest -> :latest = abc123
Deploy #42: build -> đẩy :latest -> :latest = def456 <- đè lên
Deploy #42 hỏng.

Muốn quay về #41 -> docker pull ghcr.io/cty/crm-api:???

Không có tên nào trỏ tới image của deploy #41. Nó vẫn tồn tại trong registry (dưới một digest), nhưng:

  • Không ai biết digest đó là bao nhiêu, trừ khi đã ghi lại.
  • Registry có thể đã dọn image không có tag.
  • Ngay cả tìm được digest, không có cách nào biết nó tương ứng với commit nào.

Thêm một vấn đề tinh tế hơn: latest khiến bạn không biết cái gì đang chạy.

docker inspect crm-api --format '{{.Config.Image}}'
ghcr.io/cty/crm-api:latest      # ...nhưng latest nào?

Hai máy chủ cùng chạy :latest có thể đang chạy hai image khác nhau, nếu chúng pull vào hai thời điểm khác nhau. Và --restart unless-stopped có thể kéo về một image mới hơn sau một lần reboot, khiến máy chủ đổi phiên bản mà không ai deploy gì.

Chiến lược gắn tag phục vụ rollback:

- uses: docker/metadata-action@v5
id: meta
with:
images: ghcr.io/cty/crm-api
tags: |
type=sha,prefix=sha-,format=long
type=raw,value=latest,enable={{is_default_branch}}
type=semver,pattern={{version}}
type=ref,event=branch
type=raw,value={{date 'YYYYMMDD-HHmmss'}}

- uses: docker/build-push-action@v6
with:
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
ghcr.io/cty/crm-api:sha-a1b2c3d4e5f6...    <- BẤT BIẾN, dùng để deploy và rollback
ghcr.io/cty/crm-api:1.4.2 <- bản phát hành
ghcr.io/cty/crm-api:main <- nhánh
ghcr.io/cty/crm-api:20260925-081422 <- thời điểm build
ghcr.io/cty/crm-api:latest <- tiện lợi, KHÔNG dùng để deploy

Nguyên tắc:

Deploy bằng tag bất biến (sha-). Tag di động (latest, main) chỉ để tiện tra cứu.

- name: Deploy
run: |
ssh deploy@server "/opt/crm/deploy.sh ghcr.io/cty/crm-api:sha-${{ github.sha }}"

Giờ rollback là một lệnh, và nó xác định:

git log --oneline -5
def4567 Sửa lỗi tính thuế       <- đang chạy, hỏng
abc1234 Thêm báo cáo doanh số <- muốn quay về đây
ssh deploy@server "/opt/crm/deploy.sh ghcr.io/cty/crm-api:sha-abc1234..."

Bốn lợi ích của tag theo SHA:

  1. Bất biến. sha-abc1234 luôn là cùng một image, mãi mãi.
  2. Truy được nguồn. Từ image biết ngay commit nào, từ commit biết ngay ai viết gì.
  3. Rollback xác định. Không cần đoán, không cần tra registry.
  4. Xác minh được. So sánh digest để chắc rằng image đang chạy đúng là image đã được build từ commit đó.

Thêm nhãn để image tự mô tả:

ARG VERSION=dev
ARG COMMIT=unknown
ARG BUILD_DATE

LABEL org.opencontainers.image.version=$VERSION \
org.opencontainers.image.revision=$COMMIT \
org.opencontainers.image.created=$BUILD_DATE \
org.opencontainers.image.source=https://github.com/cty/crm

ENV APP_VERSION=$VERSION APP_COMMIT=$COMMIT
app.MapGet("/version", () => new
{
Version = Environment.GetEnvironmentVariable("APP_VERSION"),
Commit = Environment.GetEnvironmentVariable("APP_COMMIT"),
Started = _thoiDiemKhoiDong,
});
curl https://crm.example.com/version
{"version":"1.4.2","commit":"abc1234...","started":"2026-09-25T08:14:22Z"}

Endpoint này là thứ đầu tiên bạn gọi khi có sự cố, và nó trả lời câu hỏi quan trọng nhất trong năm phút đầu: cái gì đang chạy?

Rollback trong GitHub Actions — một workflow chạy tay:

name: Rollback
on:
workflow_dispatch:
inputs:
commit_sha:
description: 'SHA đầy đủ của commit muốn quay về'
required: true

jobs:
rollback:
runs-on: ubuntu-latest
environment: production
steps:
- name: Kiểm tra image tồn tại
run: |
docker manifest inspect \
ghcr.io/cty/crm-api:sha-${{ inputs.commit_sha }} > /dev/null \
|| { echo "Không tìm thấy image cho commit này"; exit 1; }

- name: Deploy
run: |
ssh deploy@server \
"/opt/crm/deploy.sh ghcr.io/cty/crm-api:sha-${{ inputs.commit_sha }}"

Bước kiểm tra trước là quan trọng: thất bại ở đó mất hai giây và không đụng gì tới production, còn thất bại sau khi đã dừng container cũ thì bạn đang có một sự cố thứ hai.

environment: production giữ nguyên yêu cầu phê duyệt — rollback là thao tác khẩn cấp, nhưng nó vẫn nên có người biết.

Chính sách giữ image — để rollback còn khả thi:

- uses: actions/delete-package-versions@v5
with:
package-name: crm-api
package-type: container
min-versions-to-keep: 30 # giữ 30 bản gần nhất
delete-only-untagged-versions: true

Giữ ít nhất vài tuần image, không chỉ vài bản. Sự cố cần rollback xa thường là sự cố phát hiện muộn — một lỗi dữ liệu âm thầm, chẳng hạn — và lúc đó bạn cần quay về bản của tuần trước, không phải bản của giờ trước.

Và một thực tế cần nói rõ: rollback code không rollback được database.

Deploy #42 chạy một migration thêm cột NOT NULL
Deploy #42 hỏng vì lý do khác
Rollback về #41 -> code cũ không biết cột mới
-> INSERT thiếu cột -> lỗi ràng buộc

Đây là lý do expand–contract ở bài 13.12 quan trọng: nó bảo đảm mọi phiên bản schema đều tương thích với phiên bản code trước đó, và đó chính là điều kiện để rollback code luôn an toàn.

Không có kỷ luật đó, rollback là một canh bạc — và bạn chỉ biết mình thua sau khi đã bấm nút.

Tự kiểm tra​

Câu hỏi thường gặp

Vì sao phải khai permissions ở đầu workflow?

Mặc định của nhiều repo cũ là write cho mọi phạm vi, nghĩa là một action bị xâm nhập có thể push code, tạo release hay sửa issue. Khai tối thiểu là contents read ở cấp workflow rồi nâng riêng cho job thật sự cần.

Vì sao ghim action bằng tag lại rủi ro?

Tag di chuyển được, nên nếu tài khoản người sở hữu action bị xâm nhập, họ trỏ tag sang code độc hại và mọi workflow dùng nó sẽ chạy code đó với quyền và secret của bạn. Với action bên thứ ba thì ghim bằng SHA đầy đủ.

pull_request_target nguy hiểm ở đâu?

Nó chạy trong ngữ cảnh repo gốc nên có secret. Nếu bạn checkout code từ fork rồi build, bất kỳ ai mở pull request cũng thực thi được code tuỳ ý với secret của bạn. Dùng pull_request thường, nó chạy không có secret cho fork.

Vì sao phải build một lần cho mọi môi trường?

Build lại cho production nghĩa là bạn triển khai một artifact chưa từng được test, vì nó khác với artifact đã qua staging. Cùng một image, gắn thẻ theo commit sha, đi qua mọi môi trường.

Có cách nào deploy mà không lưu khoá SSH dài hạn không?

Ba cách. OIDC với cloud cấp token ngắn hạn nên không có secret dài hạn nào. Self-hosted runner trong mạng nội bộ không cần mở SSH ra Internet. Và pull-based, tức server tự kiểm tra registry và cập nhật khi có tag mới, nên CI chỉ cần quyền push image.

Vì sao cần job quét bảo mật theo lịch?

Image build hôm qua không có CVE nào nhưng tuần sau có, và nếu không ai build lại thì không ai biết. Một job theo lịch quét lại image đang chạy phát hiện lỗ hổng mới mà không cần chờ lần deploy tiếp theo.

Kết luận​

Ba điều đáng nhớ nhất:

  1. Khai permissions tối thiểu — mặc định nhiều repo là write cho mọi thứ.
  2. Ghim action bên thứ ba bằng SHA. Tag di chuyển được.
  3. Build một lần, triển khai artifact đó khắp nơi. Build lại là triển khai thứ chưa test.

Tham khảo​

Điều hướng​