跳到主要内容

Dockerfile、BuildKit 与 Buildx

本篇围绕「怎么把应用打成镜像」展开:先用一个完整实例把常用指令串起来(用法都写在注释里),再讲多阶段构建、构建缓存,以及 BuildKit / Buildx 带来的新能力(secret mount、ssh mount、多架构构建)。日常查指令语法也可以看附录的《Dockerfile 指令速查》。

Dockerfile 基础​

Dockerfile 是一个纯文本文件,按顺序描述「从哪个基础镜像开始、执行哪些操作、容器启动时跑什么命令」。docker build 会逐条执行里面的指令,每执行一条就生成一个镜像层,全部执行完得到一个可运行的镜像。

先写一个最小但完整的例子:用 nginx 托管一个静态页面。

目录结构:

myweb/
├── Dockerfile
└── index.html

Dockerfile 内容:

FROM nginx:alpine

# 把本地的 index.html 复制进 nginx 的站点目录
COPY index.html /usr/share/nginx/html/index.html

# 声明容器监听 80 端口(仅作文档说明,不做实际端口映射)
EXPOSE 80

构建并运行:

# -t 给镜像起名字和标签,结尾的 . 表示构建上下文是当前目录
docker build -t myweb:1.0 .

# 启动容器,把宿主机 8080 映射到容器 80
docker run -d --name myweb -p 8080:80 myweb:1.0

# 验证
curl http://localhost:8080
<h1>Hello Docker</h1>
备注

构建上下文(build context)就是 docker build 最后那个参数指定的目录。Docker 会把整个目录发给构建器,COPY 的源路径只能取这个目录里的文件,取不到外面的。目录里不想被打包的东西(.git、node_modules、密钥文件)要写进 .dockerignore,后面「镜像优化」一节会讲。

一个实例看懂常用指令​

与其逐条背指令语法,不如直接看一个完整例子。下面是一个 Node.js Web 应用的 Dockerfile,12 个常用指令全部用到,每条指令的用途和注意点都写在注释里。

项目结构:

myapp/
├── Dockerfile
├── package.json
├── package-lock.json
├── config.tar.gz # 随仓库分发的配置模板压缩包
└── server.js

server.js 是一个最小的 HTTP 服务,带健康检查接口:

const http = require('node:http')

const server = http.createServer((req, res) => {
if (req.url === '/healthz') {
res.writeHead(200)
return res.end('ok')
}
res.writeHead(200, { 'Content-Type': 'text/plain' })
res.end(`myapp ${process.env.APP_VERSION || 'dev'}`)
})

server.listen(3000)

完整的 Dockerfile:

# syntax=docker/dockerfile:1

# FROM:指定基础镜像,Dockerfile 里的一切都从它开始叠加,必须是第一条指令(前面只允许 ARG 和注释)。
# 选 node:22-alpine:官方镜像、固定大版本、alpine 变体体积小(约 130 MB)。
# 生产环境不要用 latest 标签——它会随时间指向不同版本,构建结果不可复现。
FROM node:22-alpine

# LABEL:给镜像加元数据(标题、源码地址等键值对),不影响构建产物。
# org.opencontainers.image.* 是 OCI 标准前缀,GitHub Container Registry 等仓库能自动识别展示。
LABEL org.opencontainers.image.title="myapp" \
org.opencontainers.image.source="https://github.com/example/myapp"

# ARG:构建期变量,只在 docker build 时有效,构建结束就没了,容器运行时读不到。
# 构建时用 --build-arg APP_VERSION=1.0.0 传入,不传就用默认值 dev。
# 注意不要用 ARG 传密钥——值会留在构建历史里,docker history 直接可见。
ARG APP_VERSION=dev

# ENV:环境变量,构建期(后续的 RUN)和运行期(容器里的进程)都能读到,永久留在镜像里。
# 这里把 ARG 的值转存给 ENV,应用运行时也能拿到版本号。同理,密码密钥也不要放进 ENV。
ENV NODE_ENV=production \
APP_VERSION=${APP_VERSION}

