03-镜像构建与 Dockerfile
前两篇已经完成了 Docker 的概念铺垫和运行层操作:知道镜像、容器、仓库之间的关系,也能在 Ubuntu 主机上安装 Docker Engine,并用 docker run 启动、查看、停止和清理容器。从这一篇开始,重点从“运行别人发布的镜像”转向“把自己的应用做成可交付的镜像”。

1. 概述
前两篇已经完成了 Docker 的概念铺垫和运行层操作:知道镜像、容器、仓库之间的关系,也能在 Ubuntu 主机上安装 Docker Engine,并用 docker run 启动、查看、停止和清理容器。从这一篇开始,重点从“运行别人发布的镜像”转向“把自己的应用做成可交付的镜像”。
Dockerfile 是容器化交付的核心文件。它不是简单的安装脚本,而是一份描述镜像文件系统、运行用户、启动命令、构建上下文和默认配置的声明式构建说明。写得好的 Dockerfile 可以带来稳定的构建缓存、更小的镜像体积、更少的安全暴露面和更清晰的发布流程;写得差的 Dockerfile 则会造成镜像臃肿、缓存频繁失效、密钥泄露、构建缓慢、生产环境难以复现。
本文的目标是建立一套企业内部可以直接复用的 Dockerfile 编写方法:
- 理解 Dockerfile 指令、构建上下文、镜像层和缓存失效的关系。
- 为一个最小 Python Web 应用编写可构建、可运行、可验证的 Dockerfile。
- 使用
.dockerignore控制构建上下文,避免把无关文件打进镜像。 - 使用多阶段构建把构建依赖和运行时依赖分离。
- 使用 BuildKit 的缓存和 secret 能力优化构建速度与安全性。
- 建立镜像构建、标记、扫描、运行验证和清理的基本工作流。
本文命令以 Docker Engine + Buildx 插件环境为基线。具体版本不写死,以实际 docker version、docker buildx version 输出为准。
2. 理论基础
2.1 Dockerfile 到镜像的构建链路
一次 docker build 可以简化为下面几个阶段:
这里最容易被忽略的是“构建上下文”。执行下面命令时,最后的 . 不是装饰,而是告诉 Docker 把当前目录作为 build context 发送给 builder:
docker build -t myapp:dev .
Dockerfile 中的 COPY 和 ADD 默认只能访问构建上下文里的文件。如果上下文目录过大,或者里面包含 .git、日志、测试报告、缓存、私钥和生产配置,构建会变慢,也会增加泄露风险。因此 .dockerignore 是 Dockerfile 的配套文件,不是可有可无的优化项。
2.2 镜像层与缓存失效
Dockerfile 中多数指令都会形成镜像层或影响镜像元数据。BuildKit 会尽量复用已有缓存,但只要某一层的输入发生变化,这一层以及后续依赖它的层就需要重新执行。
一个常见的低效写法是:
FROM python:3.12-slim
WORKDIR /app
COPY . .
RUN pip install -r requirements.txt
CMD ["python", "app.py"]
只要业务代码任意文件变化,COPY . . 这一层就会失效,后面的 pip install 也会重新执行。更合理的写法是先复制依赖清单,再安装依赖,最后复制业务代码:
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
CMD ["python", "app.py"]
这样修改 app.py 时,依赖安装层仍然可以复用缓存。Dockerfile 的顺序不是审美问题,而是构建速度和可复现性的关键。
2.3 常用 Dockerfile 指令
| 指令 | 作用 | 实战建议 |
|---|---|---|
FROM |
指定基础镜像,开始一个构建阶段 | 生产镜像尽量固定主版本或小版本,不要长期依赖 latest |
WORKDIR |
设置后续命令工作目录 | 优先使用,不要反复 cd |
COPY |
从上下文或构建阶段复制文件 | 复制本地文件优先用 COPY |
ADD |
复制文件,额外支持 URL 和自动解压本地 tar | 只有明确需要这些语义时再用 |
RUN |
构建期间执行命令 | 合并相关包安装和清理步骤,避免留下缓存 |
ENV |
设置镜像或容器环境变量 | 适合非敏感默认值,不要放密码 |
ARG |
设置构建参数 | 只在构建期间使用,仍不适合传密钥 |
EXPOSE |
声明容器服务端口 | 只是元数据,不会自动发布端口 |
USER |
设置运行用户 | 生产镜像尽量不用 root 运行主进程 |
ENTRYPOINT |
定义固定入口程序 | 适合封装命令型镜像 |
CMD |
定义默认参数或默认启动命令 | 可被 docker run 后面的命令覆盖 |
HEALTHCHECK |
定义容器健康检查 | 单机 Docker 可用,编排平台也可能有自己的健康检查机制 |
LABEL |
写入镜像元数据 | 可记录源码地址、维护者、构建时间、版本等 |
RUN、CMD、ENTRYPOINT 都能写 shell form 或 exec form。生产镜像的启动命令优先使用 exec form,例如:
CMD ["python", "app.py"]
这样主进程能正确接收信号,容器停止时更容易优雅退出。
2.4 基础镜像选择
基础镜像决定了镜像大小、包管理器、系统库、默认用户、安全维护和排障体验。
| 类型 | 示例 | 优点 | 注意事项 |
|---|---|---|---|
| 完整发行版 | ubuntu:24.04、debian:bookworm |
工具完整,排障方便 | 体积较大,攻击面更大 |
| slim 镜像 | python:3.12-slim、node:22-slim |
体积和兼容性较平衡 | 可能缺少编译依赖,需要显式安装 |
| Alpine | nginx:alpine、alpine:3 |
很小,适合简单服务 | 使用 musl libc,部分依赖兼容性要验证 |
| distroless | gcr.io/distroless/* |
攻击面小,适合生产运行 | 缺少 shell,排障方式不同 |
| scratch | scratch |
空基础镜像,极小 | 只适合静态二进制等场景 |
企业内部建议为每种技术栈维护标准基础镜像,例如 python-runtime、node-runtime、java-runtime,把 CA 证书、时区、非 root 用户、基础安全配置和内网源配置统一沉淀进去。业务团队不要各自从互联网随意复制基础镜像。
3. 环境规划
本文用一个最小 Python Flask 应用演示镜像构建流程。验证环境假设如下:
| 项目 | 说明 |
|---|---|
| 操作系统 | Ubuntu 24.04 LTS 或 Ubuntu 22.04 LTS |
| Docker 组件 | Docker Engine、Docker CLI、Buildx 插件 |
| 应用语言 | Python 3.12 |
| 服务端口 | 容器内 8000,宿主机示例映射到 18080 |
| 构建方式 | docker build 与 docker buildx build |
| 网络要求 | 能访问 Docker Hub 或企业镜像代理 |
创建实验目录:
mkdir -p ~/dockerfile-lab
cd ~/dockerfile-lab
准备三个文件:
cat > requirements.txt <<'EOF'
flask==3.0.3
gunicorn==22.0.0
EOF
cat > app.py <<'EOF'
from flask import Flask, jsonify
app = Flask(__name__)
@app.get("/")
def index():
return jsonify(status="ok", service="dockerfile-lab")
@app.get("/health")
def health():
return jsonify(status="healthy")
EOF
cat > .dockerignore <<'EOF'
.git
.venv
__pycache__
*.pyc
*.log
.env
dist
build
node_modules
coverage
.pytest_cache
EOF
.dockerignore 中特意加入 .env,因为环境变量文件经常包含数据库密码、Token、API Key。构建镜像时不应该把这类敏感文件复制进镜像。
4. 实战操作
这个优化视图把后续实战中的优化动作归纳为四类目标:减少镜像体积、提高缓存命中、降低运行权限、避免密钥进入镜像层。写 Dockerfile 时不要只追求“能 build 成功”,还要考虑产物是否可复现、可审计、可维护。
4.1 编写第一个可运行 Dockerfile
创建 Dockerfile:
cat > Dockerfile <<'EOF'
# syntax=docker/dockerfile:1
FROM python:3.12-slim
WORKDIR /app
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app.py .
EXPOSE 8000
CMD ["gunicorn", "--bind", "0.0.0.0:8000", "app:app"]
EOF
构建镜像:
docker build -t dockerfile-lab:dev .
运行验证:
docker run --rm -d \
--name dockerfile-lab \
-p 18080:8000 \
dockerfile-lab:dev
curl http://127.0.0.1:18080/
curl http://127.0.0.1:18080/health
docker logs dockerfile-lab
docker rm -f dockerfile-lab
如果 curl 返回 JSON,说明镜像构建、容器启动、端口监听和应用路由都正常。不要只看 docker ps,容器在运行并不代表应用逻辑可用。
4.2 查看镜像层和元数据
查看本地镜像:
docker images dockerfile-lab
查看镜像构建历史:
docker history dockerfile-lab:dev
查看镜像配置:
docker inspect dockerfile-lab:dev
关注几个关键字段:
Config.Cmd:默认启动命令。Config.Env:镜像内默认环境变量。Config.WorkingDir:容器启动后的工作目录。Config.ExposedPorts:Dockerfile 中声明的端口。RootFS.Layers:镜像层摘要。
EXPOSE 8000 只是声明端口,不等于发布端口。真正把容器端口映射到宿主机的是 docker run -p 18080:8000。
4.3 给镜像增加标签
镜像标签应该服务于发布和回滚,而不是只用 latest。
docker tag dockerfile-lab:dev dockerfile-lab:2026-05-20
docker tag dockerfile-lab:dev dockerfile-lab:git-$(git rev-parse --short HEAD 2>/dev/null || echo local)
docker images dockerfile-lab
企业 CI/CD 中常见标签策略:
| 标签 | 用途 |
|---|---|
dev |
本地或测试构建 |
staging-<build_id> |
测试环境发布 |
prod-<date>-<build_id> |
生产发布 |
<git_sha> |
精确追溯源码版本 |
latest |
可以保留,但不要作为生产唯一依据 |
生产回滚依赖明确标签或 digest。只记录 latest 会让“当时到底发布了哪个镜像”变得不可追溯。
4.4 优化 apt 和系统依赖安装
如果应用需要系统包,apt-get update 和 apt-get install 应放在同一个 RUN 指令里,并清理 apt 列表:
RUN apt-get update \
&& apt-get install -y --no-install-recommends curl ca-certificates \
&& rm -rf /var/lib/apt/lists/*
不要写成:
RUN apt-get update
RUN apt-get install -y curl
分开写可能导致缓存复用旧索引,也会让镜像层留下更多无用内容。安装系统包时尽量加 --no-install-recommends,避免把不需要的推荐包一并装进镜像。
4.5 使用非 root 用户运行应用
默认情况下,很多基础镜像内的进程以 root 启动。生产镜像建议创建专用用户:
# syntax=docker/dockerfile:1
FROM python:3.12-slim
WORKDIR /app
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1
RUN groupadd --system app && useradd --system --gid app --home /app app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app.py .
RUN chown -R app:app /app
USER app
EXPOSE 8000
CMD ["gunicorn", "--bind", "0.0.0.0:8000", "app:app"]
重新构建并验证运行用户:
docker build -t dockerfile-lab:nonroot .
docker run --rm dockerfile-lab:nonroot id
如果镜像启动命令被覆盖为 id 后显示的是普通用户 UID/GID,就说明 USER app 生效。注意:非 root 用户可能没有权限写入某些目录,因此日志、缓存、上传文件目录要提前规划权限,不能等生产容器启动失败后再临时修。
4.6 使用多阶段构建
多阶段构建适合把构建依赖留在 builder 阶段,只把运行所需产物复制到最终镜像。下面以 Python wheelhouse 为例:
# syntax=docker/dockerfile:1
FROM python:3.12-slim AS builder
WORKDIR /build
COPY requirements.txt .
RUN pip wheel --no-cache-dir --wheel-dir /wheels -r requirements.txt
FROM python:3.12-slim AS runtime
WORKDIR /app
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1
RUN groupadd --system app && useradd --system --gid app --home /app app
COPY --from=builder /wheels /wheels
RUN pip install --no-cache-dir /wheels/* \
&& rm -rf /wheels
COPY app.py .
RUN chown -R app:app /app
USER app
EXPOSE 8000
CMD ["gunicorn", "--bind", "0.0.0.0:8000", "app:app"]
构建指定阶段可以用于排障:
docker build --target builder -t dockerfile-lab:builder .
docker build --target runtime -t dockerfile-lab:runtime .
多阶段构建的价值不只是减小镜像体积,更重要的是把“编译环境”和“运行环境”隔离开。生产镜像中没有编译器、源码生成缓存和测试工具,攻击面更小,也更符合最小化原则。
4.7 使用 BuildKit 缓存挂载
BuildKit 支持在 RUN 中使用缓存挂载,让依赖下载缓存不进入最终镜像层,但能在后续构建复用。例如 pip 缓存:
# syntax=docker/dockerfile:1
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN --mount=type=cache,target=/root/.cache/pip \
pip install -r requirements.txt
COPY app.py .
CMD ["gunicorn", "--bind", "0.0.0.0:8000", "app:app"]
构建:
docker build -t dockerfile-lab:cache .
缓存挂载适合依赖下载频繁、CI 构建时间敏感的场景。它不同于把缓存目录保留在镜像里:缓存供 builder 使用,不应该成为运行镜像的一部分。
4.8 安全传递构建密钥
不要用 ARG TOKEN=... 或 ENV TOKEN=... 把密钥传入镜像构建。构建参数和环境变量很容易进入构建历史、镜像元数据或日志。BuildKit 提供 secret mount:
printf '%s' "$PIP_TOKEN" > /tmp/pip_token
docker build \
--secret id=pip_token,src=/tmp/pip_token \
-t dockerfile-lab:secret .
rm -f /tmp/pip_token
Dockerfile 中使用:
RUN --mount=type=secret,id=pip_token \
PIP_TOKEN="$(cat /run/secrets/pip_token)" \
&& pip install --extra-index-url "https://token:${PIP_TOKEN}@pypi.example.com/simple" -r requirements.txt
这类写法的重点是:密钥只在该 RUN 步骤中以临时文件形式可见,不会作为镜像层内容保存。实际企业环境中,更推荐通过 CI/CD 的 secret store、私有包仓库和短期令牌配合使用。
5. 配置详解
5.1 COPY 与 ADD 的选择
优先使用 COPY,因为语义单一、可预测。ADD 只有在需要自动解压本地 tar 包,或明确使用 Docker 支持的额外能力时再考虑。
COPY app.py /app/app.py
COPY requirements.txt /app/requirements.txt
不建议用远程 URL ADD 直接拉文件作为生产依赖来源。更稳妥的做法是在构建前通过包管理器、制品仓库或 CI 步骤准备好依赖,并校验版本与摘要。
5.2 CMD 与 ENTRYPOINT
普通 Web 应用通常用 CMD:
CMD ["gunicorn", "--bind", "0.0.0.0:8000", "app:app"]
命令型工具镜像通常用 ENTRYPOINT 固定入口,再用 CMD 提供默认参数:
ENTRYPOINT ["python", "-m", "mytool"]
CMD ["--help"]
运行时可以覆盖默认参数:
docker run --rm mytool:latest scan /data
如果入口脚本需要处理信号、生成配置或等待依赖,脚本里必须正确使用 exec "$@",避免 shell 成为无法转发信号的中间进程。
5.3 ARG 与 ENV
ARG 用在构建期间:
ARG APP_VERSION=dev
LABEL org.opencontainers.image.version=$APP_VERSION
构建时传入:
docker build --build-arg APP_VERSION=1.2.3 -t myapp:1.2.3 .
ENV 会进入镜像运行环境:
ENV APP_ENV=production
两者都不适合保存敏感信息。密码、Token、证书私钥应该通过运行时 secret、挂载文件、Kubernetes Secret、Vault 或 CI/CD secret 机制管理。
5.4 HEALTHCHECK 的取舍
可以在镜像内定义健康检查:
HEALTHCHECK --interval=30s --timeout=3s --retries=3 \
CMD python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/health', timeout=2)"
验证:
docker run -d --name dockerfile-lab -p 18080:8000 dockerfile-lab:runtime
docker inspect --format '{{json .State.Health}}' dockerfile-lab
docker rm -f dockerfile-lab
是否把 HEALTHCHECK 写进 Dockerfile,要看平台策略。如果运行在 Kubernetes,通常会在 Deployment 中配置 livenessProbe/readinessProbe;如果大量使用单机 Docker 或 Compose,把基础健康检查写进镜像也有价值。不要让镜像健康检查依赖外部数据库、第三方 API 或复杂业务链路,否则会制造误报。
6. 验证与测试
6.1 构建验证清单
每次提交 Dockerfile 后,至少执行下面的验证:
docker build -t dockerfile-lab:test .
docker run --rm -d --name dockerfile-lab-test -p 18080:8000 dockerfile-lab:test
curl -fsS http://127.0.0.1:18080/health
docker logs --tail=50 dockerfile-lab-test
docker rm -f dockerfile-lab-test
如果使用非 root 用户,再验证:
docker run --rm --entrypoint id dockerfile-lab:test
如果镜像包含命令行工具,再验证帮助信息和退出码:
docker run --rm mytool:latest --help
echo $?
6.2 镜像内容检查
检查镜像大小:
docker images dockerfile-lab
检查是否把敏感文件打入镜像:
docker run --rm --entrypoint sh dockerfile-lab:test -c 'find /app -maxdepth 3 -type f | sort'
如果镜像没有 shell,可以用临时调试镜像或在 CI 中对构建上下文进行检查。不要为了排障方便,在生产运行镜像里长期保留调试工具。
6.3 多平台构建验证
如果需要同时支持 x86_64 和 ARM64,可以使用 Buildx:
docker buildx ls
docker buildx build \
--platform linux/amd64,linux/arm64 \
-t registry.example.com/team/dockerfile-lab:1.0.0 \
--push .
单机 Docker Engine 环境中,多平台构建可能需要额外 builder、QEMU 或原生多架构节点。跨架构构建不要只看构建是否成功,还要在目标架构上做运行验证,尤其是包含系统库、C 扩展、GPU、网络工具或专有二进制依赖的镜像。
6.4 镜像扫描和元数据
建议为生产镜像补充 OCI Label:
ARG APP_VERSION=dev
ARG VCS_REF=unknown
LABEL org.opencontainers.image.title="dockerfile-lab" \
org.opencontainers.image.version=$APP_VERSION \
org.opencontainers.image.revision=$VCS_REF \
org.opencontainers.image.source="https://git.example.com/team/dockerfile-lab"
构建:
docker build \
--build-arg APP_VERSION=1.0.0 \
--build-arg VCS_REF="$(git rev-parse --short HEAD 2>/dev/null || echo local)" \
-t dockerfile-lab:1.0.0 .
镜像扫描可以使用企业已有工具,例如 Trivy、Grype、Docker Scout 或云厂商镜像仓库扫描能力。扫描结果要进入发布门禁,但不要机械地追求零漏洞。更合理的做法是结合基础镜像维护状态、漏洞可利用路径、运行用户、暴露端口和补丁可用性制定处理策略。
7. 故障排查
这张清单按失败类型给出第一证据入口:路径问题先看构建上下文,性能问题先看缓存顺序,安全问题先看镜像层和密钥处理,CI 问题则要对比本地和流水线环境差异。
7.1 COPY failed: file not found
常见原因:
- 文件不在 build context 中。
.dockerignore把文件排除了。- 执行
docker build的目录不对。 - Dockerfile 路径和上下文路径混淆。
排查:
pwd
ls -lah
cat .dockerignore
docker build -f Dockerfile -t dockerfile-lab:test .
如果 Dockerfile 不在当前目录,可以显式指定:
docker build -f docker/Dockerfile -t myapp:test .
注意最后的 . 仍然是上下文根目录。
7.2 构建每次都很慢
重点检查缓存顺序:
- 是否先
COPY . .再安装依赖。 - 依赖锁文件是否频繁变化。
- 是否把
.git、日志、构建产物放进上下文。 - 是否每次都使用
--no-cache。 - CI builder 是否没有持久缓存。
可以用普通构建对比:
docker build -t dockerfile-lab:test .
docker build -t dockerfile-lab:test .
第二次构建如果仍然大量重新执行,说明 Dockerfile 层顺序或上下文控制需要优化。
7.3 镜像体积过大
常见原因:
- 使用了过大的基础镜像。
- 编译工具留在最终镜像。
- 包管理器缓存没有清理。
- 把测试数据、源码历史、日志、依赖缓存复制进镜像。
- 没有使用多阶段构建。
排查:
docker images myapp
docker history myapp:latest
优化方向:
- 使用 slim 或企业标准 runtime 镜像。
- 用多阶段构建移除 builder 依赖。
- 补充
.dockerignore。 - 合并包安装和清理命令。
- 删除运行不需要的临时文件。
7.4 容器启动后立刻退出
先看退出状态和日志:
docker ps -a
docker logs <container>
docker inspect --format 'ExitCode={{.State.ExitCode}} Error={{.State.Error}}' <container>
常见原因:
CMD路径错误。- 工作目录不对。
- 依赖没有安装进最终阶段。
- 应用监听了
127.0.0.1而不是0.0.0.0。 - 非 root 用户没有文件写权限。
- 入口脚本没有执行权限或 shebang 错误。
调试时可以覆盖入口:
docker run --rm -it --entrypoint sh myapp:latest
如果镜像没有 shell,需要在 builder 阶段或调试版本中排查,不建议为了生产排障长期保留 shell。
7.5 构建时密钥泄露
危险写法:
ARG TOKEN
RUN git clone https://token:${TOKEN}@git.example.com/private/repo.git
风险:
- 构建日志可能出现密钥。
- 镜像历史可能留下命令片段。
- 中间层或缓存可能暴露敏感信息。
修复方向:
- 使用 BuildKit
--secret。 - 在 CI/CD 平台使用短期令牌。
- 将私有依赖发布到内网制品仓库。
- 对已推送镜像执行撤回、轮换密钥和访问日志审计。
7.6 本地能跑,CI 构建失败
常见差异:
- 本地存在未提交文件,CI 上没有。
- 本地 Docker 缓存掩盖了依赖问题。
- CI 没有访问 Docker Hub 或私有仓库权限。
- CI 的 CPU 架构与本地不同。
- CI 没有开启 BuildKit 或 builder 配置不同。
排查时先做干净构建:
git status --short
docker build --no-cache -t dockerfile-lab:clean .
再把 CI 中的构建命令、上下文路径、环境变量、secret 注入方式和 registry 权限逐项对齐。
8. 运维建议
8.1 把 Dockerfile 纳入代码评审
Dockerfile 直接决定运行环境和安全边界,应该像应用代码一样评审。评审重点:
- 基础镜像是否来自可信来源。
- 是否使用非 root 用户。
- 是否把密钥、
.env、证书私钥、源码历史打进镜像。 - 构建缓存顺序是否合理。
- 是否有多阶段构建或其他瘦身措施。
- 启动命令是否能正确接收信号。
- 端口、环境变量、健康检查是否清晰。
8.2 建立企业标准模板
不要让每个项目从零写 Dockerfile。可以按语言维护模板:
- Python API 服务模板。
- Node.js 前端构建 + Nginx 运行模板。
- Java Spring Boot 运行模板。
- Go 静态二进制 + distroless/scratch 模板。
- Nginx 静态站点模板。
模板不应该过度复杂,但要包含 .dockerignore、非 root 用户、依赖缓存、多阶段构建、标签元数据和验证命令。
8.3 固定构建产物,而不是固定构建机器
容器化的目标不是让某台机器变得特殊,而是让构建产物可复现、可分发、可回滚。建议:
- 每次发布记录镜像 tag 和 digest。
- CI 中使用一致的构建命令。
- 生产部署优先引用 digest 或不可变 tag。
- 镜像推送到企业 registry 后再部署。
- 保留关键版本的 SBOM、扫描报告和构建日志。
8.4 控制基础镜像更新节奏
基础镜像既不能几年不动,也不能每天无验证自动漂移。推荐做法:
- 企业维护标准基础镜像。
- 定期重建业务镜像,吸收基础镜像安全更新。
- 对关键服务先在测试环境运行冒烟测试。
- 发布前保留旧镜像,确保可回滚。
- 对不再维护的基础镜像建立替换计划。
8.5 明确构建与运行边界
Dockerfile 里不要塞进所有事情。构建阶段负责把应用和运行依赖做成镜像;运行配置由 Compose、Kubernetes、Nomad、systemd 或发布平台负责。下面这些内容一般不应该固化进镜像:
- 生产数据库密码。
- 生产 API Token。
- 环境专属域名和 IP。
- 临时调试脚本。
- 大量测试数据。
- 只能在某台机器上使用的本地路径。
镜像应该尽量环境无关,运行时通过环境变量、配置挂载、secret 和编排平台注入差异。
9. Dockerfile 评审清单
Dockerfile 是应用交付物的一部分,应该进入代码评审。评审重点不是格式好不好看,而是镜像是否可复现、可维护、可运行、可扫描、可回滚。
第一,看基础镜像。基础镜像是否来自可信来源,是否有明确版本,是否仍被维护,是否适合生产运行。FROM ubuntu:latest 或 FROM node:latest 对实验很方便,但生产不可追踪。更好的做法是固定到主版本或具体小版本,并建立基础镜像更新节奏。
第二,看依赖安装。包管理器缓存是否清理,依赖是否固定版本,是否把构建工具留在运行镜像里。对于 Go、Java、Node.js、Python 等项目,多阶段构建通常能把构建依赖和运行依赖分开,减少镜像体积和攻击面。
第三,看缓存顺序。频繁变化的业务代码不应该放在依赖安装之前,否则每次代码变更都会让依赖层缓存失效。常见做法是先复制依赖描述文件,例如 package.json、requirements.txt、go.mod、pom.xml,安装依赖后再复制业务代码。
第四,看运行用户。镜像默认 root 运行会扩大风险。能以非 root 用户运行的应用,应创建专用用户,并配合文件权限、只读文件系统、最小 capability 和运行时安全策略。
第五,看密钥和配置。不要在 Dockerfile 中写入 token、密码、证书私钥、私有仓库凭据或生产配置。即使后续删除文件,密钥也可能留在镜像历史层中。构建期需要访问私有依赖时,应使用 BuildKit secret、CI 凭据注入或私有包代理。
第六,看启动方式。CMD 和 ENTRYPOINT 要表达清楚默认启动行为。容器内进程应以前台方式运行,日志输出到 stdout/stderr,避免依赖 systemd 或后台守护进程模型。一个容器里跑多个强耦合进程时,要说明为什么不能拆分。
10. 镜像标签和版本策略
镜像标签决定发布、回滚和排障能否追踪。latest 不是版本,它只是一个可移动指针。生产发布至少应该保留不可变标签,例如应用版本、Git commit、构建号或日期。
一个实用策略是同时打三类标签:
| 标签 | 用途 | 示例 |
|---|---|---|
| 版本标签 | 对应产品版本 | app:1.4.2 |
| 提交标签 | 精确追踪源码 | app:git-a1b2c3d |
| 环境或渠道标签 | 表达发布渠道 | app:staging、app:prod |
真正部署时,建议使用不可变标签或 digest,而不是只使用 prod 这种可移动标签。可移动标签适合人类识别当前渠道,不适合作为唯一回滚依据。如果生产事故需要回滚,团队要能明确知道上一版镜像是什么、由哪个 commit 构建、什么时候推送、是否通过扫描和测试。
镜像仓库也要设置保留策略。构建系统长期推送镜像不清理,会让 registry 存储持续增长;清理太激进,又可能删除仍需回滚的版本。比较稳妥的方式是保留近期构建、保留正式发布版本、保留当前生产和上一生产版本,其他临时构建按时间清理。
11. 构建上下文和 .dockerignore
很多 Dockerfile 构建慢、镜像意外变大,不是指令本身问题,而是构建上下文太大。执行 docker build . 时,当前目录会作为 build context 发送给构建器。如果目录里包含 .git、日志、测试数据、node_modules、虚拟环境、临时文件或大二进制文件,构建会变慢,也可能把不该进入镜像的内容复制进去。
.dockerignore 应该像 .gitignore 一样认真维护。常见排除项包括:
.git
.env
*.log
node_modules
dist
build
.venv
__pycache__
coverage
tmp
不要盲目排除所有构建产物。有些项目需要先在 CI 中构建,再把产物复制进镜像;有些项目则应该在 Dockerfile 内部完成构建。关键是明确构建边界:哪些文件由宿主机准备,哪些文件由 Dockerfile 构建,哪些文件绝不能进入镜像。
12. 镜像安全扫描与 SBOM
镜像构建完成后,至少要做基础扫描。扫描能发现已知 CVE、过期系统包、危险配置、敏感文件和基础镜像风险。扫描结果不是绝对真理,但它能让团队知道当前镜像的风险面。
安全扫描要结合业务判断。比如某个系统包存在漏洞,但应用运行时并不使用该组件,风险可能可接受;另一个漏洞分数不高,但暴露在互联网入口,可能需要优先修复。不要只看漏洞数量,也不要把扫描当成形式。关键是有分级、例外、修复和复验流程。
SBOM 可以记录镜像中包含哪些包、库和版本。对于企业交付、供应链安全和合规审计,SBOM 越来越重要。即使当前团队不做完整 SBOM,也至少要保留镜像构建记录、基础镜像版本、依赖锁文件和扫描结果。
13. CI 中构建镜像的建议
生产镜像不应依赖个人电脑构建。CI 构建能保证构建环境稳定、过程可追踪、产物可复现。一个基础流水线可以包含以下步骤:拉取代码、安装依赖或准备缓存、运行测试、构建镜像、扫描镜像、打标签、推送仓库、生成构建记录。
CI 构建时要注意缓存和密钥。缓存能加快构建,但不能把私有依赖凭据写进镜像层。密钥应通过 CI secret 或 BuildKit secret 注入,构建结束后不能留在镜像历史中。多平台构建时,要确认目标架构,例如 amd64、arm64,避免在 ARM 开发机上构建出生产服务器无法运行的镜像。
CI 构建还要输出元数据。至少包括 Git commit、构建时间、镜像标签、基础镜像、构建流水线链接和扫描结果。后续出现事故时,团队可以从运行容器反查镜像,再从镜像反查源码和构建过程。
14. Dockerfile 与运行时配置的边界
一个常见错误是把环境差异写进 Dockerfile。比如在 Dockerfile 里写死生产数据库地址、生产 API 地址、日志路径、证书路径或业务开关。这会导致镜像只能用于一个环境,也让测试和生产镜像不一致。
更好的方式是:Dockerfile 只定义应用如何被打包和默认如何启动;环境差异通过 Compose、Kubernetes、systemd、CI/CD 或运维平台传入。配置文件可以只读挂载,敏感信息通过 secret 管理,数据通过卷或外部服务保存。
这个边界会影响后续所有环节。Dockerfile 越干净,镜像越容易在开发、测试、预生产和生产之间复用;运行时配置越明确,排障时越容易判断问题来自镜像、配置、网络、数据还是宿主机。
15. 上线前镜像验收表
| 类别 | 验收项 |
|---|---|
| 构建 | Dockerfile 可在 CI 中稳定构建 |
| 标签 | 镜像有不可变版本标签和源码追踪信息 |
| 体积 | 镜像体积符合预期,未包含无关构建缓存 |
| 权限 | 默认非 root 或有明确例外说明 |
| 密钥 | 镜像层和历史中不包含密钥 |
| 启动 | 容器前台启动,日志输出到 stdout/stderr |
| 健康 | 有健康检查或外部探测策略 |
| 扫描 | 已完成漏洞扫描并处理高风险项 |
| 回滚 | 上一版本镜像可用,数据兼容已确认 |
| 文档 | 运行参数、端口、卷、环境变量说明完整 |
这张表可以作为 Dockerfile 代码评审和发布门禁的基础。它不要求团队一次性做到平台级治理,但能避免最常见的镜像构建问题进入生产。
16. 三个典型 Dockerfile 反模式
第一个反模式是“能运行就行”的巨型镜像。Dockerfile 从完整发行版开始,安装编译器、调试工具、包管理缓存、测试文件和源码历史,最后直接运行应用。这样的镜像构建慢、传输慢、漏洞多,排查时也很难判断哪些文件真正被运行时需要。改进方式是使用多阶段构建,把构建依赖留在 builder 阶段,只把运行产物复制到运行镜像。
第二个反模式是把配置和密钥固化进镜像。比如在 Dockerfile 中复制 .env、写入数据库密码、复制生产证书、设置生产 API 地址。这样镜像一旦推送到仓库,泄露范围就变大,而且同一个镜像无法复用于测试和生产。改进方式是用环境变量、secret、只读配置挂载或编排平台注入运行时差异。
第三个反模式是没有版本和验证。构建出来的镜像只有 latest,没有源码 commit,没有构建记录,没有扫描结果,也没有运行验证。发布后如果出现问题,只能靠猜测回滚。改进方式是每次构建输出不可变标签、记录 digest、运行最小冒烟测试,并把构建日志和扫描结果关联到发布记录。
17. 镜像问题的排查路径
镜像问题通常表现为三类:构建失败、启动失败、运行后行为不符合预期。构建失败先看 build context、Dockerfile 指令、网络、包源和缓存;启动失败先看入口命令、文件权限、运行用户、环境变量和依赖服务;运行异常再看配置、端口、卷、网络和应用日志。
排查时不要马上修改 Dockerfile。先用 docker build --progress=plain 看完整构建输出,用 docker history 看镜像层,用 docker inspect 看默认命令、环境变量和工作目录,用 docker run --rm -it --entrypoint sh 进入镜像检查文件。确认问题发生在构建期还是运行期,再决定改 Dockerfile、改运行参数或改应用代码。
如果本地构建成功、CI 构建失败,要重点比较构建上下文、平台架构、网络代理、私有依赖权限、BuildKit 设置和基础镜像访问。不要把 CI 失败简单归因于“流水线不稳定”。镜像构建的目标就是让这些差异可见、可记录、可修复。
18. 从 Dockerfile 到发布标准
当团队有多个服务都开始容器化时,就需要把 Dockerfile 经验沉淀成发布标准。标准可以很简单:每个服务必须有 Dockerfile、.dockerignore、镜像标签规则、构建命令、运行示例、端口说明、卷说明、环境变量说明、健康检查和回滚说明。
标准不应该变成僵硬模板。不同语言栈有不同最佳实践,Python、Node.js、Java、Go、Nginx 静态站点的 Dockerfile 不应完全一样。但它们应该共享相同底线:不泄露密钥、不使用不可追踪标签、不以 root 作为默认习惯、不把数据写进镜像、不跳过运行验证。
这套标准会直接影响后续 Compose 和平台化。Compose 文件引用的是镜像,Kubernetes 部署的也是镜像。如果镜像本身质量差,后面的编排系统只会把问题放大。镜像构建是容器化交付链路的质量入口。
19. 一个镜像验收案例
假设团队要交付一个 Python API 服务。验收时不能只看 docker build 成功。首先要确认基础镜像版本,例如 python:3.12-slim 是否符合团队标准。其次检查 .dockerignore 是否排除了 .git、.env、测试缓存和本地虚拟环境。然后看 Dockerfile 是否先复制依赖文件、安装依赖,再复制业务代码,以保证缓存命中。
构建完成后,要运行容器并验证健康接口、日志输出、非 root 用户、端口暴露和环境变量注入。还要用 docker history 检查是否有密钥痕迹,用扫描工具检查高危漏洞,用镜像标签关联 Git commit。最后,把运行参数写入 Compose 或部署平台,而不是只保留一条手工 docker run。
这个案例说明,镜像验收是构建、运行、安全和发布四件事的组合。任何一项缺失,都可能在后续 Compose、Kubernetes 或生产部署阶段变成故障。
20. 学习 Dockerfile 的练习顺序
建议按三个练习推进。第一,用 Nginx 或 Python 写一个最小镜像,理解 FROM、COPY、CMD 和构建上下文。第二,把一个真实应用改成多阶段构建,比较优化前后的镜像体积、构建时间和运行用户。第三,把构建放进 CI,输出镜像标签、扫描结果和运行验证记录。
这三个练习分别对应“能构建”“构建得好”“构建可交付”。不要跳过第一步直接追求复杂模板,也不要停留在第一步长期使用粗糙镜像。Dockerfile 的质量会传递到所有运行环境,越早建立评审和验收习惯,后续容器平台越稳定。
如果团队只能先做一件事,就先把 Dockerfile 评审纳入合并流程。每次合并前检查基础镜像、构建上下文、密钥、运行用户、标签和启动命令。这个动作成本不高,但能挡住大量后续发布和安全问题。
镜像一旦进入仓库,就会被测试、发布和回滚流程反复使用;越早拦住镜像质量问题,后续环境越稳定。
这也是为什么 Dockerfile 应该和应用代码一起维护、一起评审、一起发布,而不是由运维在上线前临时补写。
镜像质量越稳定,后续发布链路越可控。
这也是镜像治理和发布审计的起点与长期基线。
21. 总结
本文完成了 Docker 容器化实战系列的第三步:从 Dockerfile 生成自己的应用镜像。核心结论如下:
- Dockerfile 是镜像交付规范,不是随手写的安装脚本。
- 构建上下文必须用
.dockerignore控制,避免泄露和低效构建。 - Dockerfile 指令顺序会影响缓存复用,依赖清单应先于业务代码复制。
- 生产镜像应优先使用非 root 用户、多阶段构建和明确标签。
- BuildKit 的 cache mount 和 secret mount 能同时改善构建速度与安全性。
- 镜像验证不能只看构建成功,还要运行容器、访问健康检查、查看日志和检查镜像内容。
下一篇会进入容器网络配置。到那一步,重点会从“构建单个应用镜像”转向“让容器接入宿主机、其他容器、局域网和外部服务”,包括自定义 bridge、端口发布、DNS、host 网络和网络排障。
参考资料
- Docker Docs:Dockerfile reference
- Docker Docs:Building best practices
- Docker Docs:Build context
- Docker Docs:Docker build cache
- Docker Docs:Multi-stage builds
- Docker Docs:Build secrets
- Docker Docs:Multi-platform builds
- [验证环境:Ubuntu 24.04 LTS / Ubuntu 22.04 LTS;命令依据 Docker 官方文档 2026-05-20 检索]