Mở đầu — vì sao bài này quan trọng
Nếu bạn đang vận hành bất kỳ hệ thống nào chạy trên container (Docker, Kubernetes), thì container image chính là "đơn vị vận chuyển" của toàn bộ ứng dụng. Nó gói gọn mã nguồn, thư viện phụ thuộc, runtime và cấu hình vào một artifact duy nhất, để bạn có thể build một lần rồi chạy ở bất cứ đâu — laptop lập trình viên, staging, production, hay data center bên Singapore.
Vấn đề là ở đây: image không tự nhiên mà "gọn, nhanh, an toàn". Rất nhiều đội ở Việt Nam mà tôi từng làm việc cùng có những image nặng 1.2 GB chỉ để chạy một API Node.js nhỏ, mất 8-10 phút mỗi lần build trong CI, và chứa hàng trăm lỗ hổng bảo mật (CVE) mà không ai biết. Hệ quả là deploy chậm, tốn băng thông pull image, tốn dung lượng registry, và mở toang cửa cho tấn công.
Bài này tập trung riêng vào ba trụ cột vận hành của container image: Build (dựng image sao cho nhỏ, nhanh, tái lập được), Registry (lưu trữ, phân phối, quản lý vòng đời image), và Scan (quét lỗ hổng, ký số, đảm bảo image sạch trước khi chạy). Đây là phần nền tảng mà mọi kỹ sư Ops/DevOps đều phải nắm chắc — vì một image tồi sẽ kéo theo mọi khâu phía sau đều tồi.
Lưu ý phạm vi: bài này không đi sâu vào cách vận hành cluster Kubernetes (đó là Bài 38), cũng không nói về pipeline CI/CD tổng thể (Bài 39) — chúng ta chỉ tập trung vào bản thân artifact image.
Khái niệm cốt lõi
Image là gì và cấu trúc layer
Một container image không phải một khối liền mạch, mà là một chồng các layer (lớp) chỉ đọc, xếp lên nhau theo mô hình union filesystem. Mỗi câu lệnh trong Dockerfile (FROM, RUN, COPY, ADD...) thường tạo ra một layer mới. Khi container chạy, một lớp ghi (writable layer) mỏng được thêm lên trên cùng.
Hiểu về layer là chìa khóa để tối ưu, vì hai lý do:
- Layer được cache: nếu layer không đổi, Docker tái sử dụng bản đã build trước đó thay vì build lại. Đây là nền tảng của layer caching.
- Layer được chia sẻ: hai image cùng dựa trên
node:20-alpinesẽ chia sẻ các layer base, nên registry và host chỉ lưu một bản.
Multi-stage build — vũ khí giảm kích thước số một
Đây là kỹ thuật quan trọng nhất bạn cần thuộc lòng. Ý tưởng: một Dockerfile có nhiều FROM, chia thành nhiều stage. Stage build chứa toàn bộ toolchain nặng nề (compiler, JDK, node_modules dev, go build...), còn stage cuối (final) chỉ copy đúng artifact đã biên dịch và runtime tối thiểu.
Ví dụ với một ứng dụng Go:
Stage 1: build — có toàn bộ Go toolchain (~800 MB)
FROM golang:1.22 AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o server ./cmd/serverStage 2: final — chỉ có binary, chạy trên base tí hon
FROM gcr.io/distroless/static-debian12
COPY --from=builder /app/server /server
ENTRYPOINT ["/server"]
Kết quả: image cuối có thể chỉ 15-20 MB thay vì gần 1 GB, vì toàn bộ Go toolchain bị bỏ lại ở stage builder, không đi vào image production.
Layer caching và thứ tự câu lệnh
Docker build cache theo nguyên tắc: nếu một layer thay đổi thì tất cả layer sau nó đều bị build lại (cache bị "vỡ" - cache bust). Vì vậy, hãy đặt những thứ ít thay đổi lên trước, những thứ hay thay đổi xuống dưới.
Sai lầm kinh điển:
COPY . .
RUN npm install # mỗi lần sửa 1 dòng code là npm install chạy lại từ đầu
Đúng cách — tách phần dependency ra trước:
COPY package.json package-lock.json ./
RUN npm ci # chỉ chạy lại khi package đổi
COPY . . # code đổi thường xuyên, đặt sau
Với cách thứ hai, khi bạn chỉ sửa code, npm ci được lấy từ cache, tiết kiệm vài phút mỗi lần build.
Base image — chọn cho đúng
- Full (debian, ubuntu): đầy đủ, dễ debug, nhưng nặng (100-300 MB) và nhiều CVE.
- Alpine: rất nhỏ (~5 MB) nhờ dùng musl libc, nhưng đôi khi gặp lỗi tương thích với thư viện dựa trên glibc.
- Distroless (Google): không có shell, không có package manager, chỉ có runtime — cực an toàn vì bề mặt tấn công tối thiểu, nhưng khó debug (không
execvào shell được). - Slim: bản rút gọn của image chính thức, cân bằng giữa kích thước và tính tương thích.
Registry — kho lưu trữ và phân phối image
Registry là nơi image được đẩy lên (push) sau khi build và kéo về (pull) khi deploy. Các loại phổ biến:
- Public: Docker Hub, GitHub Container Registry (ghcr.io).
- Cloud managed: Amazon ECR, Google Artifact Registry, Azure ACR — tích hợp sẵn IAM, scan, replication.
- Self-hosted: Harbor (rất phổ biến ở các doanh nghiệp Việt Nam vì miễn phí, có scan tích hợp Trivy, quản lý quyền theo project), hoặc registry mã nguồn mở của Docker.
- Tag: nhãn cho image, ví dụ
myapp:1.4.2. Tuyệt đối tránh dùnglatestcho production vì nó không cố định — hôm naylatestlà bản A, mai có thể là bản B. - Digest: mã băm SHA256 định danh duy nhất và bất biến của image, ví dụ
myapp@sha256:abc123.... Deploy theo digest đảm bảo bạn luôn chạy đúng bản đã kiểm thử. - Image retention / garbage collection: chính sách tự xóa image cũ để tiết kiệm dung lượng.
Scan — quét lỗ hổng và ký số
Mỗi image chứa hàng chục đến hàng trăm gói phần mềm, mỗi gói có thể có CVE (Common Vulnerabilities and Exposures — lỗ hổng đã được công bố). Image scanning dùng các công cụ như Trivy, Grype, Clair để đối chiếu danh sách gói trong image với cơ sở dữ liệu CVE, phân loại theo mức độ CRITICAL/HIGH/MEDIUM/LOW.
Ngoài ra còn hai khái niệm bảo mật nâng cao:
- Image signing (Cosign/Sigstore): ký số image để chứng minh nó do đúng pipeline của bạn tạo ra, chưa bị chỉnh sửa.
- SBOM (Software Bill of Materials): "hóa đơn nguyên liệu" liệt kê mọi thành phần trong image, phục vụ audit và truy vết khi có CVE mới bùng phát (như vụ Log4Shell).
Tình huống thực tế
Ví dụ 1: Startup fintech ở TP.HCM — image 1.1 GB và hóa đơn CI phình to
Một startup ví điện tử tại TP.HCM có khoảng 25 microservice Node.js, mỗi service dùng Dockerfile viết theo kiểu FROM node:18, COPY . ., RUN npm install. Mỗi image nặng trung bình 1.1 GB. Hậu quả cụ thể:
- Mỗi lần build trong GitLab CI mất 9-11 phút, chủ yếu vì
npm installchạy lại mỗi commit do đặt sai thứ tự COPY. - Bill lưu trữ registry (ECR) tăng lên vì giữ hàng nghìn tag cũ, mỗi tag hơn 1 GB.
- Khi cần scale gấp lúc có chương trình khuyến mãi, node mới pull image mất gần 40 giây, làm auto-scaling phản ứng chậm.
node:18-alpine, dùng multi-stage build (stage build có dev dependencies, stage final chỉ npm ci --omit=dev và copy dist), và tách layer package.json ra trước để tận dụng cache. Kết quả: image giảm còn khoảng 180 MB, thời gian build trung bình còn 2-3 phút, thời gian pull node mới giảm còn dưới 10 giây.Bài học: phần lớn "cân nặng" và độ chậm của image đến từ ba thứ cơ bản — base image sai, không multi-stage, và sai thứ tự layer. Sửa ba thứ này thường giải quyết 80% vấn đề.
Ví dụ 2: Công ty e-commerce Đông Nam Á — Harbor và cổng scan chặn deploy
Một sàn thương mại điện tử hoạt động tại Việt Nam, Thái Lan và Indonesia tự vận hành Harbor làm registry nội bộ. Họ bật tính năng scan bằng Trivy tích hợp trong Harbor và cấu hình policy: image có lỗ hổng mức CRITICAL sẽ bị chặn không cho pull xuống production.
Một hôm, đội platform cập nhật base image và vô tình kéo theo một phiên bản OpenSSL dính CVE mức CRITICAL. Nhờ policy, image này bị Harbor chặn ngay ở bước deploy — pipeline fail với thông báo rõ ràng, thay vì để lỗ hổng lọt lên production và bị phát hiện trong đợt pentest 3 tháng sau. Đội chỉ mất nửa ngày để cập nhật base image và rebuild.
Bài học: scan chỉ có giá trị khi gắn với policy gate — tức là scan phải có quyền chặn deploy. Scan mà chỉ để "xem cho biết" thì lỗ hổng vẫn trôi lên production như thường. Đặt cổng chặn ở mức CRITICAL/HIGH là điểm khởi đầu hợp lý cho hầu hết đội.
Ví dụ 3: Đội SaaS B2B — thảm họa tag latest
Một đội SaaS nhỏ deploy Kubernetes với manifest ghi image: myapp:latest. Một buổi tối, một pod bị Kubernetes lên lịch lại (reschedule) sang node khác. Node đó pull latest mới nhất — vốn là một bản đang dở dang mà một lập trình viên vừa push để test. Kết quả: một pod chạy bản cũ, một pod chạy bản lỗi, gây ra hành vi không nhất quán suốt 2 tiếng trước khi ai đó nhận ra là do tag latest.
Họ sửa bằng cách: mỗi build gắn tag bất biến theo commit SHA (ví dụ myapp:git-a3f9c1), và deploy theo digest thay vì tag. Từ đó, mọi pod luôn chạy đúng một phiên bản đã được kiểm thử.
Bài học: latest là một cái bẫy im lặng. Luôn dùng tag bất biến (immutable tag) hoặc digest cho production.
Hướng dẫn từng bước
Đây là quy trình vận hành container image từ đầu đến cuối bạn có thể áp dụng ngay:
- Chọn base image tối thiểu phù hợp. Ưu tiên
-slim,-alpine, hoặcdistrolesstùy khả năng debug và tương thích của đội. Ghim phiên bản cụ thể (node:20.11-alpine), tránh tag trôi nổi.
- Viết multi-stage build. Tách rõ stage build (toolchain, dev deps) và stage final (chỉ runtime + artifact). Chỉ
COPY --from=builderđúng những gì cần chạy.
- Sắp xếp layer để tối ưu cache. Copy file khai báo dependency (
package.json,go.mod,requirements.txt) và cài đặt trước; copy source code sau. Gộp các lệnhRUNliên quan bằng&&để giảm số layer.
- Thêm
.dockerignore. Loạinode_modules,.git, file log, secret local ra khỏi build context — vừa nhanh hơn, vừa tránh vô tình đóng gói secret vào image.
- Chạy container bằng non-root user. Thêm
USER appuserđể giảm rủi ro bảo mật nếu container bị chiếm quyền.
- Gắn tag bất biến. Tag theo commit SHA và/hoặc version semantic (
1.4.2). Không đẩylatestlên production.
- Scan image trước khi push. Chạy
trivy image myapp:git-a3f9c1trong CI. Nếu có lỗ hổng CRITICAL/HIGH, fail pipeline.
- Push lên registry có kiểm soát. Dùng ECR/Artifact Registry/Harbor với IAM và phân quyền theo project. Bật scan-on-push nếu registry hỗ trợ.
- Ký image (tùy chọn nâng cao). Dùng Cosign để ký, và bật admission policy trong cluster chỉ chấp nhận image đã ký.
- Thiết lập retention policy. Tự động xóa image cũ (ví dụ giữ 10 tag gần nhất mỗi service, xóa image untagged sau 7 ngày) để không phình dung lượng registry.
Lỗi thường gặp & mẹo
Lỗi: cài package rồi mới xóa cache ở layer sau. RUN apt-get install ... rồi ở layer khác RUN apt-get clean không giúp gì, vì file đã nằm trong layer trước và không thể "xóa ngược". Phải gộp trong cùng một RUN: RUN apt-get update && apt-get install -y curl && rm -rf /var/lib/apt/lists/*.
Lỗi: đóng gói secret vào image. Đừng bao giờ COPY .env hay ARG DB_PASSWORD rồi để lộ trong layer — bất kỳ ai pull image đều docker history ra được. Dùng runtime secret (Bài 33) hoặc BuildKit secret mount.
Lỗi: quét scan nhưng không chặn. Scan chỉ có ý nghĩa khi gắn policy gate fail pipeline. "Scan để tham khảo" tương đương với không scan.
Mẹo: bật BuildKit và cache mount. DOCKER_BUILDKIT=1 cho phép build song song các stage và dùng --mount=type=cache để cache thư mục ~/.npm, ~/.m2 xuyên suốt các lần build — tăng tốc đáng kể trong CI.
Mẹo: dùng registry cache trong CI. Khai báo --cache-from trỏ về image trên registry để runner CI (thường bắt đầu từ trạng thái trống) vẫn tận dụng được layer cache từ lần build trước.
Mẹo: quét cả trong CI lẫn định kỳ trên registry. CVE mới được công bố mỗi ngày — một image "sạch" hôm nay có thể "bẩn" tuần sau. Scan định kỳ image đang chạy trong registry, không chỉ scan một lần lúc build.
Mẹo: kiểm tra kích thước và phân tích layer. Dùng docker images để xem size, và công cụ dive để soi từng layer, phát hiện layer nào đang phình to bất thường.
Bài tập thực hành
- Tối ưu một Dockerfile có sẵn. Lấy một ứng dụng bất kỳ (Node.js, Python, hoặc Go). Viết một Dockerfile "ngây thơ" (single-stage,
COPY . .trước, base image full). Đo kích thước và thời gian build. Sau đó viết lại bằng multi-stage + base image nhỏ + sắp xếp layer đúng. So sánh kích thước image và thời gian build trước/sau, ghi lại con số.
- Chạy scan và diễn giải kết quả. Cài Trivy, chạy
trivy image <image của bạn>. Đếm số CVE theo từng mức nghiêm trọng. Chọn một lỗ hổng HIGH/CRITICAL, tìm hiểu nó đến từ gói nào, và thử nâng cấp base image để xem số CVE giảm đi bao nhiêu.
- Thiết kế chính sách registry. Giả sử bạn quản lý registry cho một đội có 15 service, build khoảng 30 lần/ngày. Viết ra: quy ước đặt tag (naming convention), policy retention (giữ gì, xóa gì, sau bao lâu), và quy tắc scan gate (chặn ở mức nào). Giải thích lý do cho từng lựa chọn.
Tóm tắt
Container image là artifact trung tâm của mọi hệ thống container hóa, và ba trụ cột vận hành của nó là Build, Registry, Scan.
- Build: dùng multi-stage build để tách toolchain khỏi image production, chọn base image tối thiểu (slim/alpine/distroless), và sắp xếp layer đúng thứ tự để tận dụng cache — đây là ba đòn bẩy lớn nhất giúp image nhỏ và build nhanh.
- Registry: chọn registry có kiểm soát quyền (ECR, Artifact Registry, Harbor), luôn dùng tag bất biến hoặc digest thay cho
latest, và thiết lập retention policy để không phình dung lượng. - Scan: quét CVE bằng Trivy/Grype trong CI, gắn với policy gate để chặn deploy khi có lỗ hổng nghiêm trọng, và quét định kỳ vì CVE mới xuất hiện liên tục. Nâng cao hơn thì ký image (Cosign) và tạo SBOM để phục vụ audit.
latest là cái bẫy im lặng. Nắm chắc những nguyên tắc này, bạn đã có một nền tảng vận hành image vững vàng để xây dựng mọi thứ phía trên.