# WORKDIR:设置后续指令(RUN/COPY/CMD 等)的工作目录,目录不存在会自动创建。
# 不要用 RUN cd /app 代替——cd 只在那一条 RUN 内部有效,下一条指令又回到 /。
WORKDIR /app

# COPY:把构建上下文里的文件复制进镜像,源路径相对于上下文,不能用 ../ 跑出去。
# 先只拷依赖清单:清单没变就能命中层缓存,不用重装依赖(原理见「Build Cache」一节)。
COPY package.json package-lock.json ./

# RUN:构建时执行命令,装好的软件、生成的文件固化成一个新的镜像层。
# 装依赖和清缓存写在同一条 RUN 里——分两条写的话缓存已固化在前一层,后面删了也白删。
RUN npm ci --omit=dev \
&& npm cache clean --force

# ADD:和 COPY 一样复制文件,但多两个能力——本地 tar 包自动解压、源可以是 URL。
# 这里把配置模板包解压到 /app/config。官方建议能用 COPY 就别用 ADD,
# 只有确实需要自动解压时才用 ADD。
ADD config.tar.gz /app/config/

# 再拷应用代码。复制进来的文件默认属主是 root,--chown 顺便改成 node 用户。
COPY --chown=node:node . .

# USER:之后的指令和容器进程都以 node 身份运行(node 官方镜像自带 node 用户,UID 1000)。
# 容器默认以 root 运行,应用一旦被攻破攻击者拿到的就是容器内 root,非 root 运行是最基本的安全基线。
USER node

# HEALTHCHECK:告诉 Docker 怎么判断应用是否健康,结果显示在 docker ps 的 STATUS 列。
# 退出码 0 健康、1 不健康;Node 18+ 内置 fetch,不依赖 curl/wget。
# --start-period 给启动慢的应用留宽限期,期间失败不计入失败次数。
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
CMD node -e "fetch('http://127.0.0.1:3000/healthz').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"

# ENTRYPOINT:容器启动时固定执行的入口程序,docker run 后跟的命令不会覆盖它,而是作为参数追加。
# 用 exec 表单(JSON 数组):node 直接成为 PID 1,能收到 docker stop 的 SIGTERM 优雅退出。
ENTRYPOINT ["node"]

# CMD:容器启动时默认执行的命令,docker run 后跟命令会把它整个覆盖。
# 和 ENTRYPOINT 组合时充当默认参数——默认执行 node server.js。
CMD ["server.js"]

构建并运行:

# --build-arg 传入构建期变量
docker build --build-arg APP_VERSION=1.0.0 -t myapp:1.0.0 .

docker run -d --name myapp -p 3000:3000 myapp:1.0.0

docker ps
CONTAINER ID   IMAGE          STATUS                   PORTS                    NAMES
a1b2c3d4e5f6 myapp:1.0.0 Up 2 minutes (healthy) 0.0.0.0:3000->3000/tcp myapp

docker run myapp:1.0.0 worker.js 会把 CMD 整个替换掉,实际执行 node worker.js——ENTRYPOINT 不变、CMD 被覆盖,这正是两者配合的用法。

注释里没有展开的几组易混淆点,补充辨析:

  • COPY vs ADD:能用 COPY 就别用 ADD,只有确实需要自动解压本地 tar 包时才用。从 URL 下载文件也别用 ADD——下载的文件会固化在层里删不掉,用 RUN curl -fsSL ... && 用完删掉,下载和清理在同一层完成。
  • CMD vs ENTRYPOINT:docker run 后跟的命令整个覆盖 CMD、作为参数追加给 ENTRYPOINT(想改入口要用 docker run --entrypoint);写多条 CMD 只有最后一条生效。
  • exec 表单 vs shell 表单:["node", "server.js"] 这种 exec 表单(JSON 数组),进程本身就是 PID 1,能收到 docker stop 的 SIGTERM;shell 表单(直接写命令)的 PID 1 是 sh,信号默认不转发,应用只能等 10 秒超时被强杀。必须用启动脚本时,脚本最后一行用 exec 启动主进程,让它顶替脚本成为 PID 1。
  • ENV vs ARG:ARG 只在构建期有效,ENV 构建期、运行期都在;运行期要用构建参数的值,就像实例里把 ARG 转存给 ENV。另外 ARG 值变化会让使用它的指令及之后的层缓存全部失效,频繁变化的 ARG 尽量往 Dockerfile 后面放。
  • FROM:基础镜像优先选 Docker Official Image(node、nginx 这类不带用户名前缀的),来源不明的第三方镜像可能藏着挖矿程序。
  • HEALTHCHECK:检查失败只是标记状态,Docker 本身不会自动重启 unhealthy 的容器;Kubernetes 不读它,K8s 用 Pod 里的 livenessProbe / readinessProbe,两者不冲突。

