Mở đầu — vì sao bài này quan trọng
Hãy tưởng tượng bạn đã xây dựng một pipeline CI/CD hoàn hảo: build nhanh, test đầy đủ, deploy tự động. Nhưng cuối cùng, thứ chạy trên production lại là một container image nặng 1.2 GB, chạy bằng quyền root, chứa nguyên bộ compiler, curl, bash, hàng trăm thư viện hệ thống mà ứng dụng của bạn không bao giờ dùng tới. Với một kẻ tấn công, image đó không phải một ứng dụng — nó là một hộp đồ nghề đầy đủ. Chỉ cần chọc thủng được một lỗ hổng nhỏ trong app, chúng có sẵn wget để tải mã độc, bash để chạy shell, và quyền root để leo thang.
Container image hardening — làm cứng image — chính là công đoạn khép lại vòng đời bảo mật của pipeline. Ở các bài trước bạn đã học quét lỗ hổng (SAST/DAST/SCA ở Bài 15), quản lý secret (Bài 16). Bài này tập trung riêng vào việc làm cho bản thân artifact cuối cùng — cái image — trở nên nhỏ, sạch và khó bị lợi dụng. Đây không phải chuyện lý thuyết. Ở Đông Nam Á, rất nhiều đội DevOps vẫn dùng FROM node:18 hay FROM python:3.11 mặc định rồi đẩy thẳng lên production mà không hề biết mình đang mang theo hàng trăm CVE (lỗ hổng đã công bố) ngay từ dòng đầu tiên của Dockerfile.
Năm mục tiêu cốt lõi của bài, cũng chính là năm nguyên tắc bạn cần khắc cốt ghi tâm: image nhỏ, bề mặt tấn công tối thiểu, chạy non-root, filesystem chỉ đọc, và ghim phiên bản (pinned versions). Nắm được năm điều này, image của bạn sẽ an toàn hơn 90% các image đang chạy ngoài kia.
Khái niệm cốt lõi
Bề mặt tấn công (attack surface) là gì
Mỗi thứ có mặt trong image — mỗi package, mỗi binary, mỗi file cấu hình — là một điểm mà kẻ tấn công có thể lợi dụng. Bề mặt tấn công là tổng của tất cả các điểm đó. Nguyên tắc số một của hardening cực kỳ đơn giản: cái gì không cần, đừng để trong image. Không có bash thì không ai chạy được reverse shell qua bash. Không có curl thì không tải được payload. Không có package manager thì không cài thêm được gì lúc runtime.
Image nhỏ — vì sao nó quan trọng hơn bạn nghĩ
Image nhỏ không chỉ tiết kiệm băng thông và tăng tốc deploy (điều rất đáng giá khi đội bạn deploy 50 lần/ngày). Nó còn trực tiếp giảm bề mặt tấn công. Một image node:18 đầy đủ nặng khoảng 1 GB và chứa hàng trăm gói OS. Cùng ứng dụng đó trên node:18-alpine chỉ còn ~180 MB, và trên gcr.io/distroless/nodejs18 chỉ còn ~130 MB với gần như không có công cụ hệ thống nào. Ít gói hơn nghĩa là ít CVE hơn, ít thứ phải vá hơn.
Có ba "cấp độ" base image bạn nên biết:
- Distro đầy đủ (
ubuntu,debian,node:18): tiện lúc dev nhưng nặng và nhiều CVE. - Alpine (
node:18-alpine): dùngmusl libcthayglibc, cực nhẹ. Lưu ý một số thư viện biên dịch sẵn cho glibc có thể không chạy được. - Distroless / scratch: chỉ chứa runtime tối thiểu, không có shell, không có package manager. An toàn nhất nhưng khó debug hơn.
Multi-stage build — chìa khóa của mọi thứ
Đây là kỹ thuật quan trọng nhất của bài. Ý tưởng: tách môi trường build ra khỏi môi trường runtime. Bạn dùng một stage đầy đủ công cụ để compile/build, rồi chỉ copy kết quả cuối cùng sang một stage runtime tối giản. Toàn bộ compiler, devDependencies, file tạm... bị bỏ lại và không bao giờ lên tới production.
# Stage 1: builder — có đủ công cụ để build
FROM node:18-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build && npm prune --production
# Stage 2: runtime — tối giản, chỉ có thứ cần chạy
FROM gcr.io/distroless/nodejs18-debian12 AS runtime
WORKDIR /app
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/dist ./dist
USER nonroot
EXPOSE 3000
CMD ["dist/server.js"]
Kết quả: image runtime không có npm, không có mã nguồn TypeScript, không có devDependencies, không có shell.
Non-root user
Mặc định, tiến trình trong container chạy bằng root (UID 0). Nếu ai đó thoát khỏi được ứng dụng, họ có quyền root trong container — và nếu kết hợp với một lỗ hổng escape của runtime, có thể là root trên cả host. Giải pháp: tạo một user thường và khai báo USER trong Dockerfile. Với distroless đã có sẵn user nonroot (UID 65532).
Read-only filesystem & pinned versions
Filesystem chỉ đọc: ứng dụng production hiếm khi cần ghi vào chính thư mục code của nó. Chạy container với --read-only (hoặc readOnlyRootFilesystem: true trong Kubernetes) ngăn kẻ tấn công ghi payload xuống đĩa. Với thư mục thực sự cần ghi (log tạm, cache), bạn mount một tmpfs riêng.
Ghim phiên bản (pinned versions): FROM node:18 là "tag di động" — hôm nay nó trỏ tới một bản, tuần sau có thể là bản khác. Điều này phá vỡ tính tái lập (reproducibility) và có thể âm thầm kéo về một image mới có lỗ hổng. Hãy ghim theo digest (FROM node:18-alpine@sha256:...), như vậy bạn luôn build từ đúng một image bit-for-bit.
Tình huống thực tế
Ví dụ 1 — Startup fintech ở TP.HCM và 240 CVE bất ngờ
Một startup fintech tại TP.HCM (khoảng 40 kỹ sư) chuẩn bị cho đợt audit bảo mật trước khi ký hợp đồng với một ngân hàng. Đội bảo mật chạy Trivy quét image API chính — vốn build từ FROM node:16 — và nhận về 240 lỗ hổng, trong đó 12 lỗi mức CRITICAL, hầu hết đến từ các gói OS của Debian mà ứng dụng chẳng dùng bao giờ.
Đội DevOps làm ba việc: đổi base sang gcr.io/distroless/nodejs18, chuyển sang multi-stage build, và ghim base theo digest. Kết quả sau một buổi chiều: image từ 980 MB xuống 112 MB, số CVE từ 240 xuống còn 7 (đều mức LOW nằm trong runtime, không vá được nhưng chấp nhận được). Đợt audit ngân hàng vượt qua. Bài học: phần lớn CVE trong image không đến từ code của bạn, mà đến từ base image bạn vô tình chọn. Đổi base là đòn bẩy lớn nhất.
Ví dụ 2 — Sự cố crypto-mining tại một sàn TMĐT
Một sàn thương mại điện tử tầm trung (bối cảnh giả định hợp lý, quy mô ~2 triệu người dùng) bị lợi dụng một lỗ hổng SSRF trong dịch vụ xử lý ảnh. Điều tra sau đó cho thấy: kẻ tấn công đã dùng chính curl và wget có sẵn trong image (base ubuntu:20.04) để tải về một crypto-miner, rồi ghi nó vào /tmp và chạy bằng quyền root. Miner chạy âm thầm hai tuần, đội tài chính phát hiện vì hóa đơn cloud tăng vọt 30%.
Sau sự cố, đội áp dụng ba biện pháp hardening: chuyển sang distroless (không còn curl, wget, bash), bật readOnlyRootFilesystem: true trong Kubernetes, và chạy non-root. Lần thử tấn công tương tự sau đó thất bại ở cả ba lớp: không có curl để tải, không ghi được xuống filesystem chỉ đọc, và dù có ghi được cũng không có quyền. Bài học: hardening là phòng thủ theo lớp (defense in depth). Một lỗ hổng ứng dụng là điều khó tránh tuyệt đối, nhưng một image được làm cứng biến "chọc thủng được app" thành "chọc thủng nhưng chẳng làm gì được".
Ví dụ 3 — Đội Go và cái bẫy "chỉ 8 MB nhưng vẫn dính CVE"
Một đội backend viết bằng Go tự tin rằng họ đã tối ưu: build binary tĩnh, FROM scratch, image chỉ 8 MB. Nhưng khi quét, họ vẫn thấy vài CVE — hóa ra binary Go của họ link tĩnh một thư viện libc cũ và một dependency có lỗ hổng đã biết chưa được cập nhật trong go.mod. Họ cũng quên chưa copy chứng chỉ CA nên các lời gọi HTTPS ra ngoài âm thầm thất bại ở production.
Họ sửa: cập nhật dependency, copy ca-certificates từ builder sang, và thêm một USER 65532 dù dùng scratch. Bài học: image nhỏ không đồng nghĩa image an toàn tuyệt đối. Hardening là tổng hòa của cả nội dung image (dependency) lẫn cấu trúc image (base, user, filesystem). Đừng dừng ở "image tôi nhỏ rồi".
Hướng dẫn từng bước
Bước 1 — Chọn base image tối giản và ghim digest. Ưu tiên distroless hoặc alpine. Lấy digest bằng docker pull node:18-alpine && docker inspect --format='{{index .RepoDigests 0}}' node:18-alpine, rồi ghim vào Dockerfile:
FROM node:18-alpine@sha256:8e3d2f... AS builder
Bước 2 — Chuyển sang multi-stage build. Tách stage builder (đủ công cụ) và stage runtime (tối giản). Chỉ COPY --from=builder những artifact thực sự cần chạy — binary, dist/, node_modules production. Đừng copy cả thư mục . một cách lười biếng.
Bước 3 — Tạo và dùng non-root user. Với alpine:
RUN addgroup -S app && adduser -S app -G app
USER app
Với distroless dùng luôn USER nonroot. Đảm bảo các file app copy vào thuộc quyền user này để tránh lỗi permission.
Bước 4 — Dùng .dockerignore chặt chẽ. Ngăn .git, .env, node_modules local, file test... lọt vào build context. Đây vừa là bảo mật (tránh rò secret) vừa là hiệu năng.
Bước 5 — Bật read-only filesystem lúc chạy. Trong Kubernetes:
securityContext:
runAsNonRoot: true
runAsUser: 65532
readOnlyRootFilesystem: true
allowPrivilegeEscalation: false
capabilities:
drop: ["ALL"]
volumeMounts:
- name: tmp
mountPath: /tmp
volumes:
- name: tmp
emptyDir: {}
Với docker run, thêm --read-only --tmpfs /tmp --cap-drop ALL --security-opt no-new-privileges.
Bước 6 — Quét image trong CI và chặn merge nếu vi phạm. Tích hợp Trivy hoặc Grype vào pipeline. Ví dụ một job GitHub Actions chặn nếu có CVE HIGH/CRITICAL:
- name: Scan image
uses: aquasecurity/trivy-action@0.20.0
with:
image-ref: myapp:${{ github.sha }}
severity: CRITICAL,HIGH
exit-code: '1'
ignore-unfixed: true
Bước 7 — Quét cấu hình Dockerfile. Dùng hadolint để bắt các anti-pattern (không ghim version, chạy root, dùng latest) ngay trước cả khi build.
Lỗi thường gặp & mẹo
- Dùng tag
latesthoặc tag di động. Đây là lỗi phổ biến nhất.latestkhiến build không tái lập được và có thể âm thầm kéo về lỗ hổng mới. Luôn ghim digest. - Cài công cụ debug rồi quên gỡ. Nhiều người thêm
RUN apk add curl bash vimđể debug rồi commit luôn. Hãy để công cụ debug ở stage builder, đừng bao giờ để chúng lên runtime. - Non-root nhưng file thuộc root. Khai báo
USER appnhưng app cần ghi vào thư mục thuộc root sẽ crash. Nhớchownfile cho user hoặc dùngCOPY --chown=app:app. - Read-only nhưng app cần ghi. App ghi log ra file, ghi cache, tạo file session... sẽ lỗi khi filesystem chỉ đọc. Giải pháp: mount
tmpfs/emptyDircho đúng thư mục cần ghi, và cấu hình app ghi log ra stdout thay vì file. - Mỗi
RUNtạo một layer chứa rác. Nếu bạnRUN apt-get install ...rồiRUN rm ...ở lệnh khác, file đã bị xóa vẫn nằm trong layer trước và vẫn phình image. Gộp cài đặt và dọn dẹp vào cùng mộtRUN ... && rm -rf /var/lib/apt/lists/*. - Mẹo — quét trên mỗi PR, không chỉ khi release. Phát hiện CVE sớm rẻ hơn nhiều. Đặt
ignore-unfixed: trueđể không bị chặn bởi CVE chưa có bản vá, tránh "báo động giả" gây mệt mỏi. - Mẹo — ký image (image signing). Với môi trường yêu cầu cao, dùng
cosignđể ký image và verify chữ ký lúc deploy, đảm bảo image không bị đánh tráo giữa registry và cluster.
Bài tập thực hành
- Đo trước — sau. Lấy một ứng dụng nhỏ (Node.js, Python hoặc Go) của bạn đang build từ base image đầy đủ. Chạy
docker imagesghi lại dung lượng, chạytrivy image <tên>ghi lại số CVE theo mức độ. Đây là baseline.
- Viết lại Dockerfile theo multi-stage + distroless. Áp dụng đủ: ghim digest, multi-stage, non-root,
.dockerignore. Build lại và đo lại dung lượng + số CVE. Mục tiêu: giảm ít nhất 60% dung lượng và phần lớn CVE HIGH/CRITICAL.
- Kiểm chứng non-root và read-only. Chạy
docker run --read-only --cap-drop ALL <image>và xác nhận app vẫn chạy. Sau đódocker execvào (nếu còn shell) chạyidđể xác nhận không phải root. Thửtouch /test.txtđể thấy filesystem chặn ghi.
- Tích hợp vào CI. Thêm một job Trivy vào pipeline (GitHub Actions/GitLab CI) chặn merge nếu có CVE CRITICAL. Tạo một PR cố tình thêm một dependency có lỗ hổng đã biết để xác nhận job thực sự chặn.
Tóm tắt
Container image hardening là công đoạn biến artifact cuối cùng của pipeline thành thứ nhỏ gọn, sạch sẽ và khó bị lợi dụng. Năm nguyên tắc cần nhớ: image nhỏ (giảm băng thông lẫn CVE), bề mặt tấn công tối thiểu (cái gì không cần thì bỏ), chạy non-root, filesystem chỉ đọc, và ghim phiên bản theo digest. Vũ khí trung tâm là multi-stage build — tách môi trường build khỏi môi trường runtime để chỉ những gì thực sự cần chạy mới lên production. Kết hợp với quét lỗ hổng tự động trong CI và cấu hình security context ở tầng orchestration, bạn có một chiến lược phòng thủ theo lớp: dù một lỗ hổng ứng dụng lọt qua, image được làm cứng sẽ khiến kẻ tấn công gần như không làm được gì. Như ba tình huống thực tế cho thấy, đây không phải lý thuyết xa vời mà là khác biệt giữa "vượt qua audit ngân hàng" và "trả hóa đơn cloud cho crypto-miner của kẻ khác".