Bảo mật image: non-root, image tối giản, không nhét secret

3 câu hỏi~25 phútTừ khóa: non-root USER, distroless, alpine, secret trong image, docker history, BuildKit secret, Trivy, read-only filesystem, cap_drop

Tiếp đi: image order-service đã chạy được, giờ làm sao cho nó an toàn trước khi đưa lên production?

Image chạy được chưa có nghĩa là an toàn. Bài này đi qua 5 việc mà security review gần như luôn hỏi.

1. Bài toán

Dockerfile hiện tại của order-service, kèm một dòng "cho tiện" một bạn thêm vào:

FROM eclipse-temurin:17-jre
WORKDIR /app
ENV DB_PASSWORD=...           # "cho tiện, khỏi truyền -e"
COPY --from=builder /app/target/order-service-1.0.0.jar app.jar
ENTRYPOINT ["java", "-jar", "app.jar"]
docker exec order-1 whoami
root

2. Vấn đề

3. Giải pháp

Rủi roCách xử lý
Chạy rootUSER non-root trong Dockerfile
Bề mặt tấn công lớnBase image tối giản: JRE (không JDK), -alpine, distroless
Secret trong imageTruyền lúc chạy (env, file mount, secret manager); lúc build dùng BuildKit --mount=type=secret
CVEQuét image trong CI (Trivy, Docker Scout, Grype), rebuild định kỳ
Ghi file tùy ýread_only: true + tmpfs cho /tmp
Quyền kernel thừacap_drop: [ALL], no-new-privileges, không dùng --privileged

4. Code từng bước

Bước 1: Dockerfile non-root, không secret

FROM maven:3.9-eclipse-temurin-17 AS builder
WORKDIR /app
COPY pom.xml .
RUN mvn -B dependency:go-offline
COPY src ./src
RUN mvn -B clean package -DskipTests

FROM eclipse-temurin:17-jre-alpine
RUN addgroup -S app && adduser -S app -G app
WORKDIR /app
COPY --from=builder --chown=app:app /app/target/order-service-1.0.0.jar app.jar
USER app
ENV JAVA_TOOL_OPTIONS="-XX:MaxRAMPercentage=75.0"
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]

Non-root nên app không bind được port < 1024; 8080 thì không sao. Lựa chọn tối giản hơn nữa: gcr.io/distroless/java17-debian12:nonroot (không có shell, đã chạy user nonroot); đổi lại debug khó hơn vì không exec sh được.

Bước 2: Secret truyền lúc chạy

# compose.yaml
services:
  order-service:
    build: .
    environment:
      DB_URL: jdbc:postgresql://db:5432/orders
      DB_PASSWORD: ${POSTGRES_PASSWORD:?set in .env}   # .env không commit

Nếu lúc build cần secret (ví dụ token đọc Maven repo nội bộ), dùng BuildKit secret: được mount tạm cho một lệnh RUN, không vào layer nào:

# Dockerfile
RUN --mount=type=secret,id=maven_settings,target=/root/.m2/settings.xml \
    mvn -B clean package -DskipTests
docker build --secret id=maven_settings,src=$HOME/.m2/settings.xml -t order-service:1.0 .

Kiểm tra không còn secret:

docker history --no-trunc order-service:1.0 | grep -i password    # không ra gì
docker inspect order-service:1.0 --format '{{.Config.Env}}'

Bước 3: Quét CVE

trivy image --severity HIGH,CRITICAL order-service:1.0
# hoặc
docker scout cves order-service:1.0

Trong CI: fail pipeline nếu có CRITICAL có bản vá (trivy image --exit-code 1 --ignore-unfixed --severity CRITICAL ...). Pin base image theo version (hoặc digest) và rebuild định kỳ để lấy bản vá.

Bước 4: Hạn chế lúc chạy

services:
  order-service:
    build: .
    read_only: true
    tmpfs:
      - /tmp                  # Tomcat cần thư mục tạm
    cap_drop: [ALL]
    security_opt:
      - no-new-privileges:true
    mem_limit: 768m
    cpus: 1.5
docker compose up -d
docker compose exec order-service whoami          # app
docker compose exec order-service touch /app/x    # Read-only file system ✅

5. Cách hoạt động

                 Các lớp phòng thủ
┌──────────────────────────────────────────────────┐
│ Build : multi-stage, JRE-alpine/distroless,       │
│         .dockerignore, BuildKit secret            │
├──────────────────────────────────────────────────┤
│ CI    : Trivy/Scout scan → fail nếu CRITICAL      │
│         tag cố định, push registry private        │
├──────────────────────────────────────────────────┤
│ Run   : USER app, read_only, cap_drop ALL,        │
│         no-new-privileges, limit CPU/RAM          │
│         secret từ env/secret manager              │
└──────────────────────────────────────────────────┘
Kẻ tấn công RCE vào app →
  không phải root, không ghi được file, không có shell (distroless),
  không có capability → khó leo thang

6. So sánh

Base imageKích thước (ước lượng)ShellGhi chú
eclipse-temurin:17-jdk~400MB+CóChỉ dùng để build
eclipse-temurin:17-jre~250MBCóUbuntu, dễ debug
eclipse-temurin:17-jre-alpine~150MBCó (busybox)musl libc, đôi khi khác hành vi native lib
distroless/java17~200MBKhôngÍt CVE nhất, debug khó

