大家好,我是程序员柏风,AI 最全的 Vibecoding 学习地图 http://coderoadmap.cn 作者。
容器安全基础
这一课你将能做到
- 把 Spring Boot 镜像改成非 root 用户运行,并用
id命令验证容器内进程身份 - 复现「密码写进 Dockerfile 会从
docker history泄漏」的安全事故,掌握运行时注入密钥的正确做法 - 用 Trivy 扫描镜像漏洞、用资源限制防止容器拖垮宿主机,形成一份可落地的安全清单
为什么要学
前面课程的目标一直是「能跑起来」,本课的目标是「敢放进生产」。容器默认配置有三个隐患,恰好在 Java 后端项目里最常见:
- 容器内进程默认以 root 运行——一旦容器被攻破,攻击者在容器内的 root 结合内核漏洞可能影响宿主机。
- 开发者习惯把数据库密码写进 Dockerfile 的 ENV——
docker history对任何拿到镜像的人可见,等于密码随镜像公开。 - 镜像里的基础系统与依赖带着已知 CVE——不扫描就带病上线。
这三条不需要高深知识就能修,改造成本低、面试常问、生产收益高,是性价比最高的一课。
先建立图像
安全问题的成因链条:
镜像层(构建期定型) 运行期(每次 run 决定)
┌──────────────────────────┐ ┌──────────────────────────┐
│ Dockerfile ENV 写了密码 ✗ │ │ -p 0.0.0.0:3306 裸奔 ✗ │
│ 以 root 身份运行 ✗ │ │ 无内存/CPU 限制 ✗ │
│ 基础镜像带 CVE ✗ │ │ 密码明文注入环境变量 △ │
└──────────────────────────┘ └──────────────────────────┘
↓ 修复手段 ↓ 修复手段
多阶段+最小镜像、USER 非 root 端口收敛、资源限额、
密钥不进层(history 可见) .env / secrets 注入
优先级排序(本课顺序即优先级):密钥不入镜像 > 非 root > 端口收敛 > 漏洞扫描 > 资源限制。
跟着做
步骤 1:亲眼确认默认是 root
docker run --rm eclipse-temurin:21-jre id
# 输出:uid=0(root) gid=0(root) groups=0(root)
步骤 2:Dockerfile 改为非 root 运行
FROM eclipse-temurin:21-jre
ENV TZ=Asia/Shanghai
# 创建系统用户(-r 不建家目录,uid 10001 固定便于 volume 授权)
RUN useradd -r -u 10001 appuser
USER appuser
WORKDIR /app
COPY --chown=appuser:appuser target/app.jar app.jar
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]
要点:COPY --chown 让 jar 归属应用用户;Java 监听 8080(>1024 非特权端口)无需 root。重新构建并验证:
docker build -t demo-app:1.1-secure .
docker run --rm demo-app:1.1-secure id
# 输出:uid=10001(appuser) —— 非 root 生效
步骤 3:复现「docker history 泄漏密码」
# 反例(仅实验,不要提交)
FROM eclipse-temurin:21-jre
ENV DB_PASSWORD=SuperSecret123
docker build -t leak-demo .
docker history leak-demo
# 输出里能看到:ENV DB_PASSWORD=SuperSecret123 —— 密码随镜像公开
同时它还写进了镜像层元数据,即使后续 ENV 覆盖也删不干净。结论:镜像里永远不出现明文密钥。
步骤 4:密钥的正确注入方式(运行时)
# 方式一:run 时 -e 注入
docker run -d --name secure-app \
-e SPRING_DATASOURCE_PASSWORD=StrongPass_2024 \
demo-app:1.1-secure
# 方式二:环境文件(生产推荐,权限 600)
# secret.env 内容:SPRING_DATASOURCE_PASSWORD=StrongPass_2024
docker run -d --env-file ./secret.env demo-app:1.1-secure
# compose 场景:.env 已在第 10 课实践(不进 git)
注意:-e 注入的变量可通过 docker inspect 被宿主机上有 docker 权限的人看到——所以宿主机本身必须收紧权限(docker 组≈root)。更进一步用 docker secrets / Vault / K8s Secret(进阶方向)。
步骤 5:收敛端口暴露面
# 危险:数据库端口对所有网卡开放(云上=公网可达)
docker run -d -p 3306:3306 mysql:8.0
# 正确:MySQL/Redis 完全不映射端口,只在容器网络内互通(第 9 课 app-net)
# 需要本地调试时,只绑回环:
docker run -d -p 127.0.0.1:3306:3306 mysql:8.0
# 应用端口对外提供时再绑 0.0.0.0(默认)
步骤 6:资源限制防「拖垮宿主机」
docker run -d --name limited-app \
--memory=512m --memory-swap=512m \
--cpus=1 \
-p 8080:8080 demo-app:1.1-secure
docker stats --no-stream limited-app # 查看实际占用
内存超限的容器会被 OOM kill(docker inspect 里 State.OOMKilled: true)——这本身就是保护:宁可重启单个应用,不能让 JVM 吞掉整台机器。Java 侧配合:JDK 10+ 默认感知容器内存限制;仍建议显式 -XX:MaxRAMPercentage=75.0 而不是写死 -Xmx。
步骤 7:镜像漏洞扫描
# Trivy(业界主流,一条命令扫指定镜像)
docker run --rm -v /var/run/docker.sock:/var/run/docker.sock \
aquasec/trivy:latest image demo-app:1.1-secure
输出按严重级别列出 CVE(CRITICAL/HIGH 优先处理)。配合习惯:基础镜像固定用较新 tag、定期重建镜像(让基础层更新)、CRITICAL 清零后再发布。Docker Desktop 内置的 docker scout 可作为替代。
讲清楚要点
- 密钥不入镜像是铁律:ENV、ARG、COPY 的配置文件都会永久留在镜像层里,
docker history与 registry 上的任何使用者都可见。密钥只在运行时进入容器,且来源必须受控(.env/secrets/配置中心)。 - 非 root 的边界:它防的是「容器内横向提权 + 配合内核漏洞逃逸」的常见路径,不是银弹。生产进阶方向:rootless 模式运行整个引擎、K8s 里 securityContext 强制约束。
- 最小镜像 = 最小攻击面:基础镜像里每个多余的包都是潜在漏洞来源。多阶段构建(第 6 课)同时是性能优化和安全优化。
- 端口默认关闭、按需打开:安全组只放行 80/443(及必要的管理口);MySQL/Redis 保持在容器网络内部。
-p 127.0.0.1:...是本地调试的安全姿势。 - 资源限制是可用性安全:无限制的容器 = 单点故障放大器。JVM 应用显式设内存上限,避免 full GC 或内存泄漏拖垮宿主机上所有服务。
你可能会懵的地方
-
容器内 root 等于宿主机 root 吗? 不完全等于——容器有独立的用户命名空间映射、能力(capabilities)被裁剪,root 在容器内权限受限。但历史上多次出现「容器内 root + 内核漏洞 → 逃逸到宿主机」,所以非 root + 最小能力仍是标配。
-
非 root 运行后写卷报 Permission denied? 卷目录属主与容器内 uid 不一致。两种修法:构建时固定 uid 并用
chown初始化卷(如 MySQL 容器内是 uid 999);或运行一次性容器chown -R 10001:10001 /挂载点对齐属主。 -
密码用
-e注入就绝对安全了吗? 相比写进镜像是质的提升,但docker inspect和/proc/1/environ在容器内可见。分级认知:镜像内 ✗ → 宿主机 .env △ → secrets 管理(文件挂载、配置中心、Vault)✓。按公司安全等级选择。 -
镜像扫描出一堆 CVE 怎么办? 按 CRITICAL/HIGH 优先修:多数来自基础镜像,升级基础镜像 tag 重新构建即可消除;少数来自应用依赖(jar 里的库漏洞),走 Maven 依赖升级流程。扫描要进 CI,做成发布门禁。
-
还需要学 rootless Docker / Podman 吗? 知道方向即可:rootless 让引擎本身不以 root 跑,进一步缩小逃逸影响面,多见于强合规环境;Podman 是无守护进程的 OCI 兼容替代品,命令与 docker 几乎一致(
alias docker=podman可无缝过渡)。
错法对照
| 错法 | 问题 | 正解 |
|---|---|---|
Dockerfile 里 ENV DB_PASSWORD=xxx | docker history 与镜像层永久可见 | 运行时 -e / --env-file / secrets |
| 容器默认 root 运行 | 提权与逃逸风险放大 | useradd + USER + COPY --chown |
-p 3306:3306 直接暴露数据库 | 公网爆破,教科书级事故 | 不映射;调试绑 127.0.0.1 |
| JVM 容器不设内存限制 | OOM 拖垮宿主机全部服务 | --memory + MaxRAMPercentage |
基础镜像用 latest 永不更新 | CVE 随时间持续累积 | 固定版本 + 定期重建 + CI 扫描 |
| 只扫一次镜像 | 新 CVE 持续披露 | 扫描进流水线,发布门禁 |
卡点急救
- 非 root 后容器启动即退:
docker logs看是否有 Permission denied。检查 jar 与工作目录的属主(COPY --chown)、写日志目录是否挂载给了正确 uid。 - Trivy 拉取失败或扫描超时:国内网络问题,可换 registry 镜像源拉
aquasec/trivy,或改用 Docker Desktop 的 docker scout。 --env-file不生效:文件里不能有空格分隔的行内赋值格式错误(必须KEY=value),且不会做 shell 展开;用docker exec 容器 printenv验证注入结果。- 容器 OOMKilled 反复重启:
docker inspect确认State.OOMKilled: true;上调--memory或下调 JVMMaxRAMPercentage,并排查应用内存泄漏(docker stats观察)。 - 误提交了带密码的镜像到仓库:立即换密码(改密码优于删镜像)、删除镜像 tag、清理 git/CI 日志中的明文,再用运行时注入方式重新发布。
面试常问
-
说一说你的容器安全实践清单? 按层回答:镜像层(多阶段最小镜像、固定版本基础镜像、非 root、无明文密钥、CI 漏洞扫描);运行层(端口最小暴露、资源限额、restart 策略);宿主层(docker 组收紧、内核与 Docker 版本及时更新、日志审计)。
-
为什么容器内不应该用 root? 纵深防御:非 root 缩小容器被攻破后的横向移动能力,降低配合内核漏洞逃逸的风险。实现是 Dockerfile 的
USER指令;K8s 侧对应 securityContext 的 runAsNonRoot。 -
如何在 Dockerfile 之外安全地传递数据库密码? 运行时注入:
--env-file(宿主机文件,权限 600、不入 git)、compose 的.env、docker secrets、配置中心/密钥管理服务。原则:密钥不进镜像层、不进 git、进容器仅运行时。 -
镜像漏洞扫描的原理与落地方式? 扫描器解析镜像层里的系统包与应用依赖清单,与 CVE 数据库比对。落地:Trivy/scout 进 CI,CRITICAL 阻断发布,基础镜像定期升级重建。
自测三问
- 写出把 Spring Boot 镜像改为 uid 10001 非 root 运行的 Dockerfile 片段,并说明
COPY --chown的作用。 - 为什么
docker history能看到密码?哪些 Dockerfile 写法会让密钥进入镜像层? - 给一个内存 2G 的宿主机上跑的 JVM 容器,设计内存限制方案(容器 + JVM 两侧)。
小结一句话
容器安全的四件套——密钥运行时注入、非 root 运行、端口最小暴露、资源限额加漏洞扫描——把「能跑的镜像」变成「敢上生产的镜像」。
延伸
- Docker 安全官方文档
- Trivy 官方仓库
- 最后一课预告:综合练习——把 11 课所学串成一条完整的生产部署流水线,并绘制 K8s 进阶路线图。