Docker 镜像安全实战:基础镜像治理、扫描与运行时收敛

容器镜像的安全问题通常不是某个高危漏洞突然出现,而是无人治理的累积:基础镜像半年没更新、构建工具链被打进生产镜像、容器以 root 和全能力集运行。治理思路是把攻击面拆成三层——基础镜像层、构建过程层、运行时层——每层都有明确的收敛手段。本文按这三层给出可直接执行的清单:选型与更新纪律、扫描接入点、Dockerfile 写法、运行时参数,并说明报告噪音和老镜像认领这两个组织层面的常见问题。

适用环境:Docker 20.10 及以上(运行时参数对 containerd 同样适用),扫描工具以 Trivy 为例,仓库以 Harbor 为例(其思路适用于任何支持扫描触发的镜像仓库)。

前置条件

  • 有一条能修改的镜像构建流水线(或本地构建环境),以及一个私有镜像仓库;
  • 能统计现网正在运行的镜像清单(至少知道哪些镜像、哪个版本在跑);
  • 明确一条底线:扫描结果要有处置流程,而不是只生成报告。

第一层:基础镜像选型与更新纪律

绝大多数镜像漏洞来自基础镜像而非业务代码,治理先从这里开始:

  • 选官方或可信来源:优先使用语言/运行时官方镜像或发行版官方镜像;来历不明的「优化版」镜像不进生产,哪怕它体积小;
  • 体积与攻击面成正比:在满足运行依赖的前提下优先选 slim、alpine 或 distroless 变体。alpine 注意 musl 与 glibc 的兼容差异,distroless 没有 shell 会增加排障成本,按团队能力选,不要只比大小;
  • 固定版本,禁止 latest:基础镜像必须固定到具体版本,最好固定 digest。latest 意味着每次构建结果不可复现,出问题时无法回答「和上次到底差在哪」;
  • 定期重建,而非等漏洞:给基础镜像设固定更新节奏(例如每月跟随官方安全更新重建一次并全量回归),漏洞驱动的临时更新只处理高危项。重建后用新 tag 发布、灰度替换,老 tag 保留一个周期用于回滚。
# 推荐:固定到具体版本
FROM eclipse-temurin:17.0.9_9-jre-jammy
# 要求更强可复现时固定 digest(版本可读性差一些,用注释标明对应版本)
# FROM eclipse-temurin:17.0.9_9-jre-jammy@sha256:xxxxxxxx

第二层:构建过程——Dockerfile 的安全写法

构建层的目标是:镜像里只有运行必需的东西,进程不以特权身份运行。多阶段构建是实现这一点的标准手法,第一阶段带完整工具链编译,第二阶段只拷产物:

# ---- 构建阶段:工具链只存在于这一层,最终镜像不包含 ----
FROM golang:1.21-bookworm AS builder
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o /out/app ./cmd/app

# ---- 运行阶段:最小基础镜像 + 非 root 用户 + 只带产物 ----
FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=builder /out/app /app
EXPOSE 8080
USER nonroot:nonroot
ENTRYPOINT ["/app"]

