Service API dùng node:18 làm base image — build xong 1.2 GB, pull mất 2 phút trên node chưa cache, Trivy scan ra 347 CVE toàn từ package không bao giờ dùng: gcc, perl, python3. Image chứa cả toolchain build lẫn .git directory. Copy Dockerfile từ tutorial, chạy được là deploy, không ai xem lại.

Image production cần hai tính chất: nhỏ (pull nhanh, cold start thấp) và an toàn (ít package thừa, không chạy root, không chứa secret). Hai tính chất này bổ trợ nhau — image càng nhỏ thì attack surface càng hẹp.


Tại sao kích thước image quan trọng

Kích thước image ảnh hưởng trực tiếp đến bốn thứ mà nhiều team chỉ nhận ra khi đã muộn.

Pull time là thứ rõ ràng nhất. Image 1 GB trên registry, mỗi node mới trong cluster phải tải về trước khi container start. Trên mạng nội bộ 1 Gbps thì mất khoảng 10 giây, nhưng cross-region hoặc edge node với bandwidth hạn chế thì có thể vài phút. Autoscaler cần spin node mới khi traffic tăng đột ngột — mỗi phút chờ pull image là một phút user chịu latency cao hoặc lỗi.

Storage cost tích luỹ nhanh hơn tưởng tượng. Mỗi image version giữ trên registry, mỗi node cache layer cũ. Với 50 service, mỗi service deploy 3 lần/tuần, giữ 10 version gần nhất — registry nhanh chóng lên hàng trăm GB. ECR, GCR, Docker Hub đều tính tiền storage.

Attack surface là lý do quan trọng nhất từ góc bảo mật. Mỗi binary, mỗi library trong image là một vector tấn công tiềm năng. Image có curl, wget, bash đầy đủ thì attacker exploit được RCE sẽ thoải mái tải payload, lateral move. Image chỉ có binary ứng dụng và vài shared library thì attacker gần như không làm gì được — không có shell để chạy command, không có tool để tải thêm malware.

Cold start trên serverless và edge (AWS Lambda container, Cloud Run, Fly.io) phụ thuộc trực tiếp vào image size. Image 50 MB cold start vài trăm millisecond. Image 1 GB có thể mất 5-10 giây — không chấp nhận được cho API endpoint.


Chọn base image

Chọn base image là quyết định ảnh hưởng lớn nhất đến kích thước và bảo mật — nhưng cũng là nơi nhiều team chọn theo thói quen thay vì phân tích.

Ubuntu và Debian đầy đủ

ubuntu:22.04 nặng khoảng 77 MB, debian:bookworm khoảng 116 MB. Cả hai đều chứa package manager (apt), nhiều utility hệ thống, và glibc đầy đủ. Ưu điểm là compatibility cao — hầu hết library và tool đều chạy không cần chỉnh gì. Nhược điểm là nhiều package thừa mà ứng dụng production không cần, và mỗi package là thêm CVE tiềm năng.

debian:bookworm-slim là variant đã strip bớt docs, locales, và package không thiết yếu — khoảng 74 MB. Đây là lựa chọn cân bằng nếu cần glibc và apt nhưng muốn nhỏ hơn Debian đầy đủ.

Alpine

alpine:3.19 chỉ khoảng 7 MB — nhỏ gấp 10 lần so với Debian slim. Alpine dùng musl libc thay vì glibc, busybox thay vì GNU coreutils, và apk thay vì apt. Kích thước nhỏ ấn tượng, nhưng musl tạo ra một số gotcha mà cần biết trước khi adopt.

DNS resolution trong musl khác glibc: musl không đọc nsswitch.conf, xử lý DNS purely qua resolver, và có hành vi khác khi gặp CNAME chain dài hoặc search domain phức tạp. Trên Kubernetes, một số service discovery pattern dựa vào hành vi DNS của glibc có thể hoạt động khác trên Alpine — thường thể hiện qua timeout kỳ lạ hoặc resolution fail sporadic.

Python wheels pre-built trên PyPI hầu hết compiled cho glibc. Trên Alpine, pip install nhiều package phải compile từ source, cần cài thêm gcc, musl-dev, header files — build chậm hơn và image lớn hơn nếu không dùng multi-stage. Tương tự với một số Node.js native addon dùng node-gyp.

Go và Rust static binary thì không gặp vấn đề gì trên Alpine vì binary đã static-link — nhưng nếu binary static thì dùng scratch hoặc distroless sẽ còn nhỏ hơn Alpine.

