阶段一 · 容器与集群安全

容器镜像安全

一句话总结

镜像是容器的"基因", 有什么漏洞, 跑什么用户, 暴露什么权限, 在 docker build 那一刻就决定了.
镜像安全是云原生安全的第一道防线: 构建阶段消灭的风险, 运行时就不需要防御.

容器镜像的攻击面

一个容器镜像从构建到运行, 每个环节都可能引入风险:

环节 风险 攻击示例
基础镜像 包含已知 CVE 漏洞的系统库 攻击者利用 OpenSSL 漏洞逃逸容器
依赖安装 引入恶意包或有漏洞的版本 npm/pip 供应链投毒
构建过程 密钥泄露到镜像层 git clone 用的 SSH key 留在中间层
镜像仓库 镜像被篡改或替换 中间人攻击替换 latest tag
运行时用户 以 root 身份运行 应用漏洞直接获得宿主机 root
多余组件 镜像中包含 shell, 编译器等 攻击者进入容器后可利用这些工具

镜像 = 打包好的行李箱.
把护照(密钥), 剪刀(危险工具), 过期食品(已知漏洞)都塞进去, 到了目的地(运行时)就是一堆安全隐患.
正确做法: 只带必需品, 行李箱上锁(签名), 过安检(扫描).

基础镜像选择

常见基础镜像对比

镜像 大小 包含内容 安全性 适用场景
ubuntu:22.04 ~77MB 完整 apt 包管理器 + 常用工具 攻击面大 开发调试, 需要系统工具
debian:slim ~52MB 精简 Debian, 无文档和非必需包 中等 需要 apt 但想减小体积
alpine:3.19 ~7MB musl libc + busybox + apk 较好 通用轻量场景
gcr.io/distroless ~2-20MB 只有运行时依赖, 无 shell/包管理器 极好 Go/Java/Node 生产运行
scratch 0MB 空镜像, 什么都没有 最佳 静态编译的 Go binary

Go 服务的最佳实践

Go 可以静态编译(CGO_ENABLED=0), 产出一个不依赖任何系统库的单一 binary. 直接用 scratchdistroless/static 做基础镜像, 镜像里只有 binary, 没有 shell, 没有包管理器, 没有任何多余工具.

攻击者即使进入容器也无从下手.

Distroless 详解

Google 维护的 Distroless 镜像去掉了操作系统中一切非必需的东西:

  • 没有 shell(/bin/sh, /bin/bash)
  • 没有包管理器(apt, apk)
  • 没有常用工具(curl, wget, ls)
  • 只包含运行时所需的最小依赖(libc, ca-certificates 等)
# Go 服务: 多阶段构建 + distroless
FROM golang:1.22-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -ldflags="-s -w" -o /server ./cmd/server

FROM gcr.io/distroless/static:nonroot
COPY --from=builder /server /server
USER nonroot:nonroot
ENTRYPOINT ["/server"]

Distroless 的调试困境

没有 shell 意味着不能 kubectl exec 进容器排查问题. 解决方案:

  1. 使用 Ephemeral Containers(K8s 1.25+ GA)临时注入调试容器
  2. 在 CI 中同时构建 debug 版本(基于 alpine)用于非生产环境

漏洞扫描

镜像中的系统包和应用依赖可能包含已知漏洞(CVE). 扫描器检查镜像的每一层, 对比 CVE 数据库, 报告风险.

Trivy: 全能扫描器

Trivy(Aqua Security 开源)是当前最流行的容器安全扫描工具:

# 扫描镜像漏洞
$ trivy image myapp:latest

# 只显示 CRITICAL 和 HIGH
$ trivy image --severity CRITICAL,HIGH myapp:latest

# 扫描 Dockerfile(构建前检查)
$ trivy config Dockerfile

# 扫描 K8s YAML(配置安全)
$ trivy config k8s/deployment.yaml

# 输出 JSON(集成 CI)
$ trivy image --format json --output report.json myapp:latest

Trivy 扫描结果示例:

myapp:latest (alpine 3.18.4)
Total: 3 (CRITICAL: 1, HIGH: 2)

