Docker for Developers · Part 7 — Optimizing & Securing Images
Make images small, fast, and safe: slim and distroless bases, layer-cache discipline, multi-stage recap, running as a non-root user, handling secrets properly, .dockerignore, and image scanning.
Part 7 of 10 in the Docker → Compose → Kubernetes series {Phần 7/10 trong series Docker → Compose → Kubernetes}. Previous {Trước}: Part 6 — Compose in Depth · Next {Tiếp}: Part 8 — Debugging & Troubleshooting Docker.
In Part 2 — Images & the Dockerfile you learned to build images — layers, cache, multi-stage, .dockerignore {Ở Phần 2 bạn đã học build image — layer, cache, multi-stage, .dockerignore}. In Part 6 — Compose in Depth you orchestrated multi-service stacks with profiles and health checks {Ở Phần 6 bạn điều phối stack nhiều service với profile và health check}. A working image is not a good image {Image chạy được chưa phải image tốt}: bloated layers slow every pull and deploy, root-by-default widens blast radius, and secrets baked into layers survive in docker history forever {Layer phình làm mọi pull và deploy chậm, mặc định root mở rộng vùng ảnh hưởng, secret nhúng vào layer sống mãi trong docker history}. This part makes your images small, fast, and safe {Phần này giúp image nhỏ, nhanh và an toàn}.
Every part ends with exercises; run the commands on your machine {Mỗi phần kết thúc bằng bài tập; chạy lệnh trên máy thật}.
Shrink the image — small bases & multi-stage recap {Thu nhỏ image — base gọn & ôn multi-stage}
Image size is not vanity — it is latency and cost {Kích thước image không phải chuyện “đẹp” — đó là độ trễ và chi phí}: smaller images pull faster on every node, start quicker, and expose fewer packages to scan {Image nhỏ pull nhanh hơn trên mọi node, khởi động nhanh hơn, và ít package hơn để quét CVE}.
Choose a small base {Chọn base nhỏ}
| Base style {Kiểu base} | Examples {Ví dụ} | Trade-off {Đánh đổi} |
|---|---|---|
| Full | node:20, python:3.12 | Easiest debug shell; largest attack surface {Dễ debug; bề mặt tấn công lớn nhất} |
| Slim / Alpine | node:20-alpine, python:3.12-slim | Much smaller; Alpine uses musl — test native deps {Nhỏ hơn nhiều; Alpine dùng musl — test dependency native} |
| Distroless | gcr.io/distroless/nodejs20-debian12 | No shell, minimal OS — best for prod runtimes {Không shell, OS tối thiểu — tốt cho runtime production} |
Multi-stage recap (from Part 2): compilers, test runners, and devDependencies stay in a builder stage; only the artifact lands in the final FROM {Ôn multi-stage (Phần 2): compiler, test runner và devDependencies ở builder; chỉ artifact vào FROM cuối}.
BEFORE (single-stage, node:20) AFTER (multi-stage + alpine runtime)
┌────────────────────────────┐ ┌────────────────────────────┐
│ node:20 + npm ci (all deps)│ │ builder: compile / test │
│ + source + build tools │ ──► │ │ COPY --from │
│ ~900 MB typical │ │ runtime: alpine + artifact │
└────────────────────────────┘ │ ~80–120 MB typical │
└────────────────────────────┘
Clean package caches in the same RUN layer — otherwise the deleted files still live in the previous layer {Dọn cache package trong cùng layer RUN — nếu không, file đã xóa vẫn nằm ở layer trước}:
RUN apt-get update \
&& apt-get install -y --no-install-recommends curl \
&& rm -rf /var/lib/apt/lists/*
See Part 2 for a full Node multi-stage example; the same pattern applies to Go (builder → COPY --from → distroless runtime) {Xem Phần 2 cho ví dụ Node multi-stage đầy đủ; cùng mẫu với Go (builder → COPY --from → distroless)}.
Compare sizes after build {So sánh kích thước sau build}:
docker build -t myapp:fat -f Dockerfile.single .
docker build -t myapp:slim -f Dockerfile.multistage .
docker images myapp --format "table {{.Repository}}\t{{.Tag}}\t{{.Size}}"
Layer-cache discipline — order for speed {Kỷ luật layer-cache — thứ tự cho tốc độ}
Docker rebuilds from the first changed layer downward {Docker rebuild từ layer đổi đầu tiên trở xuống}. Put instructions that change least often at the top and most often at the bottom {Đặt chỉ thị đổi ít nhất lên trên và nhiều nhất xuống dưới}:
GOOD ORDER (fast rebuilds on code edits)
┌─────────────────────────────┐
│ FROM (pinned tag/digest) │ ← rarely changes
├─────────────────────────────┤
│ RUN apt / system deps │
├─────────────────────────────┤
│ COPY package.json lockfile │
│ RUN npm ci │ ← cache survives code edits
├─────────────────────────────┤
│ COPY . . │ ← changes every commit
├─────────────────────────────┤
│ RUN npm run build │
│ CMD ... │
└─────────────────────────────┘
Rules to deepen Part 2 {Quy tắc mở rộng Phần 2}:
- Copy dependency manifests before source —
package.json,requirements.txt,go.mod{Copy manifest dependency trước source}. - Combine related
RUNsteps — one layer beats five micro-layers that each install one package {GộpRUNliên quan — một layer tốt hơn năm layer cài từng package}. - Don’t bust cache early — avoid
COPY . .beforeRUN npm ci{Đừng phá cache sớm — tránhCOPY . .trướcRUN npm ci}. - Pin base images —
FROM node:20-alpinebeatsFROM node:latestfor reproducible cache keys {Ghim base image —node:20-alpineổn định hơnnode:latestcho cache}.
DOCKER_BUILDKIT=1 docker build -t myapp:1.0 .
docker builder prune # reclaim stale build cache when disk fills up
Run as non-root {Chạy không phải root}
By default, processes in your container run as root (UID 0) {Mặc định, tiến trình trong container chạy root (UID 0)}. Root inside a container is still root inside that namespace — misconfigurations, kernel bugs, and volume mounts to sensitive host paths hurt more {Root trong container vẫn là root trong namespace đó — cấu hình sai, lỗi kernel và mount volume nhạy cảm trên host nguy hiểm hơn}.
Add a dedicated user and switch before CMD {Tạo user riêng và chuyển trước CMD}:
FROM node:20-alpine
WORKDIR /app
RUN addgroup -g 1001 -S app && adduser -u 1001 -S app -G app
COPY --chown=app:app package.json package-lock.json ./
RUN npm ci --omit=dev
COPY --chown=app:app . .
USER app
EXPOSE 3000
CMD ["node", "server.js"]
USERsets the default UID/GID forCMD,ENTRYPOINT, anddocker exec{USERđặt UID/GID mặc định choCMD,ENTRYPOINTvàdocker exec}.COPY --chown=app:appavoids root-owned files the non-root user cannot write {COPY --chowntránh file thuộc root mà user non-root không ghi được}.
Verify at runtime {Kiểm tra lúc chạy}:
docker run --rm myapp:1.0 whoami # should print: app
docker exec -it <container> id
Distroless images often ship with a
nonrootuser already — check the image docs {Image distroless thường có usernonrootsẵn — xem tài liệu image}.
Secrets done right — never bake them into layers {Secret đúng cách — không nhúng vào layer}
Never put secrets in ENV or ARG in a Dockerfile for production credentials {Không bao giờ đặt secret trong ENV hoặc ARG của Dockerfile cho credential production}. Image layers are immutable history — anyone with the image can run docker history or extract layers {Layer image là lịch sử bất biến — ai có image có thể docker history hoặc trích layer}.
The leak — prove it {Lộ secret — chứng minh}
# BAD — do not ship this pattern
FROM alpine:3.20
ARG API_KEY=super-secret-key-demo
ENV API_KEY=${API_KEY}
RUN echo "configured" > /tmp/ok
docker build -t leaky:demo .
docker history leaky:demo --no-trunc | grep -i API_KEY
# or inspect build args in layer metadata
The value can appear in layer commands and build logs {Giá trị có thể lộ trong lệnh layer và log build}.
Fixes: runtime -e or Compose environment for run-time creds; Compose/Swarm secrets as files under /run/secrets/; BuildKit --mount=type=secret for build-only tokens (npm, private Git) — never committed to a layer {Cách sửa: -e hoặc Compose environment lúc chạy; secrets dưới /run/secrets/; BuildKit --mount=type=secret cho token chỉ lúc build — không commit vào layer}.
BuildKit example {Ví dụ BuildKit}:
# syntax=docker/dockerfile:1
FROM node:20-alpine
RUN --mount=type=secret,id=npmrc,target=/root/.npmrc \
npm ci --omit=dev
DOCKER_BUILDKIT=1 docker build \
--secret id=npmrc,src=$HOME/.npmrc \
-t myapp:1.0 .
At run time, pass secrets via orchestrator or env — not by rebuilding the image with ENV PASSWORD=... {Lúc chạy, đưa secret qua orchestrator hoặc env — không rebuild image với ENV PASSWORD=...}.
.dockerignore for safety & speed {.dockerignore cho an toàn & tốc độ}
The build context is every file sent to the daemon (unless ignored) {Context build là mọi file gửi lên daemon (trừ ignore)}. Without .dockerignore, you risk copying .env, .git, and host node_modules into layers — slow builds and accidental leaks {Không có .dockerignore, bạn có thể copy .env, .git, node_modules host vào layer — build chậm và lộ nhầm}.
# .dockerignore — typical Node / Python project
.git
.gitignore
.env
.env.*
*.pem
*.key
node_modules
dist
build
coverage
.vscode
.idea
**/*.md
Dockerfile*
.dockerignore
Watch context size during build {Theo dõi kích thước context khi build}:
docker build -t myapp:1.0 . 2>&1 | head -5
# "transferring context: 12.34kB" vs hundreds of MB without ignore
Image scanning — find CVEs before prod {Quét image — tìm CVE trước prod}
Smaller images help, but vulnerabilities live in packages {Image nhỏ giúp ích, nhưng lỗ hổng nằm trong package}. Scan on every release candidate {Quét mỗi bản release candidate}.
Docker Scout (built into Docker Desktop / CLI) {Docker Scout}:
docker scout quickview node:20-alpine
docker scout cves myapp:1.0
Trivy (popular in CI) {Trivy (phổ biến trong CI)}:
docker run --rm -v /var/run/docker.sock:/var/run/docker.sock \
aquasec/trivy image myapp:1.0
Hardening habits {Thói quen cứng hóa}:
- Pin tags —
node:20-alpine, not barenodeor driftinglatest{Ghim tag}. - Prefer digests for prod —
myapp@sha256:abc123...{Ưu tiên digest cho prod}. - Rebuild regularly — base image patches arrive on Docker Hub; your app image does not auto-heal {Rebuild định kỳ — bản vá base lên Hub; image app không tự khỏe}.
docker pull node:20-alpine
docker build --pull -t myapp:1.0 .
Common pitfalls {Các bẫy thường gặp}
latesttag drift — today’snginx:latest≠ tomorrow’s; prod should use explicit versions or digests {Taglatesttrôi — prod cần version hoặc digest rõ}.- Secrets in
ARG/ENV— persist in history; use runtime injection or BuildKit secrets {Secret trongARG/ENV— còn trong history}. - Giant single-layer images — one
RUNthat installs everything without cleaning caches, or copying the whole repo before deps {Image một layer khổng lồ — mộtRUNkhông dọn cache, hoặc copy cả repo trước deps}. - Running as root by default — add
USER; fix ownership with--chown{Chạy root mặc định — thêmUSER; sửa quyền bằng--chown}. - Not pinning versions — unpinned
apt,npm, and base images → unreproducible builds and surprise CVEs {Không ghim version — build không tái lập và CVE bất ngờ}. - Skipping scans — shipping
:1.0once without CI scan means you discover CVEs in production {Bỏ qua quét — đẩy:1.0không scan → gặp CVE trên production}.
Cheat sheet {Bảng tra nhanh}
# size & layers
docker images myapp --format "table {{.Tag}}\t{{.Size}}"
docker history myapp:1.0
docker image inspect myapp:1.0 --format '{{.Config.User}}'
# build slim + cache-friendly
DOCKER_BUILDKIT=1 docker build --pull -t myapp:1.0 .
docker build --no-cache -t myapp:1.0 .
# non-root check
docker run --rm myapp:1.0 whoami
docker run --rm myapp:1.0 id
# secrets at build (BuildKit)
docker build --secret id=npmrc,src=$HOME/.npmrc -t myapp:1.0 .
# scan
docker scout cves myapp:1.0
# trivy: docker run --rm aquasec/trivy image myapp:1.0
# cleanup
docker builder prune
Bài tập / Exercises
Create docker-lab-07/ and work there {Tạo docker-lab-07/ và làm bài trong đó}. Each task is runnable on its own {Mỗi bài chạy độc lập}.
1. Build a fat single-stage image (FROM node:20, copy all, npm install) and a slim multi-stage image (node:20-alpine, deps-first cache, USER node). Compare docker images sizes {Build image fat single-stage và slim multi-stage; so sánh kích thước docker images}.
Solution {Lời giải}
Use FROM node:20 + COPY . . first for fat; use alpine, deps-before-source, and USER node for slim (mirror Part 2’s multi-stage layout) {Fat: node:20 + copy hết trước; slim: alpine, dependency trước source, USER node (giống multi-stage Phần 2)}.
docker build -f Dockerfile.fat -t lab07:fat .
docker build -f Dockerfile.slim -t lab07:slim .
docker images lab07 --format "table {{.Tag}}\t{{.Size}}"2. Add a non-root USER to your slim image (if not already), run a container, and verify with docker exec ... whoami {Thêm USER non-root, chạy container, xác nhận bằng docker exec ... whoami}.
Solution {Lời giải}
docker run -d --name lab07-web -p 3070:3000 lab07:slim
docker exec lab07-web whoami # node
docker exec lab07-web id # uid=1000(node) ...
docker stop lab07-web && docker rm lab07-web3. Build the leaky ARG/ENV demo from this post as lab07:leaky. Prove the secret shows up in docker history, then rebuild without baking the key (runtime -e only) as lab07:safe {Build demo leaky, chứng minh secret trong docker history, rồi build safe chỉ dùng -e lúc chạy}.
Solution {Lời giải}
# leaky: ARG + ENV API_KEY=... in Dockerfile (see section above)
docker build -f Dockerfile.leaky -t lab07:leaky .
docker history lab07:leaky --no-trunc | grep -i API_KEY
# safe: no secret in image; inject at run time
docker build -f Dockerfile.safe -t lab07:safe .
docker run --rm -e API_KEY=demo-leaked-key-12345 lab07:safe4. Add a .dockerignore that excludes node_modules, .env, and .git. Rebuild and note the smaller transferring context line {Thêm .dockerignore loại node_modules, .env, .git; build lại và xem dòng transferring context nhỏ hơn}.
Solution {Lời giải}
node_modules
.git
.env
.env.*docker build -t lab07:slim .
# compare "transferring context" before/after adding .dockerignore5. Scan lab07:slim with docker scout cves or Trivy; list at least one finding or confirm a clean report {Quét lab07:slim bằng docker scout cves hoặc Trivy; liệt kê ít nhất một finding hoặc xác nhận sạch}.
Solution {Lời giải}
docker scout cves lab07:slim
# or:
docker run --rm aquasec/trivy image lab07:slimIf Scout is unavailable, install Trivy or use Docker Desktop’s built-in scan UI {Nếu không có Scout, dùng Trivy hoặc UI scan của Docker Desktop}.
Stretch {Nâng cao}: pin your base with a digest (FROM node:20-alpine@sha256:...), rebuild with --pull, and document one CVE fixed by updating the base {Ghim base bằng digest, rebuild --pull, ghi lại một CVE được sửa khi cập nhật base}.
Key takeaways {Điểm chính}
- Small bases + multi-stage ship only what runs in prod; clean apt/npm caches in the same
RUNlayer {Base nhỏ + multi-stage chỉ đưa phần chạy prod; dọn cache trong cùngRUN}. - Cache discipline — dependencies before source, least-changing instructions first {Kỷ luật cache — dependency trước source, chỉ thị ít đổi lên trên}.
USERnon-root limits damage; useCOPY --chownfor writable app files {USERnon-root giới hạn thiệt hại;COPY --chowncho file app cần ghi}.- Secrets belong at runtime or BuildKit mounts — never in committed
ENV/ARGlayers {Secret ở runtime hoặc BuildKit mount — không trong layerENV/ARGcommit}. .dockerignore+ scanning + pinned tags keep builds fast and CVEs visible before deploy {.dockerignore+ quét + tag ghim giữ build nhanh và CVE lộ trước khi deploy}.
Next up {Tiếp theo}
Part 8 — Debugging & Troubleshooting Docker — docker logs, exec, inspect, exit codes, networking and volume gotchas, and a systematic debug checklist when a container misbehaves {Phần 8 — docker logs, exec, inspect, exit code, bẫy mạng/volume, và checklist debug khi container hành xử lạ}. ← Part 6