Distroless

Google Distroless image (gcr.io/distroless/static-debian12, gcr.io/distroless/base-debian12) chứa đúng runtime tối thiểu: glibc (hoặc không có gì cho static binary), ca-certificates, timezone data. Không có shell, không có package manager, không có ls hay cat. Image static variant chỉ khoảng 2 MB.

Ưu điểm bảo mật rõ ràng: attacker exploit RCE vào container distroless không có shell để chạy command, không có tool để tải payload. Debug khó hơn — không thể exec vào container và ls hay cat file. Nhưng trên production, debug bằng cách exec vào container không phải practice tốt — dùng log, metric, trace thay vì SSH/exec.

Distroless phù hợp tuyệt đối cho Go, Rust (static binary), Java (có variant chứa JRE), và Node.js (có variant chứa Node runtime). Với Python thì phức tạp hơn vì cần nhiều shared library.

Scratch

FROM scratch là image rỗng hoàn toàn — 0 byte. Chỉ dùng được cho static binary (Go với CGO_ENABLED=0, Rust static). Không có ca-certificates (phải copy vào nếu cần HTTPS), không có timezone data, không có gì. Nhỏ nhất có thể, nhưng cần tự lo mọi dependency.


Multi-stage build

Multi-stage build là kỹ thuật quan trọng nhất để giữ image production nhỏ. Ý tưởng đơn giản: stage đầu tiên chứa toàn bộ toolchain build (compiler, package manager, dev dependencies), stage cuối cùng chỉ copy artifact đã build vào base image tối thiểu.

Ví dụ với Go

# Stage 1: build
FROM golang:1.22-bookworm AS builder
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \
    go build -ldflags="-s -w" -o /app ./cmd/server

# Stage 2: runtime
FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=builder /app /app
ENTRYPOINT ["/app"]

Stage build dùng golang:1.22-bookworm nặng gần 1 GB — có Go compiler, git, gcc, mọi thứ cần để compile. Stage runtime dùng distroless static, chỉ copy binary /app vào. Image cuối cùng khoảng 7-10 MB tuỳ kích thước binary — nhỏ hơn 100 lần so với nếu dùng golang:1.22 làm runtime.

Flag -ldflags="-s -w" strip symbol table và debug info, giảm binary size 20-30%. CGO_ENABLED=0 đảm bảo binary static, không link dynamic library — chạy được trên scratch hoặc distroless static.

Ví dụ với Node.js

# Stage 1: install dependencies + build
FROM node:20-bookworm-slim AS builder
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --ignore-scripts
COPY . .
RUN npm run build && npm prune --production

# Stage 2: runtime
FROM gcr.io/distroless/nodejs20-debian12:nonroot
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/package.json ./
CMD ["dist/main.js"]

Stage build cài tất cả dependencies (gồm devDependencies), chạy TypeScript compile hoặc bundler, rồi npm prune --production xoá devDependencies. Stage runtime chỉ copy output đã build, production dependencies, và package.json. Không có npm, npx, hay source TypeScript trong image cuối.

npm ci thay vì npm install đảm bảo cài đúng version trong lockfile, reproducible build. Flag --ignore-scripts ngăn postinstall script chạy lúc build — giảm rủi ro supply chain attack từ malicious postinstall.


Tối ưu layer cache

Docker build cache hoạt động theo nguyên tắc: nếu instruction và context input của một layer không thay đổi, Docker dùng lại layer cached thay vì chạy lại. Cache invalidate từ layer đầu tiên thay đổi trở đi — tất cả layer sau đều phải rebuild.

Hệ quả là thứ tự instruction trong Dockerfile ảnh hưởng trực tiếp đến tốc độ build. Đặt những thứ ít thay đổi trước, những thứ hay thay đổi sau.

Pattern phổ biến nhất: copy lockfile trước, cài dependencies, rồi mới copy source code. Dependencies thay đổi ít hơn source code — khi chỉ sửa code mà không thêm/xoá dependency, Docker dùng lại layer npm ci hoặc go mod download đã cached, chỉ rebuild từ COPY . . trở đi. Tiết kiệm hàng phút mỗi lần build.

# Tốt: lockfile trước, source sau
COPY package.json package-lock.json ./
RUN npm ci
COPY . .

# Không tốt: copy tất cả rồi mới install
COPY . .
RUN npm ci