┌──────────────┬──────────────┬──────────┬───────────────────┐
│ Library │ Vulnerability│ Severity │ Fixed Version │
├──────────────┼──────────────┼──────────┼───────────────────┤
│ openssl │ CVE-2024-xxx │ CRITICAL │ 3.1.5-r0 │
│ curl │ CVE-2024-yyy │ HIGH │ 8.5.0-r0 │
│ zlib │ CVE-2024-zzz │ HIGH │ 1.3.1-r0 │
└──────────────┴──────────────┴──────────┴───────────────────┘

CI 集成: 构建时拦截

# GitHub Actions 示例
- name: Scan image
uses: aquasecurity/trivy-action@master
with:
image-ref: myapp:${{ github.sha }}
severity: CRITICAL,HIGH
exit-code: 1 # 发现高危漏洞时 CI 失败

扫描时机

构建时: CI 中扫描, 有 Critical 漏洞则阻止合并/部署.
仓库中: 镜像仓库(Harbor)定期重新扫描已存储的镜像(新 CVE 可能随时公布).
运行时: 准入控制器(如 Kyverno)拒绝部署未扫描或有高危漏洞的镜像.

三道防线缺一不可.

最小化镜像构建

多阶段构建

将"编译环境"和"运行环境"分离, 编译器, 源码, 构建工具不进入最终镜像:

# 阶段 1: 编译(需要 Go SDK + git)
FROM golang:1.22 AS builder
WORKDIR /app
COPY . .
RUN CGO_ENABLED=0 go build -o /app/server

# 阶段 2: 运行(只需要 binary)
FROM scratch
COPY --from=builder /app/server /server
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
ENTRYPOINT ["/server"]
# 最终镜像: 只有 binary + CA 证书, ~10MB