配套的几条写法纪律:

  • 非 root 运行:镜像内创建专用用户并用 USER 切过去;语言类镜像多数自带如 node、www-data 这类现成用户,优先用它们;
  • 密钥不进镜像:密码、令牌、私钥一律运行时注入(环境变量、挂载、密钥系统),绝不 COPY 进镜像——镜像层一旦分发出去就收不回。构建期需要凭据的用 BuildKit 的 secret 挂载,不要用构建参数硬塞;
  • .dockerignore 要维护:把 .git、本地配置、测试数据排除在构建上下文之外,既减小体积,也避免顺手把敏感文件打进去;
  • 包管理器收尾:用 apt/dnf 装包时同层清理缓存(rm -rf /var/lib/apt/lists/*),分层残留的已删文件在下层仍然可被取出。

第三层前半:镜像扫描接入——流水线与仓库两侧

扫描要放两个点,作用不同:流水线侧是「出厂门禁」,仓库侧是「存量巡检」(新漏洞是持续披露的,今天干净的镜像下个月可能爆出高危)。

流水线侧:构建后立即扫,按级别卡口

# 构建完成后、推送前扫描;仅对 HIGH/CRITICAL 且已有修复版本的漏洞返回非零退出码
trivy image --severity HIGH,CRITICAL --ignore-unfixed \
  --exit-code 1 harbor.example.com/apps/demo-app:1.4.2

卡口策略要现实:存量项目第一次接入时高危可能成百上千,一刀切全卡会直接瘫痪交付。可行做法是先只统计不拦截跑两周,摸清基数;再对「有修复版本的高危」开启拦截;中低危进报告跟踪。基线只对新版本生效,老版本不追溯拦截,但要列入重建清单。

仓库侧:定时全量复扫

Harbor 自带扫描集成(可对接 Trivy),开启后按计划对仓库内镜像周期性复扫,并可设置「存在指定级别漏洞的镜像禁止拉取」的仓库级策略。没有仓库集成时,用定时任务跑 Trivy 批量扫运行中镜像清单,效果相同。复扫发现新高危时的动作要预先定义:通知镜像负责人、给出重建期限、超期未处理的在仓库侧限制拉取新部署(存量运行实例不强制下线,避免把安全治理做成生产事故)。

第三层后半:运行时收敛

镜像再干净,也要假设它会被利用,运行时收敛是限制爆炸半径的最后一道:

docker run -d --name demo-app \
  --read-only \                                  # 根文件系统只读,临时目录用 tmpfs 单独给
  --tmpfs /tmp:rw,noexec,nosuid,size=64m \
  --cap-drop ALL \                               # 先清空全部 capability,再按需加回
  --security-opt no-new-privileges \             # 禁止进程通过 setuid 等方式提权
  --memory 512m --cpus 1.0 \                     # 资源上限,防单个容器拖垮宿主机
  -p 8080:8080 \
  harbor.example.com/apps/demo-app:1.4.2
  • cap_drop ALL 是默认动作:绝大多数应用不需要任何 capability;确需绑低端口之类时,只加回单个(如 NET_BIND_SERVICE),不要图省事用 --privileged,它等于放弃容器隔离;
  • 只读文件系统要先验证:应用往本地写日志、写缓存的习惯会让它在只读模式下直接起不来,先改成日志走标准输出、临时文件走 tmpfs,再开 --read-only;
  • Kubernetes 里等价配置:对应 Pod 的 securityContext(runAsNonRoot、readOnlyRootFilesystem、capabilities.drop、allowPrivilegeEscalation: false),口径与上面一一对应。

验证

# 确认进程身份与能力集:Uid 非 0、CapEff 接近 0 即收敛生效
docker exec demo-app id
docker inspect demo-app --format '{{.HostConfig.CapDrop}} {{.HostConfig.ReadonlyRootfs}}'
# 对比改版前后漏洞数量,确认治理收益可量化
trivy image --severity HIGH,CRITICAL harbor.example.com/apps/demo-app:1.4.1
trivy image --severity HIGH,CRITICAL harbor.example.com/apps/demo-app:1.4.2

常见问题

1. 扫描报告噪音太大,没人看。噪音主要来自三类:无修复版本的漏洞(修不了,先忽略并记录)、发行版标记为「不受影响/争议中」的条目、以及业务根本用不到的组件被基础镜像带进来。前两类用忽略清单治理,忽略必须带理由和复审日期,禁止无期限全局忽略;第三类靠换 slim 基础镜像根治。报告呈现上只推「新增高危」增量,不推全量存量,否则两周后所有人都会对报告脱敏。

2. 老镜像无人认领,没人敢删也没人重建。这是组织问题不是技术问题:先用仓库与集群数据对账,列出「在跑的镜像 → 所属服务 → 负责人」,没有负责人的先标记孤儿并公告认领期;到期未认领的先停止新部署(仓库侧限制拉取到新节点),观察一个周期后再删。直接删在跑镜像是给自己造故障,流程上宁可慢。

3. 切了非 root 后应用起不来、报权限错误。常见原因是应用要写的工作目录、缓存目录属于 root。解决办法是在 Dockerfile 里提前把这些目录属主改给运行用户,或运行时挂载时指定属主;不要用「先 root 启动再降权」这类绕法长期将就,那等于没收敛。

4. alpine 镜像省了体积却引来兼容问题。musl libc 与 glibc 在 DNS 解析、部分二进制兼容上有差异,某些预编译程序在 alpine 上直接跑不了或行为不同。选型顺序建议:先 slim(glibc 系)验证,再评估 distroless,最后才考虑 alpine;省下的几十 MB 不值得用线上兼容事故来换。

小结

镜像安全治理可以压缩成四句话:基础镜像固定版本并按月重建;构建只留产物、进程非 root、密钥不进层;扫描在流水线卡增量、在仓库巡检存量;运行时默认 cap 全丢、文件系统只读、资源设上限。技术动作都不难,成败取决于纪律能否持续执行——所以从第一天就把这些写进流水线模板和交付检查单,而不是靠每位开发者的自觉。

发表回复

后才能评论