Với cách thứ hai, bất kỳ file nào trong context thay đổi (kể cả sửa README) đều invalidate cache của COPY . ., buộc npm ci chạy lại. Với cách đầu, sửa README chỉ invalidate từ COPY . . thứ hai — npm ci vẫn cached.


.dockerignore

File .dockerignore ngăn Docker gửi file không cần thiết vào build context. Build context là toàn bộ thư mục mà Docker daemon nhận khi bạn chạy docker build — context lớn thì gửi chậm, và COPY . . sẽ copy cả thứ không cần.

.git
node_modules
dist
*.md
tests
coverage
.env
.env.*
docker-compose*.yml

.git directory có thể hàng trăm MB với repo lớn. node_modules local không nên vào image — dependencies phải cài từ lockfile trong build stage để đảm bảo reproducible. File .env chứa secret tuyệt đối không được vào image — dù ở layer nào, secret vẫn có thể extract từ image layer.

Thiếu .dockerignore là nguyên nhân phổ biến khiến build context lên tới hàng GB, build chậm và image chứa file thừa.


Không chạy root

Mặc định, process trong Docker container chạy với UID 0 — root. Nếu attacker exploit RCE, họ có quyền root trong container. Dù container có namespace isolation, root trong container vẫn nguy hiểm: có thể đọc/ghi mọi file trong container filesystem, mount host path nếu container có volume mount không hạn chế, và trong một số cấu hình thiếu seccomp/AppArmor, có thể escape container.

# Tạo user non-root
RUN addgroup --system --gid 1001 appgroup && \
    adduser --system --uid 1001 --ingroup appgroup appuser

# Chuyển ownership file cần thiết
COPY --chown=appuser:appgroup --from=builder /app /app

USER appuser

Directive USER đặt UID cho mọi instruction sau đó và cho container runtime. Distroless image có sẵn tag :nonroot — đã cấu hình user non-root, không cần adduser thủ công.

Trên Kubernetes, enforce non-root bằng securityContext trong pod spec:

securityContext:
  runAsNonRoot: true
  runAsUser: 1001
  allowPrivilegeEscalation: false
  readOnlyRootFilesystem: true

readOnlyRootFilesystem: true ngăn process ghi vào filesystem — attacker không thể tải và lưu payload. Nếu ứng dụng cần ghi file tạm (log buffer, upload temp), mount emptyDir volume vào đúng path cần ghi, giữ phần còn lại read-only.


Quét CVE

Image chứa hàng trăm package hệ điều hành và library. Mỗi package có thể có known vulnerability (CVE) mà attacker đã có exploit public. Quét CVE không phải “nice to have” — nó phải nằm trong CI pipeline, chạy mỗi lần build.

Trivy (Aqua Security, open source) là tool phổ biến nhất hiện tại. Scan nhanh, database CVE cập nhật thường xuyên, hỗ trợ nhiều OS package manager và language-specific dependency (npm, pip, go modules, Maven):

trivy image --severity HIGH,CRITICAL myapp:latest

Grype (Anchore, open source) và Snyk Container là hai lựa chọn khác. Grype nhẹ hơn Trivy, Snyk có tính năng fix suggestion và monitoring liên tục.

Trong CI, fail build khi có CVE critical:

trivy image --exit-code 1 --severity CRITICAL myapp:latest

Quét CVE ở hai thời điểm: lúc build (trong CI pipeline) và định kỳ trên image đang chạy trong registry. CVE mới được công bố hàng ngày — image build tuần trước có thể sạch lúc build nhưng có critical CVE tuần này. Schedule scan hàng ngày cho tất cả image đang deploy.

Giảm CVE hiệu quả nhất không phải bằng cách patch từng package, mà bằng cách dùng base image nhỏ hơn — image distroless có vài chục package thay vì vài trăm, số CVE giảm tương ứng.


Giảm package thừa

Mỗi apt-get install thêm vào image là thêm attack surface và thêm CVE tiềm năng. Nguyên tắc: chỉ cài package mà ứng dụng cần để chạy, không phải để debug.

curl, wget, netcat, telnet — hữu ích khi debug nhưng trên production image, chúng là công cụ cho attacker. Nếu cần health check endpoint, dùng ứng dụng tự expose /healthz thay vì curl localhost:8080/healthz trong Dockerfile HEALTHCHECK.