7. Khi nào nên dùng / không nên dùng

Nếu mình COPY settings.xml để build xong rồi RUN rm settings.xml ngay sau đó thì image cuối đâu còn file đó nữa nhỉ?

Chưa đúng. Image là chồng layer, rm chỉ tạo một layer mới đánh dấu file bị xóa; layer cũ vẫn chứa nguyên file:

layer 5: RUN rm settings.xml        → "whiteout" ẩn file
layer 4: RUN mvn package
layer 3: COPY settings.xml          → file VẪN nằm ở đây
...
docker save order-service:1.0 -o img.tar   # giải nén, mở layer 3 là thấy file

Hai cách đúng: multi-stage (file chỉ có ở stage builder, image cuối không chứa layer đó) hoặc tốt hơn là BuildKit secret (RUN --mount=type=secret), file không bao giờ vào layer nào kể cả ở stage builder, nên cache build trên CI cũng không lộ. Và nếu secret đã từng bị push lên registry thì coi như lộ: phải rotate.

Phỏng vấn hỏi làm sao để Docker image an toàn cho production thì trả lời sao?

Q1. Bạn harden một Docker image cho Spring Boot thế nào?

🇻🇳 Tiếng Việt:

Em dùng multi-stage build với base image runtime tối giản như JRE alpine hoặc distroless, tạo user riêng và dùng USER để không chạy root. Lúc chạy em bật read-only filesystem, drop toàn bộ capability, đặt giới hạn CPU, RAM. Image được quét CVE trong CI và rebuild định kỳ.

🇬🇧 English:

I use a multi-stage build with a minimal runtime base image such as a JRE alpine or distroless image, and I create a dedicated user and switch to it with USER so the app does not run as root. At runtime I enable a read-only filesystem, drop all capabilities and set CPU and memory limits. The image is scanned for CVEs in CI and rebuilt regularly.

Q2. Secret nên đưa vào container thế nào?

🇻🇳 Tiếng Việt:

Không bao giờ để trong image, kể cả ENV, ARG hay file đã xóa ở layer sau, vì đọc lại được từ layer và history. Secret được inject lúc chạy qua biến môi trường, file mount, Kubernetes Secret hoặc secret manager như Azure Key Vault. Nếu lúc build cần token, em dùng BuildKit secret mount.

🇬🇧 English:

Never bake secrets into the image, not in ENV, ARG or a file deleted in a later layer, because they can be recovered from layers and history. Secrets are injected at runtime through environment variables, mounted files, Kubernetes Secrets or a secret manager such as Azure Key Vault. If the build needs a token, I use a BuildKit secret mount.

Q3. Vì sao không chạy container bằng root?

🇻🇳 Tiếng Việt:

Container dùng chung kernel với host. Nếu app bị khai thác, root trong container có toàn quyền trong container và nếu kết hợp với lỗ hổng escape thì rủi ro lên tới host. Chạy non-root theo nguyên tắc least privilege giới hạn thiệt hại.

🇬🇧 English:

Containers share the host kernel. If the app is exploited, root inside the container has full control of the container, and combined with an escape vulnerability the risk extends to the host. Running as non-root follows the least-privilege principle and limits the damage.

Cách nhớ ngắn gọn


Tự kiểm tra

Vì sao ENV DB_PASSWORD=... trong Dockerfile là sai?

ENV được lưu trong metadata image; ai có image đều đọc được qua docker inspect/docker history.

RUN rm secret.txt sau khi COPY có xóa được secret khỏi image không?

Không. Layer COPY trước đó vẫn chứa file. Dùng multi-stage hoặc BuildKit secret mount.

Distroless đổi lại điều gì?

Ít package, ít CVE, không shell; nhưng không docker exec sh để debug được.

read_only: true làm Spring Boot lỗi ở đâu, sửa thế nào?

Tomcat cần ghi thư mục tạm. Mount tmpfs cho /tmp.

Mount /var/run/docker.sock vào container app có rủi ro gì?

Container điều khiển được Docker daemon, tạo container privileged, tương đương root trên host.


Tổng kết chương Docker

BàiChủ đềÝ chính cần nhớ
16.1Image, layer, containerImage = layer read-only; container = instance + writable layer; tag cố định, không dùng latest khi deploy
16.2DockerfileRUN lúc build, ENTRYPOINT/CMD lúc start; EXPOSE chỉ là metadata; multi-stage + layered jar + .dockerignore
16.3Namespace & cgroupsContainer là process dùng chung kernel; vượt RAM → exit 137; JVM dùng MaxRAMPercentage
16.4Volume & networkingNamed volume cho data DB; cùng network gọi bằng tên; localhost trong container là chính nó; -p host:container
16.5Composebuild: cho code mình, image: cho Postgres/Redis; healthcheck + service_healthy; secret trong .env
16.6PID 1 & signalExec form; script phải exec; server.shutdown=graceful; grace period Docker > timeout Spring
16.7SecurityNon-root, base tối giản, secret inject lúc chạy, quét CVE, read-only + cap_drop