多阶段构建​

多阶段构建指在一个 Dockerfile 里写多个 FROM:前面的阶段负责编译构建,最后一个阶段只通过 COPY --from 把产物挑出来。镜像变小的原理:最终镜像只保留最后一个阶段产生的层,前面阶段的层(编译器、依赖缓存、中间产物)全部丢弃,编译工具链根本不出现在最终镜像里。

Go:编译 + distroless 运行——

# syntax=docker/dockerfile:1

# ---------- 构建阶段 ----------
FROM golang:1.23-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 .

# ---------- 运行阶段 ----------
FROM gcr.io/distroless/static-debian12
COPY --from=builder /app/server /server # 只把二进制挑出来
USER nonroot:nonroot
EXPOSE 8080
ENTRYPOINT ["/server"]

同一个 Go Web 程序,几种打包方式的体积对比(约数):

方案镜像体积
单阶段 golang:1.23900 MB+
单阶段 golang:1.23-alpine400 MB+
多阶段 + distroless static20 MB 左右
多阶段 + scratch10 MB 左右

scratch 里什么都没有——没有证书、没有时区数据、没有 /tmp,程序必须编译成完全静态链接的二进制(CGO_ENABLED=0)才能跑。distroless static 比 scratch 多了 CA 证书、时区、passwd 文件和一个 nonroot 用户,日常更省心,推荐优先用。

前端项目同理——Node 构建、nginx 托管,最终镜像里只有 nginx 和静态产物,node_modules、npm 缓存全部不进镜像:

# ---------- 构建阶段 ----------
FROM node:22-alpine AS build
WORKDIR /app

COPY package.json package-lock.json ./
RUN npm ci

COPY . .
RUN npm run build

# ---------- 运行阶段 ----------
FROM nginx:alpine
COPY --from=build /app/dist /usr/share/nginx/html
EXPOSE 80

常用技巧:

  • 阶段用 AS 命名后按名字引用(COPY --from=builder),比按序号引用(COPY --from=0)稳定,调整阶段顺序时不会错位。
  • docker build --target builder . 只构建到指定阶段为止,调试构建阶段的问题时很有用。
  • COPY --from 还可以引用外部镜像,从别的镜像里抠文件:COPY --from=nginx:alpine /etc/nginx/nginx.conf /nginx.conf。
  • 最终镜像里没有编译器、shell、包管理器,攻击面小了很多——体积和安全双赢。

Build Cache​

层缓存机制:每条指令对应一个镜像层,构建时 Docker 逐条检查——这条指令和它依赖的上一层有没有变过,没变就直接复用,变了就重新执行,而且从这条开始,后面所有层的缓存一起作废(链式失效)。FROM、RUN 等比较指令字符串;COPY、ADD 计算文件内容的校验和,内容变了缓存就失效,只改文件名不行。

所以指令顺序决定构建速度。反面教材:

FROM node:22-alpine
WORKDIR /app
COPY . . # 任何一行代码改动,这层缓存就失效
RUN npm ci # 于是依赖每次都要重新安装,非常慢
CMD ["node", "server.js"]

正确写法——把不常变的「装依赖」放前面,常变的「拷代码」放后面:

