容器安全不只是上线前执行一次漏洞扫描。BellSoft 的 Catherine Edelveis 围绕加固运行时镜像与容器安全展开的讨论,指向了一个更实际的问题:进入生产环境的 Java 容器究竟包含什么、以什么权限运行,以及出现新漏洞后能否快速重建和替换。
真正有效的加固通常不是增加更多安全工具,而是减少镜像中的组件、收紧进程权限,并让构建结果可以重复验证。
“更小”不等于“已加固”
缩小镜像很重要,因为每删除一个不必要的软件包,就减少一部分潜在攻击面。不过,镜像体积只是一个容易观察的指标,并不能单独代表安全性。
一个面向生产环境的运行时镜像至少需要回答这些问题:
- 是否包含编译器、包管理器、Shell 或调试工具?生产进程不需要的工具通常不应留在最终镜像中。
- Java 应用是否以非 root 用户运行?
- 基础镜像和 JRE 是否有明确版本,能否稳定重建?
- 应用是否只能写入必要目录?
- 镜像中有哪些操作系统包和 Java 依赖,能否生成 SBOM 并持续扫描?
- 发现高危漏洞后,是在运行中的容器里“打补丁”,还是重新构建并替换不可变镜像?
因此,选用经过裁剪或加固的运行时镜像只是起点。Dockerfile、Kubernetes 安全上下文、供应链检查和更新机制必须一起工作。
用多阶段构建隔离编译环境
下面是一个可以直接改造的 Java 示例。假设项目使用 Maven,并能通过 mvn package 生成一个可执行 JAR。运行前需要把基础镜像标签替换为团队已经审核并固定的版本;示例中的标签仅用于说明构建结构。
# syntax=docker/dockerfile:1
FROM maven:3.9-eclipse-temurin-21 AS build
WORKDIR /workspace
COPY pom.xml .
RUN --mount=type=cache,target=/root/.m2 \
mvn -B dependency:go-offline
COPY src ./src
RUN --mount=type=cache,target=/root/.m2 \
mvn -B package -DskipTests
FROM bellsoft/liberica-runtime-container:jre-21-slim-glibc
RUN addgroup --system app && adduser --system --ingroup app app
WORKDIR /app
COPY --from=build --chown=app:app /workspace/target/*.jar app.jar
USER app:app
EXPOSE 8080
ENTRYPOINT ["java", "-XX:MaxRAMPercentage=75", "-jar", "/app/app.jar"]
构建并检查实际运行身份:
docker build -t demo-java:local .
docker run --rm --entrypoint id demo-java:local
docker run --rm -p 8080:8080 demo-java:local
多阶段构建让 Maven、源码和编译缓存停留在构建阶段,最终镜像只接收运行产物。USER 则避免应用默认获得容器内 root 权限。不过,即使容器使用非 root 用户,也不能把容器边界视为绝对隔离;宿主机内核、容器运行时和编排平台仍然需要及时更新。
若应用不依赖字体、Shell、原生诊断工具或动态安装能力,还可以评估更精简的运行时。反过来,如果值班人员需要在生产环境执行诊断命令,不应悄悄把大量工具塞回业务镜像,更合适的做法是使用受控的临时调试容器。
把运行权限继续收紧
镜像中的非 root 用户只是第一层。部署到 Kubernetes 时,还可以禁止提权、删除 Linux capabilities,并将根文件系统设为只读。下面的配置假设应用只需要监听 8080 端口,并将临时文件写入 /tmp:
apiVersion: apps/v1
kind: Deployment
metadata:
name: demo-java
spec:
replicas: 2
selector:
matchLabels:
app: demo-java
template:
metadata:
labels:
app: demo-java
spec:
securityContext:
runAsNonRoot: true
seccompProfile:
type: RuntimeDefault
containers:
- name: app
image: registry.example.com/demo-java@sha256:REPLACE_WITH_DIGEST
ports:
- containerPort: 8080
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
volumeMounts:
- name: tmp
mountPath: /tmp
resources:
requests:
cpu: 250m
memory: 256Mi
limits:
memory: 512Mi
volumes:
- name: tmp
emptyDir:
sizeLimit: 128Mi
使用 digest 而不是可变标签,可以确保部署的是审核过的确切镜像。readOnlyRootFilesystem 往往会暴露应用的隐式写盘行为,因此应先在测试环境运行集成测试。若框架需要缓存、上传或生成堆转储,应为这些用途分别挂载受限卷,而不是重新开放整个根文件系统。
让扫描结果进入交付链路
漏洞扫描只有在结果能够影响发布时才有价值。可以这样实践:构建镜像后生成 SBOM,再按团队策略检查漏洞。
# 需要预先安装 syft 和 grype
syft demo-java:local -o cyclonedx-json > sbom.cdx.json
grype sbom:sbom.cdx.json --fail-on high
--fail-on high 是示例阈值,不应机械套用。生产策略通常还要区分漏洞是否存在可利用路径、是否已有修复版本,以及基础镜像维护方的安全公告。扫描器也可能产生误报或暂时没有修复方案,因此流水线需要受控的例外机制,并为例外设置负责人和过期时间。
SBOM 也不应只作为构建日志里的临时文件。更稳妥的做法是将它与镜像 digest、源码提交和构建记录关联保存,从而在新 CVE 发布时迅速定位受影响的制品。
落地时检查这六件事
- 使用多阶段构建,避免把构建工具和源码带入生产镜像。
- 固定基础镜像版本,并在部署清单中使用镜像 digest。
- 同时在镜像和编排层强制非 root、禁止提权、删除 capabilities。
- 默认使用只读根文件系统,只为明确的写入路径挂载卷。
- 生成并保存 SBOM,对镜像和应用依赖持续扫描。
- 定期重建镜像,而不是等待线上容器长期积累补丁债务。
加固镜像的核心不是追求一个“绝对安全”的标签,而是建立可审计、可重建、可快速替换的运行时。镜像越简单,权限越明确,更新路径越短,团队在漏洞出现时就越容易做出可靠响应。