jvinhit//lab

Search posts

Type to search across journal entries.

navigate open esc close

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}
Fullnode:20, python:3.12Easiest debug shell; largest attack surface {Dễ debug; bề mặt tấn công lớn nhất}
Slim / Alpinenode:20-alpine, python:3.12-slimMuch smaller; Alpine uses musl — test native deps {Nhỏ hơn nhiều; Alpine dùng musl — test dependency native}
Distrolessgcr.io/distroless/nodejs20-debian12No 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à devDependenciesbuilder; 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}:

  1. Copy dependency manifests before sourcepackage.json, requirements.txt, go.mod {Copy manifest dependency trước source}.
  2. Combine related RUN steps — one layer beats five micro-layers that each install one package {Gộp RUN liên quan — một layer tốt hơn năm layer cài từng package}.
  3. Don’t bust cache early — avoid COPY . . before RUN npm ci {Đừng phá cache sớm — tránh COPY . . trước RUN npm ci}.
  4. Pin base imagesFROM node:20-alpine beats FROM node:latest for reproducible cache keys {Ghim base imagenode:20-alpine ổn định hơn node:latest cho 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"]
  • USER sets the default UID/GID for CMD, ENTRYPOINT, and docker exec {USER đặt UID/GID mặc định cho CMD, ENTRYPOINTdocker exec}.
  • COPY --chown=app:app avoids root-owned files the non-root user cannot write {COPY --chown trá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 nonroot user already — check the image docs {Image distroless thường có user nonroot sẵ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 tagsnode:20-alpine, not bare node or drifting latest {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}

  • latest tag drift — today’s nginx:latest ≠ tomorrow’s; prod should use explicit versions or digests {Tag latest trôi — prod cần version hoặc digest rõ}.
  • Secrets in ARG / ENV — persist in history; use runtime injection or BuildKit secrets {Secret trong ARG/ENV — còn trong history}.
  • Giant single-layer images — one RUN that installs everything without cleaning caches, or copying the whole repo before deps {Image một layer khổng lồ — một RUN khô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êm USER; 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.0 once without CI scan means you discover CVEs in production {Bỏ qua quét — đẩy :1.0 khô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-web

3. 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:safe

4. 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 .dockerignore

5. 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:slim

If 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 RUN layer {Base nhỏ + multi-stage chỉ đưa phần chạy prod; dọn cache trong cùng RUN}.
  • 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}.
  • USER non-root limits damage; use COPY --chown for writable app files {USER non-root giới hạn thiệt hại; COPY --chown cho file app cần ghi}.
  • Secrets belong at runtime or BuildKit mounts — never in committed ENV/ARG layers {Secret ở runtime hoặc BuildKit mount — không trong layer ENV/ARG commit}.
  • .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 Dockerdocker logs, exec, inspect, exit codes, networking and volume gotchas, and a systematic debug checklist when a container misbehaves {Phần 8docker logs, exec, inspect, exit code, bẫy mạng/volume, và checklist debug khi container hành xử lạ}. ← Part 6