FROM node:22-alpine
WORKDIR /app
COPY package.json package-lock.json ./ # 只有依赖清单变了才触发重装
RUN npm ci
COPY . . # 日常改代码只影响这一层
CMD ["node", "server.js"]
# 完全不用缓存,从头构建(怀疑缓存有问题时用)
docker build --no-cache -t myapp .

# 只让某个阶段之后的缓存失效(--no-cache-filter 后跟阶段名)
docker buildx build --no-cache-filter=builder -t myapp .

cache mount:包管理器自己的下载缓存(npm 的 .npm、apt 的 /var/cache/apt 等)是个两难——留在镜像里占体积,清掉了下次构建又要全部重新下载。BuildKit 的 cache mount 给出第三条路:把这些目录挂到独立的缓存存储里,不写入镜像层、跨多次构建共享:

# syntax=docker/dockerfile:1
FROM node:22-alpine
WORKDIR /app
COPY package.json package-lock.json ./

# npm 下载缓存放进 BuildKit 的缓存卷:不进镜像、下次构建直接复用
RUN --mount=type=cache,target=/root/.npm \
npm ci

COPY . .
CMD ["node", "server.js"]

apt 同理,用 --mount=type=cache,target=/var/cache/apt,sharing=locked 挂载。挂载的目录在 RUN 结束后就被卸载、不进镜像层,所以用了 cache mount 就不必再写 rm -rf /var/lib/apt/lists/* 这类清理命令。注意这个语法依赖 BuildKit,记得首行写 # syntax=docker/dockerfile:1 声明。

BuildKit​

BuildKit 是 Docker 的新一代镜像构建器,替代老的经典 builder。Docker 23.0 起 docker build 默认就走 BuildKit,不需要任何配置;老版本可以显式开启:DOCKER_BUILDKIT=1 docker build ...(或在 /etc/docker/daemon.json 里设 {"features": {"buildkit": true}})。

相比老 builder 的优势:

特性老 builderBuildKit
执行方式严格串行,逐条执行分析依赖关系,无依赖的阶段可并行构建
多阶段构建所有阶段全部构建只构建目标阶段依赖的部分
缓存能力仅本地层缓存cache mount、缓存导出/导入(--cache-to / --cache-from)
敏感数据只能靠 ARG/ENV,会泄露secret mount、ssh mount,不落盘
输出方式只能产出镜像到本地可导出 tar、目录、直接推 registry

Dockerfile 第一行的 # syntax=docker/dockerfile:1 注释是给 BuildKit 看的:指定用哪个 Dockerfile 前端解析器。cache/secret/ssh mount 这些新语法特性都通过它启用,建议养成习惯写上。

Secret Mount​

先讲为什么密钥不能进镜像层。镜像层一旦生成就不可变,写进层里的文件会被完整保留——即使后面的层把它 rm 删了,那也只是加了一条「删除标记」,文件内容还躺在下面的层里,docker save 导出或 docker history 翻历史就能找回来。ARG 传 token、COPY .npmrc 再删、ENV 写密码,全都中招。

正确做法是用 BuildKit 的 secret mount:密钥只在 RUN 执行的那一瞬间以文件形式挂载进来,用完即卸载,不落进任何镜像层。

完整例子——构建时访问私有 npm 仓库:

# syntax=docker/dockerfile:1
FROM node:22-alpine
WORKDIR /app
COPY package.json package-lock.json ./

# /root/.npmrc 只在这一条 RUN 执行期间存在
RUN --mount=type=secret,id=npmrc,target=/root/.npmrc \
npm ci

COPY . .
CMD ["node", "server.js"]

构建时通过 --secret 把密钥传进去,支持文件和环境变量两种方式:

# 方式一:从宿主机文件传入
docker buildx build --secret id=npmrc,src=$HOME/.npmrc -t myapp .

# 方式二:从环境变量传入(CI 里常用)
export NPMRC_CONTENT="$(cat ~/.npmrc)"
docker buildx build --secret id=npmrc,env=NPMRC_CONTENT -t myapp .
危险

三个「不要」:不要把密钥写进 Dockerfile;不要 COPY 进镜像再删;不要用 --build-arg 传密钥。密钥只要进过镜像层,就永远在里面,删掉也没用。构建期用 secret mount,运行期用 docker run -e、K8s Secret 或密钥管理服务在启动时注入。

SSH Mount​

场景:构建时要 git clone 私有仓库、go mod download 私有模块,需要用到你本地的 SSH 私钥。私钥当然不能 COPY 进镜像,BuildKit 的 ssh mount 可以把宿主机 ssh-agent 的 socket 临时挂进构建环境——构建过程能借用你的身份完成认证,私钥本身永远不离开宿主机。

完整例子——构建时拉私有仓库:

# syntax=docker/dockerfile:1
FROM golang:1.23-alpine AS builder

RUN apk add --no-cache git openssh-client

# 先把 github.com 的主机指纹写进 known_hosts,否则首次连接会卡在确认提示上
RUN mkdir -p -m 0700 /root/.ssh \
&& ssh-keyscan github.com >> /root/.ssh/known_hosts

# 这一条 RUN 期间可以使用宿主机的 ssh-agent
RUN --mount=type=ssh \
git clone git@github.com:example/private-lib.git /src

WORKDIR /src
RUN go build -o /bin/app .

构建前先把私钥加载进 ssh-agent:

# 启动 agent 并加载私钥
eval "$(ssh-agent -s)"
ssh-add ~/.ssh/id_ed25519

# --ssh default 表示使用默认 agent,也可以显式指定 socket 路径
docker buildx build --ssh default=$SSH_AUTH_SOCK -t myapp .

注意:前提是你本机 ssh -T git@github.com 能认证成功;CI 环境(GitHub Actions 等)要先配合 webfactory/ssh-agent 这类 action 把 agent 起好,再执行构建。

Buildx​

Buildx 是 BuildKit 能力的命令行入口,以 Docker CLI 插件的形式提供(docker buildx ...)。它把 docker build 升级成 docker buildx build,多出来的核心能力是:多 builder 实例、多架构构建、缓存导出、多样化输出。现在的 Docker 里 docker build 默认就走 buildx,平时用起来是无感的。

buildx 把「构建环境」抽象成 builder,可以创建多个相互隔离的实例:

# 创建一个叫 mybuilder 的实例并设为当前默认
docker buildx create --name mybuilder --driver docker-container --use

# 查看所有实例(带 * 号的是当前正在使用的)
docker buildx ls

# 启动并检查实例状态
docker buildx inspect --bootstrap

driver 的区别:

driver说明
docker内置在 dockerd 里的构建器,无需创建,但功能受限(不能一次构建多个平台、不能导出缓存)
docker-container在容器里跑一个独立的 BuildKit 进程,功能最全,多架构构建必须用它
kubernetes把构建任务分发到 K8s 集群,团队共享构建资源时使用

常用命令:

# 构建并加载到本地镜像列表(单平台时等价于 docker build)
docker buildx build -t myapp:1.0 --load .

# 构建完直接推送到仓库
docker buildx build -t <你的仓库>/myapp:1.0 --push .

# 只构建不落地,CI 里验证 Dockerfile 能不能构建通过
docker buildx build .

# 不用的实例删掉
docker buildx rm mybuilder
备注

docker-container driver 构建出来的镜像存在 builder 容器里,本地 docker images 看不到,必须显式 --load(加载进本地)或 --push(推送到仓库)。平时 docker build 用起来无感,就是因为默认的 docker driver 帮你自动做了加载。

多架构镜像(amd64 / arm64)​

背景:服务器大多是 x86_64(amd64),而 Apple Silicon Mac 和越来越多的 ARM 云服务器是 arm64。在 amd64 机器上构建的镜像拿到 arm64 机器上是跑不起来的。多架构镜像的做法是:同一个镜像名+标签下挂多个架构的镜像(技术上叫 manifest list / image index),docker pull 时 Docker 按本机架构自动选对的那一个。

跨架构构建靠 QEMU 模拟执行异架构指令。Docker Desktop 已经内置;Linux 上要手动注册一次 binfmt:

# 注册 QEMU 模拟器(一次性操作)
docker run --privileged --rm tonistiigi/binfmt --install all

# 确认 builder 支持的平台
docker buildx ls

完整流程——构建 amd64 + arm64 双架构镜像并推送到仓库:

# 1. 建一个 docker-container 驱动的 builder(默认 docker driver 干不了多架构)
docker buildx create --name mybuilder --driver docker-container --use
docker buildx inspect --bootstrap

# 2. 登录仓库
docker login

# 3. 一次构建两个平台并推送
docker buildx build \
--platform linux/amd64,linux/arm64 \
-t <你的用户名>/myapp:1.0 \
--push .

# 4. 验证 manifest list:输出里能看到 linux/amd64 和 linux/arm64 两个条目
docker buildx imagetools inspect <你的用户名>/myapp:1.0
注意

多平台构建的结果不能直接 --load 到本地——本地的 Docker 镜像存储只支持单平台,所以要直接 --push 到仓库。如果只想在本地试试某个平台的构建,单平台加 --load 是可以的:docker buildx build --platform linux/arm64 --load .。

QEMU 模拟的代价是慢:在 amd64 机器上模拟执行 arm64 指令,编译型任务可能慢数倍。对 Go 这类支持交叉编译的语言,更好的办法是配合 TARGETARCH 在本机架构上直接交叉编译,下一节讲。

TARGETARCH​

做多架构构建时,Dockerfile 里经常需要知道「当前正在为哪个架构构建」,比如下载对应架构的预编译二进制。BuildKit 会自动注入一组构建参数(不需要 --build-arg 手动传),在 Dockerfile 里用 ARG 声明后即可使用:

变量含义示例值
TARGETPLATFORM目标平台linux/arm64
TARGETOS / TARGETARCH目标系统 / 架构linux / arm64
TARGETVARIANT架构变体arm/v7 时是 v7
BUILDPLATFORM执行构建的机器平台linux/amd64
BUILDOS / BUILDARCH构建机的系统 / 架构linux / amd64

按架构下载不同二进制(以 kubectl 为例,构建 arm64 镜像时就下载 arm64 的 kubectl):

# syntax=docker/dockerfile:1
FROM alpine:3.20

# 必须先 ARG 声明,后面的 RUN 里才用得到
ARG TARGETARCH

RUN apk add --no-cache curl \
&& curl -fsSL -o /usr/local/bin/kubectl \
"https://dl.k8s.io/release/v1.31.0/bin/linux/${TARGETARCH}/kubectl" \
&& chmod +x /usr/local/bin/kubectl

Go 交叉编译,比 QEMU 模拟快得多——构建阶段固定跑在构建机的原生架构上(不做模拟),让 Go 编译器交叉编译出目标架构的二进制:

# syntax=docker/dockerfile:1

# --platform=$BUILDPLATFORM:这个阶段始终用构建机的原生架构,不做模拟
FROM --platform=$BUILDPLATFORM golang:1.23-alpine AS builder
ARG TARGETOS TARGETARCH
WORKDIR /app
COPY . .

# Go 交叉编译:本机 amd64 的编译器直接产出 arm64 二进制,速度几乎无损
RUN GOOS=${TARGETOS} GOARCH=${TARGETARCH} CGO_ENABLED=0 go build -o server .

FROM alpine:3.20
COPY --from=builder /app/server /server
ENTRYPOINT ["/server"]

这样执行 docker buildx build --platform linux/amd64,linux/arm64 时,编译全程在 amd64 原生速度下跑,只是最后打包成两种架构的镜像,比 QEMU 模拟整个构建过程快非常多。

备注

新手最常踩的坑是忘了写 ARG TARGETARCH 声明,结果变量展开成空字符串,下载地址 404 还排查半天。另外注意 FROM --platform=$BUILDPLATFORM 里变量前要带 $,因为 --platform 要的是变量的值。

镜像优化​

减小镜像体积的清单,按收益从大到小排:

  1. 换小基础镜像。同一个 Node 应用,不同基础镜像能差好几倍:

    基础镜像体积(约)特点
    node:22(Debian 完整版)1.1 GB工具全,调试图方便,生产偏大
    node:22-slim(Debian 精简)220 MB平衡之选
    node:22-alpine130 MBmusl libc,个别依赖 glibc 的包不兼容
    distroless视语言而定无 shell 无包管理器,最安全,调试困难
    scratch0空镜像,只适合静态编译的二进制
  2. 多阶段构建:编译产物和编译工具链分离,见「多阶段构建」一节。

  3. 合并 RUN,减少层数:装包、配置、清理写在同一条 RUN 里。

  4. 在同一层里清缓存:

    RUN apt-get update \
    && apt-get install -y --no-install-recommends curl \
    && rm -rf /var/lib/apt/lists/*

    注意必须是同一层:分两条 RUN 的话,apt 索引已经被前一条固化进层了,后一条删了也白删。

  5. 写好 .dockerignore:别把无关文件发进构建上下文——上下文大会拖慢构建,还可能把密钥文件打进镜像:

    .git
    node_modules
    dist
    *.log
    .env
    docker-compose*.yml
  6. 拆分 COPY,利用层缓存:依赖清单先拷、代码后拷,见「Build Cache」一节。

  7. 不装用不到的包:apt 加 --no-install-recommends,alpine 的 apk 加 --no-cache;构建完别留 vim、curl 这类调试工具(真要调试,用临时容器挂进去)。

  8. 固定依赖版本:基础镜像标签、apt 包、npm 包全部锁定,保证构建可复现。

体积之外:把最常变的指令放最后,镜像更新时只需拉取和重建最少的层;非 root 运行、distroless 缩小攻击面;打 LABEL 标注版本和源码地址,方便追溯。

企业级 Dockerfile 最佳实践​

把前面各节的要求汇总成一份检查清单,写 Dockerfile 时逐条对照。

基础镜像

  • 不用 latest,固定到具体版本标签;核心服务固定到 digest。
  • 能用 alpine/slim 就不用完整版;Go 等静态编译语言优先考虑 distroless 或 scratch。
  • 基础镜像来源可信:Docker Official Image,或企业内部统一维护、定期更新的基础镜像。

构建过程

  • 有 .dockerignore,排除 .git、node_modules、密钥文件、CI 配置。
  • 依赖安装和代码 COPY 分开,充分利用层缓存。
  • 编译型语言用多阶段构建,最终镜像不含编译工具链。
  • 构建期的密钥/私库凭证用 secret mount 或 ssh mount,绝不用 ARG/ENV 传递。
  • Dockerfile 第一行声明 # syntax=docker/dockerfile:1,启用新语法。

运行安全

  • 创建专用用户,USER 切换为非 root 运行。
  • 只安装运行必需的包,能不留 shell 就不留(distroless)。
  • CMD/ENTRYPOINT 用 exec 表单,保证 PID 1 能收到 SIGTERM 优雅退出。
  • 不在 ENV、镜像层里放任何敏感信息。

可观测与可维护

  • 配置 HEALTHCHECK(K8s 场景由 probe 接管)。
  • 打 OCI 标准 LABEL:title、version、source、revision。
  • 应用日志输出到 stdout/stderr,不写容器内文件。
  • 镜像在 CI 里统一构建和推送;版本号、commit 用 --build-arg 注入,并写入 LABEL 便于追溯。

持续治理

  • 定期重建镜像,即使代码没变——基础镜像的安全补丁要靠重建才能拿到。
  • 接入镜像扫描(trivy、docker scout、registry 自带扫描),高危漏洞阻断发布。
  • 需要跨架构部署的,用 buildx 统一构建 amd64/arm64 并推送 manifest list。
提示

这份清单可以落到团队的代码评审 checklist 或 CI 流水线里。也可以配合 hadolint 这类 Dockerfile lint 工具,把「合并 RUN、固定版本、非 root 运行」等规则变成自动化检查,比人肉 review 可靠。