Bảo mật image: non-root, image tối giản, không nhét secret
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 đề
- Chạy bằng root: app có lỗ hổng RCE thì kẻ tấn công có root trong container, ghi được mọi file, và nếu có thêm lỗ hổng container escape thì gần với root trên host.
- Secret trong image: ai pull được image là đọc được password:
docker history,docker inspecthiện ENV. Xóa ở layer sau cũng không mất ở layer trước. - Base image to: có shell, package manager, hàng trăm package → nhiều CVE, nhiều công cụ cho kẻ tấn công.
- Không ai quét: CVE mới công bố trong base image mà không biết.
- Filesystem ghi được: kẻ tấn công tải và chạy file lạ trong container.
3. Giải pháp
| Rủi ro | Cách xử lý |
|---|---|
| Chạy root | USER non-root trong Dockerfile |
| Bề mặt tấn công lớn | Base image tối giản: JRE (không JDK), -alpine, distroless |
| Secret trong image | Truyền lúc chạy (env, file mount, secret manager); lúc build dùng BuildKit --mount=type=secret |
| CVE | Qué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ừa | cap_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 image | Kích thước (ước lượng) | Shell | Ghi chú |
|---|---|---|---|
eclipse-temurin:17-jdk | ~400MB+ | Có | Chỉ dùng để build |
eclipse-temurin:17-jre | ~250MB | Có | Ubuntu, dễ debug |
eclipse-temurin:17-jre-alpine | ~150MB | Có (busybox) | musl libc, đôi khi khác hành vi native lib |
distroless/java17 | ~200MB | Không | Ít CVE nhất, debug khó |
7. Khi nào nên dùng / không nên dùng
- Luôn: non-root, không secret trong image, quét CVE trong CI.
- Nên: distroless cho production khi team đã quen debug qua log/metrics; alpine/ubuntu khi còn cần
exec sh. - Nên:
read_onlykhi app không cần ghi đĩa (ghi log ra stdout). - Không nên:
--privileged, mount/var/run/docker.sockvào container app (tương đương root trên host).
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
USERso 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
- Non-root:
USER app. - Nhỏ: JRE-alpine / distroless.
- Secret: inject lúc chạy; build thì BuildKit secret.
- Quét: Trivy/Scout trong CI.
- Khóa: read_only, cap_drop ALL, no-new-privileges.
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ài | Chủ đề | Ý chính cần nhớ |
|---|---|---|
| 16.1 | Image, layer, container | Image = layer read-only; container = instance + writable layer; tag cố định, không dùng latest khi deploy |
| 16.2 | Dockerfile | RUN lúc build, ENTRYPOINT/CMD lúc start; EXPOSE chỉ là metadata; multi-stage + layered jar + .dockerignore |
| 16.3 | Namespace & cgroups | Container là process dùng chung kernel; vượt RAM → exit 137; JVM dùng MaxRAMPercentage |
| 16.4 | Volume & networking | Named volume cho data DB; cùng network gọi bằng tên; localhost trong container là chính nó; -p host:container |
| 16.5 | Compose | build: cho code mình, image: cho Postgres/Redis; healthcheck + service_healthy; secret trong .env |
| 16.6 | PID 1 & signal | Exec form; script phải exec; server.shutdown=graceful; grace period Docker > timeout Spring |
| 16.7 | Security | Non-root, base tối giản, secret inject lúc chạy, quét CVE, read-only + cap_drop |