减少层数与内容

  • 合并 RUN 命令: RUN apt install && apt clean && rm -rf /var/lib/apt/lists/* 在一层中完成安装和清理
  • .dockerignore: 排除 .git, node_modules, 测试文件等不需要的内容
  • 固定版本: FROM alpine:3.19.1 而非 alpine:latest, 确保构建可复现
  • 不安装非必需包: apt install --no-install-recommends

非 Root 运行

默认情况下容器以 root 运行, 如果应用被攻破, 攻击者直接获得 root 权限.

为什么危险

容器的 root 虽然通过 Linux Namespace 与宿主机隔离, 但:

  • 如果有内核漏洞可以逃逸 Namespace → 直接变成宿主机 root
  • 如果挂载了宿主机目录 → 可以修改宿主机文件
  • 如果有 SYS_ADMIN capability → 可以挂载 proc 等特殊文件系统

实现非 root

# 方式 1: Dockerfile 中创建用户
FROM alpine:3.19
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
COPY --chown=appuser:appgroup ./app /app
USER appuser
ENTRYPOINT ["/app"]

# 方式 2: 使用 distroless/nonroot(已预置 nonroot 用户 UID=65532)
FROM gcr.io/distroless/static:nonroot
COPY --from=builder /server /server
USER nonroot:nonroot
ENTRYPOINT ["/server"]
# K8s 层面强制非 root
apiVersion: v1
kind: Pod
spec:
securityContext:
runAsNonRoot: true # 强制非 root
runAsUser: 65532 # 指定 UID
runAsGroup: 65532
containers:
- name: app
securityContext:
allowPrivilegeEscalation: false # 禁止提权
readOnlyRootFilesystem: true # 只读文件系统

runAsNonRoot 不检查 Dockerfile 的 USER 指令

runAsNonRoot: true 只在 Pod 启动时检查进程 UID 是否 ≠ 0. 如果镜像的 Dockerfile 没有设置 USER(默认 root), 但 K8s 配置了 runAsUser: 65532, 容器可以正常启动(K8s 覆盖了 UID).

如果只设 runAsNonRoot: true 而不指定 runAsUser, 且镜像 USER 是 root → Pod 启动失败.

镜像中的密钥泄露

Docker 镜像的每一层都是永久保存的. 即使在后面的层 rm 了一个文件, 它仍然在前面的层中可以被提取.

常见泄露方式

  • COPY .env /app/.env - 环境变量文件包含密钥
  • RUN git clone https://token@github.com/... - token 留在层历史中
  • COPY id_rsa /root/.ssh/id_rsa - SSH 私钥
  • ARG DB_PASSWORD - 构建参数写入镜像元数据

防止泄露

# 错误: 密钥留在镜像层中
COPY .npmrc /app/.npmrc
RUN npm install
RUN rm /app/.npmrc # 没用! 前面的层仍然有这个文件

# 正确: 使用 BuildKit secret mount
# syntax=docker/dockerfile:1
RUN --mount=type=secret,id=npmrc,target=/app/.npmrc npm install
# 密钥只在 RUN 执行期间存在, 不写入任何层

# 正确: 多阶段构建, 密钥只在 builder 阶段
FROM node:20 AS builder
COPY .npmrc /app/.npmrc
RUN npm install
RUN rm /app/.npmrc

FROM node:20-slim
COPY --from=builder /app/node_modules /app/node_modules
# 最终镜像不包含 .npmrc

检测已泄露的密钥

Trivy 和 docker history 可以扫描镜像层中的敏感内容. 还有专门的工具如 ggshield(GitGuardian), trufflehog 用于检测仓库和镜像中的 secret.

建议在 CI 中集成 secret 扫描作为必要检查.

镜像 Tag 与不可变性

latest 的风险

myapp:latest 是可变的, 今天指向 v1.2.3, 明天可能指向 v1.2.4. 这导致:

  • 无法确定生产环境跑的到底是哪个版本
  • 攻击者替换 latest tag → 部署恶意镜像
  • 回滚时 kubectl rollout undo 拉到的可能不是之前的版本

用 Digest 固定镜像

# 不推荐: 使用可变 tag
image: myapp:latest

# 推荐: 使用 Git SHA 或语义版本
image: myapp:v1.2.3

# 最佳: 使用 Digest(内容寻址, 绝对不可变)
image: myapp@sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4

生产最佳实践

CI 构建时生成 image:git-sha 的 tag 并推送.
部署文件引用具体的 sha tag 或 digest.
通过 Kyverno 策略禁止使用 :latest tag 部署.
镜像仓库开启 tag 不可变(Immutable Tags)防止覆盖.

镜像仓库安全

  • 私有仓库: 不要把生产镜像放在公开的 Docker Hub 上
  • 漏洞扫描: Harbor 等企业仓库内置自动扫描
  • RBAC: 控制谁能 push/pull 哪些镜像
  • 镜像签名: Cosign 签名 + 准入策略验证(第 06 篇详解)
  • 内容信任: Docker Content Trust / Notary 确保镜像未被篡改

镜像安全检查清单

Dockerfile 安全审查要点

每次 code review 时对照检查:

  • □ 基础镜像使用固定版本 tag(非 latest)
  • □ 使用多阶段构建, 编译工具不进入最终镜像
  • □ 最终镜像尽量使用 distroless / scratch
  • □ 设置了 USER 为非 root
  • □ 没有在 COPY/ADD 中包含敏感文件
  • □ 没有在 ARG/ENV 中硬编码密钥
  • □ RUN 中用到密钥时使用 --mount=type=secret
  • □ .dockerignore 排除了 .git / .env / 测试文件
  • □ CI 中集成了 Trivy 扫描(Critical/High 阻断)
  • □ 部署文件使用具体版本 tag 或 digest

快速回顾

  • 基础镜像选择: Go 用 scratch/distroless, 其他语言用 distroless 对应变体, 避免 ubuntu/debian 全量镜像
  • 漏洞扫描: Trivy 集成到 CI, 构建 + 仓库 + 准入三道防线
  • 多阶段构建: 编译环境和运行环境分离, 最终镜像只有 binary + 运行时依赖
  • 非 root: Dockerfile USER + K8s runAsNonRoot + allowPrivilegeEscalation=false
  • 密钥保护: BuildKit secret mount + 多阶段隔离, 绝不让密钥进入镜像层
  • 不可变 tag: 使用 git-sha 或 digest, 禁止 latest

动手练习

  1. Trivy 扫描修复: 用 Trivy 扫描当前项目的 Docker 镜像, 列出所有 Critical/High 漏洞并修复(通常是升级基础镜像版本)
  2. 多阶段 + distroless 改写: 将一个基于 ubuntu 的 Dockerfile 改写为多阶段 + distroless, 对比镜像大小和漏洞数量
  3. 验证镜像层密钥泄露: 在 Dockerfile 中故意 COPY 一个 .env 文件, 然后用 docker historydocker save | tar 验证文件确实可以从镜像层中提取出来
  4. CI 自动扫描配置: 配置 GitHub Actions / GitLab CI 在构建后自动运行 Trivy, 设置 exit-code=1 让 Critical 漏洞阻断 pipeline