安全团队第一次对容器环境做漏洞扫描时,通常会看到一个让人头皮发麻的数字——几百个已知 CVE。逐个排查后会发现一个反直觉的事实:绝大多数漏洞根本不在你的应用代码里,而是来自基础镜像自带的那些你从未调用过的包:shell、编译器、调试工具、多余的库。换句话说,你辛辛苦苦写的业务代码只占攻击面的极小一部分,真正的风险藏在供应链上游。
这就引出了 Hardened Image(加固镜像) 的核心思路:把基础镜像里不需要的东西砍掉,让攻击面从"几百个 CVE"收缩到"只有应用真正依赖的那几个"。
CVE 的真正来源:基础镜像的"全家桶"
普通基础镜像为了通用性,预装了大量工具。以 ubuntu:22.04 为例,默认带 bash、apt、dpkg、gcc 运行时依赖、netcat、甚至 python3——你的 Go 二进制或 Java 应用压根不需要它们,但它们每一个都可能携带 CVE。
扫描一个典型未加固镜像的结果大致是这样的分布:
| 来源 | CVE 占比 | 典型组件 |
|---|---|---|
| 基础镜像预装包 | 70-90% | bash, libssl, libcurl, gcc-libs, python3 |
| 应用直接依赖 | 10-20% | 框架/SDK 自带库 |
| 应用自身代码 | <5% | 业务逻辑漏洞 |
这意味着:哪怕你的代码写得滴水不漏,只要基础镜像没加固,攻击面就已经敞开了。攻击者不需要找到你代码的 0day,只需要利用镜像里那个你从未用过但一直存在的 libcurl 远程执行漏洞就够了。
加固镜像做了什么
Hardened Image 的策略可以概括为三条:
1. 切换到最小化基础镜像
从 ubuntu / debian 切换到 distroless 或 alpine。Distroless 镜像只包含你的应用及其运行时依赖,没有 shell、没有包管理器、没有调试工具——连 apk 或 apt 都不存在,攻击者即使拿到容器权限也无法轻易安装新工具。
2. 移除不需要的包和文件
如果必须用较大的基础镜像,在构建阶段显式删除不必要组件:文档、man page、缓存、开发工具链。每删一个包,就少了一整类 CVE。
3. 固化运行时行为
只读根文件系统、非 root 用户运行、删除写权限目录——即使有残留漏洞,也大幅降低可利用性。
实战:从普通镜像到加固镜像
下面给出三种常见加固路径,从轻到重,你可以按团队现状选择。
路径一:Alpine 替换 + 非 root 用户
最简单的加固:把基础镜像换成 Alpine,并用非 root 用户运行。
# --- before: 普通镜像 ---
FROM ubuntu:22.04
RUN apt-get update && apt-get install -y python3 python3-pip
COPY . /app
WORKDIR /app
RUN pip3 install -r requirements.txt
CMD ["python3", "app.py"]
# --- after: 加固镜像 ---
FROM python:3.12-alpine
# 只安装运行时真正需要的系统库(示例:PostgreSQL 客户端库)
RUN apk add --no-cache libpq \
&& rm -rf /var/cache/apk/*
# 创建非 root 用户
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
COPY requirements.txt /app/requirements.txt
WORKDIR /app
RUN pip install --no-cache-dir -r requirements.txt \
&& rm -rf /root/.cache
COPY . /app
USER appuser
CMD ["python", "app.py"]
关键改动说明:
- --no-cache 让 apk 不保留缓存索引;rm -rf /var/cache/apk* 双重保险
- pip install --no-cache-dir 不在镜像内留 pip 缓存
- USER appuser 确保进程不以 root 运行
路径二:Distroless——连 shell 都没有
Google 的 distroless 镜像更激进:镜像里只有你的应用二进制和它的运行时依赖,没有 /bin/sh,没有 apk/apt,没有任何交互入口。
# --- 多阶段构建 + distroless ---
FROM python:3.12-slim AS builder
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir --prefix=/install -r requirements.txt
COPY . /app
# --- 运行阶段:distroless,无 shell ---
FROM gcr.io/distroless/python3-debian12
COPY --from=builder /install /usr/local
COPY --from=builder /app /app
WORKDIR /app
CMD ["app.py"]
注意:CMD 只能写数组形式(["app.py"]),不能用 shell 语法("python app.py"),因为镜像里根本没有 shell。调试时你可以临时用 debug 版本镜像(gcr.io/distroless/python3-debian12:debug),它带一个 busybox shell,但生产环境绝不应该用 debug 版本。
路径三:只读根文件系统 + 安全上下文
在 Kubernetes 中配合只读根文件系统,进一步锁死运行时:
apiVersion: v1
kind: Pod
metadata:
name: hardened-app
spec:
securityContext:
runAsNonRoot: true
runAsUser: 1000
fsGroup: 1000
containers:
- name: app
image: my-registry/hardened-app:latest
securityContext:
readOnlyRootFilesystem: true
allowPrivilegeEscalation: false
capabilities:
drop:
- ALL
volumeMounts:
# 只读根文件系统下,需要把写目录挂成 tmpfs
- name: tmp-write
mountPath: /tmp
- name: cache-write
mountPath: /app/.cache
volumes:
- name: tmp-write
emptyDir:
medium: Memory # tmpfs,内存临时存储
- name: cache-write
emptyDir:
medium: Memory
这段 YAML 做了四件事:
- runAsNonRoot: true 强制非 root,Pod 级别兜底
- readOnlyRootFilesystem: true 根文件系统只读,攻击者无法写入恶意文件
- drop ALL capabilities 移除所有 Linux capabilities,只留最小权限
- 写目录用 emptyDir + Memory(tmpfs)挂载,重启后自动清空
验证加固效果:扫描对比
加固前后用 Grype 或 Trivy 扫描,量化差异:
# 安装 Grype(Anchore 的开源扫描器)
curl -sSfL https://raw.githubusercontent.com/anchore/grype/main/install.sh | sh -s -- -b /usr/local/bin
# 扫描普通镜像
grype ubuntu:22.04 -o table | head -30
# 扫描加固后的镜像
grype my-registry/hardened-app:latest -o table | head -30
# 只看严重/高危 CVE 数量对比
grype ubuntu:22.04 -o json | jq '.matches | length'
grype my-registry/hardened-app:latest -o json | jq '.matches | length'
典型结果:ubuntu:22.04 可能检出 200+ CVE,而加固后的 distroless/alpine 镜像通常只剩 0-10 个,且多数是运行时库的低危问题。
采用加固镜像的现实考量
加固镜像不是零成本的,团队需要评估以下取舍:
调试成本上升。 Distroless 镜像没有 shell,kubectl exec 进去只能看到空荡荡的文件系统。应对方式:本地开发用普通镜像调试,生产用加固镜像;紧急排查时临时部署 debug 版本。
CI/CD 流程需要调整。 多阶段构建让 Dockerfile 更复杂;只读根文件系统要求应用不能随意写文件,日志要输出到 stdout、临时数据要用 tmpfs 或外部存储。
基础镜像更新节奏。 Alpine 和 distroless 的更新频率低于 Ubuntu/Debian,关键 CVE 修复可能滞后几天。建议在 CI 中加入每日镜像扫描步骤,发现高危 CVE 时自动触发重建。
渐进式路线图:
| 阶段 | 动作 | 预期 CVE 降幅 |
|---|---|---|
| 第 1 周 | 所有镜像添加 USER appuser,禁止 root 运行 |
0%(降可利用性,不降 CVE 数) |
| 第 2-3 周 | 基础镜像从 ubuntu/debian 切到 alpine | 60-80% |
| 第 4-6 周 | 关键服务迁移到 distroless + 多阶段构建 | 85-95% |
| 第 7 周+ | Kubernetes 加 readOnlyRootFilesystem + drop ALL capabilities | 运行时攻击面进一步收窄 |
不要试图一步到位。先让所有镜像不以 root 运行——这一个改动就能阻断大量提权攻击路径。再逐步替换基础镜像、收紧文件系统权限。每一步都让攻击者的可用选项更少,而你的运维负担只增加一点点。