Khi phải cài package trong Debian/Ubuntu, clean cache sau khi cài và gộp vào cùng một RUN instruction để không tạo layer chứa cache:

RUN apt-get update && \
    apt-get install -y --no-install-recommends ca-certificates && \
    rm -rf /var/lib/apt/lists/*

--no-install-recommends ngăn apt cài package “recommended” mà không bắt buộc — thường giảm 50-70% số package được cài. rm -rf /var/lib/apt/lists/* xoá apt cache — nhưng phải trong cùng RUN instruction. Nếu tách thành RUN riêng, layer chứa cache đã được commit, rm chỉ tạo thêm layer mới đánh dấu file đã xoá chứ không giảm kích thước image.


Multi-arch build

Workload ngày càng chạy trên cả AMD64 (server truyền thống) và ARM64 (Graviton trên AWS, M-series Mac, Ampere trên GCP/Oracle). Image chỉ build cho một architecture thì không chạy được trên architecture kia — hoặc chạy qua emulation, chậm gấp 5-10 lần.

Docker buildx hỗ trợ build multi-arch image và push manifest list lên registry:

docker buildx create --use
docker buildx build \
  --platform linux/amd64,linux/arm64 \
  -t myregistry/myapp:1.0.0 \
  --push .

Khi pull, Docker daemon tự chọn đúng architecture variant từ manifest list — transparent cho người dùng. Lưu ý khi build multi-arch: nếu Dockerfile có RUN instruction chạy binary (ví dụ compile), buildx dùng QEMU emulation cho architecture khác — chậm hơn đáng kể. Với Go, cross-compile bằng GOARCH env var nhanh hơn emulation nhiều lần:

ARG TARGETARCH
RUN CGO_ENABLED=0 GOOS=linux GOARCH=${TARGETARCH} \
    go build -o /app ./cmd/server

TARGETARCH là build arg mà buildx tự inject — amd64 hoặc arm64 tuỳ platform đang build.


Ký và xác minh image

Image pull từ registry có thật sự là image mà CI đã build? Không có image signing, câu trả lời là “tin tưởng registry”. Nếu registry bị compromise hoặc có supply chain attack, image có thể bị thay thế mà không ai biết.

Cosign (thuộc dự án Sigstore) là tool ký và verify image phổ biến nhất hiện tại:

# Ký image sau khi push
cosign sign --key cosign.key myregistry/myapp:1.0.0

# Verify trước khi deploy
cosign verify --key cosign.pub myregistry/myapp:1.0.0

Trên Kubernetes, Kyverno hoặc Connaisseur enforce policy “chỉ deploy image đã ký” — admission controller reject pod spec có image chưa verify được signature. Đây là lớp phòng thủ quan trọng trong supply chain security.

Cosign hỗ trợ keyless signing qua Sigstore Fulcio — dùng OIDC identity (GitHub Actions, GitLab CI) thay vì quản lý private key. CI pipeline sign image bằng identity của workflow, không cần lưu secret key ở đâu.


Registry best practices

Tag latest là anti-pattern trên production. latest là mutable tag — mỗi lần push image mới, latest trỏ đến image khác. Hai deployment cùng dùng myapp:latest nhưng pull ở hai thời điểm khác nhau có thể chạy hai version code khác nhau. Debug và rollback trở thành ác mộng vì không biết latest lúc đó là version nào.

Dùng semantic version (myapp:1.2.3) hoặc git commit SHA (myapp:abc1234) cho production deployment. Tag immutable — một khi push thì không overwrite. Nhiều registry hỗ trợ tag immutability policy: ECR, GCR, Harbor đều có option bật.

Retention policy cũng cần thiết lập từ đầu. Giữ 20-30 version gần nhất, xoá tự động version cũ hơn. Không có retention thì registry phình to vô hạn — mỗi CI build push một image, sau vài tháng có hàng nghìn image mà không ai dùng.


Secret trong image — sai lầm đắt giá

Đây là lỗi bảo mật nghiêm trọng mà vẫn xảy ra thường xuyên. Mọi thứ trong Dockerfile instruction đều được lưu vào image layer — kể cả khi bạn xoá file ở layer sau.

# SAI: secret nằm trong layer, extract được
COPY .env /app/.env
RUN source /app/.env && npm run migrate
RUN rm /app/.env  # Không giúp gì — layer trước vẫn chứa .env

File .env nằm trong layer của COPY .env, dù RUN rm ở layer sau. Ai có quyền pull image đều extract được secret bằng docker save rồi tar extract từng layer.

Giải pháp đúng: dùng Docker BuildKit secret mount:

# syntax=docker/dockerfile:1
RUN --mount=type=secret,id=db_url \
    DB_URL=$(cat /run/secrets/db_url) npm run migrate
docker build --secret id=db_url,src=.env .

Secret chỉ available trong RUN instruction đó, không lưu vào layer. Ngoài build, runtime secret nên inject qua environment variable hoặc mounted volume (Kubernetes Secret, Vault agent), không bao giờ bake vào image.


Sai lầm phổ biến

Ngoài secret trong layer, còn vài sai lầm mà team hay mắc khi mới làm việc với container image.

Build context quá lớn vì thiếu .dockerignore. Docker CLI gửi toàn bộ build context tới daemon trước khi bắt đầu build. Context 2 GB (gồm .git, node_modules, data file) mất hàng chục giây chỉ để gửi, chưa kể build. Thêm .dockerignore là fix đơn giản nhất.

Dùng ADD thay vì COPY. ADD có magic behaviour: tự extract tar archive, hỗ trợ URL remote. Trên production Dockerfile, COPY explicit hơn và không có side effect bất ngờ. Chỉ dùng ADD khi cần extract tar — và ngay cả lúc đó, COPY + RUN tar xf rõ ràng hơn.

Không pin version base image. FROM node:20 có thể là 20.1 hôm nay và 20.5 tháng sau — build không reproducible. Pin đến minor hoặc patch version: FROM node:20.12-bookworm-slim. Hoặc tốt hơn, pin đến digest: FROM node:20.12-bookworm-slim@sha256:abc123... — đảm bảo cùng một image bất kể registry tag có bị overwrite.

Chạy apt-get updateapt-get install ở hai RUN instruction riêng. Layer apt-get update được cache, khi thêm package mới Docker dùng lại cache cũ (package index cũ) — có thể cài sai version hoặc fail vì package đã bị remove khỏi repo. Luôn gộp updateinstall trong cùng một RUN.


Dockerfile tổng hợp cho Node.js production

Đây là Dockerfile mà team có thể dùng làm template, áp dụng tất cả practice đã thảo luận:

# syntax=docker/dockerfile:1
FROM node:20.12-bookworm-slim AS builder
WORKDIR /app

COPY package.json package-lock.json ./
RUN npm ci --ignore-scripts

COPY tsconfig.json ./
COPY src ./src
RUN npm run build && npm prune --production

FROM gcr.io/distroless/nodejs20-debian12:nonroot
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/package.json ./

EXPOSE 3000
CMD ["dist/main.js"]

Build stage cài dependencies, compile TypeScript, prune devDependencies. Runtime stage dùng distroless Node.js image với tag nonroot — không có shell, không có package manager, chạy non-root. Image cuối khoảng 130-170 MB tuỳ node_modules production — nhỏ hơn 7-8 lần so với dùng node:20 làm runtime.


Tóm tắt

Image container production cần nhỏ và an toàn — hai tính chất bổ trợ nhau vì image nhỏ hơn đồng nghĩa ít package thừa, ít CVE, ít attack surface. Multi-stage build tách toolchain build khỏi runtime — stage cuối chỉ chứa artifact cần chạy. Base image chọn theo nhu cầu: distroless hoặc scratch cho static binary, Alpine nếu chấp nhận musl gotcha, Debian slim nếu cần glibc đầy đủ.

Layer cache tối ưu bằng cách đặt instruction ít thay đổi trước — copy lockfile rồi cài dependencies trước khi copy source. .dockerignore ngăn file thừa vào build context. Non-root user bằng directive USER hoặc tag :nonroot, kết hợp readOnlyRootFilesystem trên Kubernetes. Quét CVE bằng Trivy hoặc Grype trong CI pipeline, fail build khi có critical.

Secret không bao giờ nằm trong image layer — dùng BuildKit secret mount cho build-time, environment variable hoặc mounted secret cho runtime. Tag immutable thay vì latest, pin base image version, set retention policy trên registry. Image signing bằng cosign đảm bảo supply chain integrity — enforce bằng admission controller trên cluster.

Nhỏ gọn và an toàn không phải hai mục tiêu tách biệt — chúng là hai mặt của cùng một nguyên tắc: chỉ đưa vào production đúng thứ cần thiết để ứng dụng chạy, không hơn.