正文
Docker 镜像泄露 secret,很多时候不是黑客攻破了服务器,而是开发者把本地文件、构建参数、缓存层或调试脚本一起打进了镜像。镜像一旦推到 registry、被 CI 缓存、被同事拉取,里面的 .env、SSH key、npm token、云厂商凭据就不再只是“本机误操作”。
2023 年一项针对容器镜像的互联网范围研究分析了 337,171 个 Docker Hub 镜像和 8,076 个其它私有 registry 镜像,发现 8.5% 的镜像包含 secret,并识别出 52,107 个私钥和 3,158 个 API secret。研究还提到,一部分泄露密钥已经在真实 TLS 或 SSH 主机上被使用。参考:Secrets Revealed in Container Images。
上线前先把 Docker 镜像当成一个会被复制、缓存、下载、解包和长期保存的制品。你要检查的不只是最终容器能不能启动,还要确认构建上下文、镜像层、镜像历史、缓存和 CI 日志里没有凭据。
| 最容易泄露的位置 | 常见内容 |
|---|---|
| build context | .env、测试数据、数据库 dump、私钥、日志 |
ARG / ENV | API key、npm token、云厂商凭据 |
COPY . . | 整个仓库、隐藏目录、临时输出 |
| 构建缓存 | 下载私有包时用过的 token、生成文件 |
| 镜像历史 | 构建命令里的明文参数 |
| 多阶段构建 | 从 build stage 误拷贝了配置和缓存 |
| CI 日志 | docker build 命令、构建输出、错误堆栈 |
先查 build context 里有什么
Docker 构建不是只读 Dockerfile。你执行 docker build . 时,当前目录会作为 build context,Dockerfile 里的 COPY、ADD 可以引用这个上下文里的文件。Docker 官方文档也说明,本地目录作为 context 时,这些文件会对 builder 可用。
先列出项目里不应该进入构建上下文的文件:
rg -n --hidden -g "!node_modules" -g "!dist" -g "!build" -g "!.output" "(PASSWORD|SECRET|TOKEN|PRIVATE_KEY|DATABASE_URL|AWS_|GCP_|AZURE_|OPENAI_API_KEY|ANTHROPIC_API_KEY)" .再查容易被忽略的文件名:
Get-ChildItem -Force -Recurse -File |
Where-Object {
$_.Name -match '(^\.env|\.pem$|\.key$|id_rsa|id_ed25519|credentials|secret|dump|backup|\.sqlite$|\.db$)'
} |
Select-Object FullName这些文件默认不要进 build context:
| 文件 | 风险 |
|---|---|
.env / .env.production | 生产数据库、API key、JWT secret |
*.pem / *.key | TLS 私钥、SSH 私钥 |
.npmrc / .yarnrc.yml | 私有 registry token |
.aws/ / .config/gcloud/ | 云厂商凭据 |
db.dump / *.sqlite / *.db | 真实业务数据 |
logs/ / coverage/ / tmp/ | 请求、token、用户样本 |
.git/ | 历史提交和可能被删掉的旧 secret |
不要只看 Dockerfile 有没有 COPY . .。只要文件在 context 里,后续 Dockerfile、脚本、构建工具或误配置就可能把它带进镜像。
把 .dockerignore 当成第一道边界
.dockerignore 不是为了让构建快一点而已。它是防止本地敏感文件进入 builder 的第一道边界。Docker 文档说明,构建客户端会在发送 build context 前按 .dockerignore 排除匹配文件。
一个基础版本可以这样写:
# secrets
.env
.env.*
*.pem
*.key
id_rsa
id_ed25519
secrets/
credentials/
# local and VCS
.git
.svn
.hg
.DS_Store
# dependencies and build output
node_modules/
dist/
build/
.output/
.next/
coverage/
.cache/
# data and logs
*.log
logs/
tmp/
*.sqlite
*.db
*.dump
backup/如果项目里有多个 Dockerfile,Docker 还支持 Dockerfile-specific ignore 文件,例如 build.Dockerfile.dockerignore。这适合把测试镜像、lint 镜像、生产镜像分开处理:测试镜像可以包含测试夹具,生产镜像不应该包含。
注意 .dockerignore 不是 Git ignore。.gitignore 里排除的文件,仍然可能被 Docker build context 发送出去。两份文件要分别维护。
不要用 ARG / ENV 传密钥
很多 Dockerfile 会这样写:
ARG NPM_TOKEN
ENV DATABASE_URL="postgres://user:pass@example/db"
RUN npm ci这很危险。Docker 的 Build secrets 文档明确说,build arguments 和 environment variables 不适合传 build secret,因为它们会持久化在最终镜像里。Docker 的 SecretsUsedInArgOrEnv build check 也把“在 ARG 或 ENV 里使用敏感数据”标成问题。
如果构建时必须访问私有 npm、GitHub Packages、私有 PyPI 或云对象存储,用 BuildKit secret mount:
# syntax=docker/dockerfile:1
FROM node:24-alpine AS deps
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN --mount=type=secret,id=npmrc,target=/root/.npmrc pnpm install --frozen-lockfile构建时传入 secret:
docker buildx build --secret id=npmrc,src=$HOME\.npmrc -t my-app:local .如果需要拉私有 Git 仓库,用 SSH mount,不要把私钥复制进镜像:
# syntax=docker/dockerfile:1
FROM alpine/git AS source
RUN --mount=type=ssh git clone git@github.com:org/private-repo.git /src构建时传 SSH agent:
docker buildx build --ssh default -t my-app:local .成功标准不是“构建能拉到私有包”,而是构建完成后镜像里没有 .npmrc、私钥、token 和数据库连接串。
检查镜像历史里有没有明文
镜像层和历史记录会保留很多构建痕迹。即使你在后续层里 rm .env,前面的层也可能已经包含过它。
构建后先看历史:
docker history --no-trunc my-app:local重点查这些内容:
| 历史内容 | 处理方式 |
|---|---|
ARG NPM_TOKEN | 改成 BuildKit secret mount |
ENV DATABASE_URL=... | 改成运行时环境变量或 secret manager |
RUN echo token=... | 立即轮换 token,重建镜像 |
COPY . . | 缩小 COPY 范围,补 .dockerignore |
RUN rm .env | 不算修复,secret 可能在前一层 |
再导出镜像做一次字符串扫描:
docker save my-app:local -o image.tar
tar -tf image.tar | Select-String -Pattern "layer.tar|json"如果本机有 rg,可以对导出的 tar 做粗查:
rg -a -n "(DATABASE_URL|PRIVATE_KEY|BEGIN RSA PRIVATE KEY|BEGIN OPENSSH PRIVATE KEY|NPM_TOKEN|AWS_SECRET_ACCESS_KEY|OPENAI_API_KEY|ANTHROPIC_API_KEY)" image.tar这不是完整安全扫描,但能快速抓到最明显的泄露。命中任何真实 secret,都按泄露处理:撤销、轮换、重建、删除旧镜像 tag,并检查 registry 和 CI 缓存。
多阶段构建只拷贝运行时需要的文件
多阶段构建的价值不是“Dockerfile 看起来高级”,而是把编译工具、依赖缓存、测试文件、源码和构建 secret 留在 build stage。Docker 官方文档说明,多阶段构建可以从一个 stage 选择性复制 artifact 到另一个 stage,留下最终镜像不需要的内容。
更安全的结构:
# syntax=docker/dockerfile:1
FROM node:24-alpine AS deps
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN corepack enable
RUN --mount=type=secret,id=npmrc,target=/root/.npmrc pnpm install --frozen-lockfile
FROM deps AS build
WORKDIR /app
COPY src ./src
COPY public ./public
COPY package.json tsconfig.json vite.config.ts ./
RUN pnpm build
FROM nginx:1.29-alpine AS runtime
COPY --from=build /app/dist /usr/share/nginx/html不要在 runtime stage 里复制整个 build 目录:
FROM nginx:1.29-alpine
COPY --from=build /app /usr/share/nginx/html后一种写法可能把源码、测试、.npmrc、缓存、临时文件一起带进最终镜像。
后端应用也一样。最终镜像只需要运行产物、生产依赖和必要配置,不需要:
- 源码里的测试目录。
- 包管理器缓存。
.git。- 构建时
.npmrc。 - 本地
.env。 - 数据库 dump。
- 调试脚本和内部文档。
构建缓存不要接触长期凭据
构建缓存是提速工具,不是 secret 存储。Docker build cache 会复用之前构建产生的层;如果某一层下载私有依赖时接触过 secret,后续缓存、远程 builder、CI cache 和 registry cache 都可能让排查变复杂。
安全做法:
| 场景 | 建议 |
|---|---|
| 私有依赖下载 | 用 secret mount,不用 ARG |
| SSH 拉代码 | 用 SSH mount,不复制私钥 |
| CI 远程缓存 | 不缓存接触过 secret 的目录 |
| 发现泄露后重建 | 用 --no-cache,必要时换 builder |
| 基础镜像更新 | 用 --pull 获取新基础镜像 |
清理本地 builder cache:
docker builder prune如果泄露发生在 CI 或远程 builder,不要只清理本机。要检查:
- GitHub Actions / GitLab CI cache。
- registry cache tag。
- buildx remote builder。
- Docker Build Cloud 或企业远程构建环境。
- 已推送的旧 image tag。
只要 secret 可能进入过远程缓存,就按远程泄露处理。
不要把运行时配置烤进镜像
镜像应该尽量是可复用制品。数据库地址、JWT secret、第三方 API key、对象存储 key、OAuth client secret 都应该在运行时注入,而不是写进 Dockerfile。
不要这样:
ENV DATABASE_URL="postgres://prod-user:prod-pass@db.example.com/app"
ENV JWT_SECRET="change-me-in-prod"更稳的做法是:
ENV NODE_ENV=production
CMD ["node", "server.js"]运行时再由部署平台、Kubernetes Secret、Docker secret、云厂商 Secret Manager 或 CI/CD 的受控变量注入:
docker run --env-file .env.runtime my-app:local.env.runtime 不要提交到仓库,也不要复制进镜像。它是运行时输入,不是镜像内容。
推送前跑一张镜像检查清单
准备推送镜像前,至少做这些检查。
| 检查项 | 通过标准 |
|---|---|
.dockerignore | 已排除 .env、私钥、日志、dump、.git |
| Dockerfile | 没有用 ARG / ENV 传 secret |
| BuildKit | 私有包和私有仓库使用 secret / SSH mount |
COPY | 不使用无边界的 COPY . . 进入 runtime stage |
| 多阶段构建 | runtime 只包含运行产物和必要依赖 |
| 镜像历史 | docker history --no-trunc 没有明文凭据 |
| 镜像导出 | docker save 粗查没有 key、token、连接串 |
| CI 日志 | 构建命令和报错没有打印 secret |
| 缓存 | 接触过 secret 的缓存不会上传到远程 |
| 旧 tag | 发现泄露后旧镜像已撤回或禁用 |
可以把这段放进 PR 模板或发布清单:
Docker image release check:
- [ ] .dockerignore excludes secrets, logs, dumps, .git and local env files
- [ ] Dockerfile does not use ARG / ENV for tokens or passwords
- [ ] Private package install uses BuildKit secret mount
- [ ] Private Git access uses SSH mount
- [ ] Runtime stage copies only required artifacts
- [ ] docker history --no-trunc was checked
- [ ] image archive was scanned for obvious secrets
- [ ] CI logs do not print secrets
- [ ] If any secret was exposed, it has been rotated已经把 secret 打进镜像怎么办
如果镜像已经推送,先不要只改 Dockerfile。按泄露处理。
- 立即撤销或轮换相关 secret。
- 从 registry 删除或禁用受影响 image tag。
- 清理 CI cache、registry cache、远程 builder cache。
- 用新 secret 和干净 builder 重新构建镜像。
- 检查部署环境是否仍在拉旧 tag。
- 查 registry pull 记录、CI 日志和访问日志。
- 把
.dockerignore、BuildKit secret mount、多阶段构建规则补进仓库。
如果泄露的是生产数据库、云厂商 root key、GitHub PAT、npm token 或 SSH 私钥,不要只轮换应用配置。还要检查是否有人用它访问过资源。
Docker 镜像是部署制品,也是可解包、可缓存、可复制的文件。把 .env、构建缓存、SSH key、npm token 和生产配置放进去,风险不会随着容器停止而消失。
上线前把镜像当成公开制品来检查:build context 先收窄,secret 用 mount,runtime stage 只放运行产物,推送前看历史和导出内容。做到这些,Docker 才是交付工具,而不是密钥打包工具。