资讯详情

资讯详情

Dockerfile 生产级实践:构建缓存、多阶段构建与镜像安全

很多做容器化改造的朋友对 Dockerfile 的第一印象往往是“不就是把部署步骤写成文件嘛”。实际用起来才发现写得随意的 Dockerfile 和写得讲究的 Dockerfile在构建速度、镜像体积、安全性和可维护性上的差距能相差十倍。这篇“Docker 专题(七)”我就把 Dockerfile 从指令细节到构建原理、从踩坑记录到生产级模板完整过一遍适合已经会用 Docker 基本命令、正在尝试自己写 Dockerfile 的开发者也适合想在团队里推行镜像交付规范的读者。1. 写 Dockerfile 之前先想清楚你要交付的是“镜像”还是“环境”先纠正一个普遍误区Dockerfile 不是简单的“部署步骤说明”它描述的是镜像层的构建过程而镜像的最终形态是一套包含操作系统文件、运行时、依赖和应用代码的只读模板。所以写 Dockerfile 之前心里要有两张图第一张是最终镜像运行起来之后容器里的文件系统长什么样第二张是构建过程中哪些文件会进入镜像、哪些不会。1.1 镜像的本质三层结构你可以把一个镜像拆成三个部分底层操作系统基础层、中间依赖层、顶层应用代码层。底层基础层一般来自FROM指定的基础镜像比如系统发行版、官方运行时镜像中间依赖层负责把应用所需的库、包管理器和配置文件“安装”进去顶层应用层则是最容易变化的业务代码。这三层里的任何一层发生变化都会导致该层以及它之上的所有层重新构建这点后面讲缓存时会反复用到。所以在动手写 Dockerfile 之前先回答三个问题应用是什么类型是常驻服务Web 服务、消息消费者、一次性任务批处理脚本、数据迁移还是定时任务不同类型的应用决定了基础镜像选择、启动命令写法都不一样。构建产物是否需要编译有些语言Go、C需要编译出二进制有些语言Python、Node直接把源码和依赖拷进镜像即可。这直接决定你需不需要“多阶段构建”。运行镜像时需要哪些配置变量哪些是构建期就固定的哪些是运行期由外部传入的这决定了你用ARG还是ENV。把这几个问题想清楚再往下写你的 Dockerfile 才不会只是在“碰运气式”地把命令堆在一起。1.2 开始前确认两件事第一确认 Docker 版本支持 BuildKit。现在 Docker 20.10 以上默认就开启了 BuildKit新语法如COPY --link、RUN --mounttypecache都依赖它。你可以在构建时加上DOCKER_BUILDKIT1或者直接在 Docker 配置里开启。第二确认.dockerignore文件存在。就算你的 Dockerfile 只写了三行没有.dockerignore也会把构建上下文里一堆无关文件打包发送给守护进程拖慢构建速度还容易把敏感文件带进镜像。后面专门有一章说这个。2. FROM 指令的三个隐藏约束基础镜像决定“血统”FROM是 Dockerfile 的第一条有效指令它指定了基础镜像镜像。看似简单但这里很值得多花心思因为基础镜像直接决定了镜像的攻击面、体积上限和可维护性。2.1 不用 latest要用明确版本标签FROM ubuntu:latest这种写法非常危险。latest是个移动标签它会随着发行方更新而悄悄变成新版本。今天构建没问题三个月后构建的时候基础镜像可能已经换了大版本导致系统包源变了、运行时行为变了容器里跑的应用就莫名其妙出问题。更稳妥的做法是锁定标签比如ubuntu:22.04、node:20-bookworm-slim甚至更进一步直接固定到 digest如ubuntu:22.04sha256:xxxx。锁定 digest 的缺点是每次升级基础镜像都要手动更换哈希值、重新验证日常项目锁标签就够了做严肃交付的场景才需要锁 digest。2.2 基础镜像不是越小越好很多人一看到alpine体积小就无脑用但 alpine 的优点是“小”代价是使用了 musl libc 而不是 glibc。很多预编译的二进制依赖 glibc在 alpine 上直接跑不起来还有些 Python 包在 alpine 上需要现场编译反而比在 Debian 上耗时更久。我的个人经验是场景推荐基础镜像理由Python 应用、依赖中带 C 扩展python:3.12-slim体积适中兼容性比 alpine 好很多Node 前端构建产物node:20-alpine纯 JS 运行alpine 兼容性没问题体积优势明显Go 应用alpine或scratch/distrolessGo 编译静态二进制基础层可以压到极小Java 应用eclipse-temurin:17-jre注意选 JRE 而非 JDK只跑字节码不需要编译器有系统调用或 cgo 的应用Debian slim 系列兼容性和排障环境都更成熟不要为了“小”而牺牲“稳”。镜像体积的优化更多应该靠多阶段构建而不是在基础镜像上纠结几十兆。2.3 架构匹配问题在 Apple 芯片的 MacARM64上构建的镜像传到 x86_64 服务器上默认是跑不起来的除非配置了多架构 manifest。如果你团队里有多种开发机构建时可以用docker buildx build --platform linux/amd64,linux/arm64同时打出多架构镜像再推到仓库。这样拉取时 Docker 会按当前平台自动选择对应的架构。这块属于 CI 配置层面的内容Dockerfile 本身要保证的是——你的依赖包有没有分架构版本。3. RUN、COPY 与 ADD 的职责边界别把一切都塞进一条 RUNDockerfile 里最常用的三条指令是RUN、COPY、ADD。它们的职责其实非常清晰COPY负责把本地文件放进镜像ADD是COPY的增强版支持解压、支持远程 URLRUN负责在镜像内执行命令、生成新的层。但在实际项目里我见过太多把这三者混用的写法尤其是把“下载依赖编译清理”全部塞进一条超长RUN的。3.1 RUN 指令的两种执行形式RUN有两种写法# Shell 形式等价于 /bin/sh -c apt-get update apt-get install -y curl RUN apt-get update apt-get install -y curl # Exec 形式直接执行命令不走 shell RUN [apt-get, update]推荐使用 Shell 形式因为你在一条RUN里往往要做“更新索引、安装、清理”等多个连续操作。但注意 Exec 形式有个经典坑如果命令里有环境变量替换Exec 形式不会经过 shell 解析所以RUN [echo, $HOME]输出的是字面量$HOME而不是/root。遇到这类需求老老实实用 Shell 形式。3.2 为什么建议把一条长 RUN 拆成多条先看一个反面例子RUN apt-get update apt-get install -y build-essential libpq-dev pip install --no-cache-dir -r requirements.txt rm -rf /var/lib/apt/lists/*这个写法把安装系统依赖、安装 Python 依赖、清理缓存放在一起优点是一个层搞定、镜像层数少。但缺点是——只要requirements.txt有改动或者任何一个系统包版本变了整条 RUN 全部重跑连带的系统包安装也要重新执行。更合适的拆分方式是# 第一层安装系统级依赖和清理除非依赖列表变了否则不会重跑 RUN apt-get update apt-get install -y --no-install-recommends build-essential libpq-dev rm -rf /var/lib/apt/lists/* # 第二层安装 Python 依赖 COPY requirements.txt /tmp/requirements.txt RUN pip install --no-cache-dir -r /tmp/requirements.txt这样拆完之后requirements.txt一变只重跑第二层第一层直接从缓存里取。构建速度提升非常明显。3.3 ADD 的三个坑ADD比COPY多了解压和远程 URL 能力但越是能力多越要克制ADD xxx.tar.gz /app/会自动解压有时你本意只是想把压缩包作为文件放进镜像做备份结果被解压了容易踩坑。ADD支持从远程 URL 拉文件但构建时拉远程文件无法利用缓存且不稳定而且拉下来的文件没有校验机制不如先curl下载再COPY。ADD会自动改变目标目录下文件的权限模式比如 tar 包里的 owner 信息会保留这经常造成后续容器启动时遇到权限问题。所以我的准则是本地文件进镜像用COPY需要解压现场才用ADD远程下载一律不用ADD。4. 构建缓存失效的完整逻辑重新认识“层”这个概念Dockerfile 的每条指令都会产生一个镜像层而构建缓存效验的规则决定了镜像的重建效率。很多人只知道“改文件会失效缓存”但具体怎么失效、怎么利用缓存需要理解底层的匹配逻辑。4.1 缓存匹配的原理Docker 守护进程在做缓存匹配时会计算当前指令和“这条指令对应的镜像层”的 cache key。对COPY指令cache key 包含了原文件的校验和checksum对RUN指令cache key 是命令字符串本身根据上一层缓存结果去判断。这意味着如果一条COPY拷入的文件内容变化了这一层失效后续所有层都失效。如果一条RUN的字符串发生变化即使只是多了一个空格这一层也失效。如果上层失效了下层即使指令完全没变也无法使用缓存必须重新执行。4.2 经典的错误顺序新手最常见的写法是COPY . /app # 整个项目代码包括源码、配置文件、测试 RUN apt-get update apt-get install -y python3-dev RUN pip install -r /app/requirements.txt只要任意一行代码有改动第一条COPY . /app的校验和就变了一次小小的提交就会导致后面系统依赖、全部 Python 包重新安装。构建速度慢到怀疑人生。正确顺序是把变化频率最低的文件放最前面变化最频繁的放最后面。# 稳定层基础系统依赖 RUN apt-get update apt-get install -y --no-install-recommends python3-dev rm -rf /var/lib/apt/lists/* # 较稳定层依赖声明文件 COPY requirements.txt /tmp/requirements.txt RUN pip install --no-cache-dir -r /tmp/requirements.txt # 最不稳定层业务代码 COPY . /app这样改业务代码时前面三层全部走缓存构建时间能压缩到几秒钟。4.3 利用 BuildKit 特性挂载缓存即便你把文件顺序排对了像apt-get install、npm install这种操作在层缓存失效之后还是要重新联网安装。为了进一步提速BuildKit 支持在RUN执行期间挂载临时缓存# syntaxdocker/dockerfile:1 FROM node:20-alpine AS builder WORKDIR /app COPY package.json package-lock.json ./ RUN --mounttypecache,target/root/.npm \ npm ci --prefer-offline COPY . . RUN npm run build这里--mounttypecache,target/root/.npm会把 npm 的缓存目录挂到宿主机缓存空间即使镜像层整体失效已下载的包也不会重新下载。Debian/Ubuntu 的apt缓存、Go 的模块缓存、Python 的pip缓存都可以用同样的方式处理。4.4 什么时候强制不走缓存更新系统包索引、拉取新版本元数据时旧缓存反而会干扰结果。比如RUN apt-get update apt-get install -y ...如果缓存命中apt-get update不会重新执行而包索引过期会导致后续安装报错。此时你需要用docker build --no-cache强制重建或者定期主动让这条指令失效比如在命令后面追加#注释和日期。5. ARG 与 ENV 的正确分工构建期与运行期的变量边界ARG和ENV都用于设置变量但作用范围完全不同。很多人一开始没分清导致构建出的镜像要么少了配置、要么把敏感信息写进了镜像。5.1 两者的作用域差异ARG只在构建阶段有效容器运行时不存在。ENV是环境变量既可以在构建阶段被RUN指令引用也会保留在最终镜像的环境配置中容器启动时自动生效。举个例子ARG APP_VERSION1.0.0 ENV APP_ENVproduction RUN echo $APP_VERSION /app/version.txt # 可以正常写入 RUN echo $APP_ENV /app/env.txt # 也可以正常写入但容器启动后 APP_ENV 依然存在5.2 典型的敏感信息事故最常见的坑是把密码、API Key 写进ENV构建阶段用来做认证然后打进镜像层里。即使后来你在镜像里把文件删了删除动作本身只是新加一层原来的层仍然存在于镜像历史中。别人只要docker history一下就能把密钥翻出来。正确做法构建期用于认证的凭据用ARG并且尽量配合 BuildKit 的--secret挂载方式避免把密钥值直接写在 Dockerfile 里。运行期需要的密钥用--env或env_file在容器启动时注入不要写死在镜像里。5.3 给 ARG 设置默认值还是不设置ARG可以设默认值也可以不设。不设默认值时如果构建命令没传--build-arg这个变量就是空字符串。经典报错场景Dockerfile 里写了ARG APP_PORT但构建时忘了传最后EXPOSE $APP_PORT变成了EXPOSE空值导致端口映射不生效。给默认值可以降低出错率但也会掩盖“构建参数缺失”的问题。我的习惯是必须由外部注入的参数不设默认值并在启动前后做校验纯优化项、版本号这类参数才设默认值。5.4 ARG 和 ENV 配合使用的场景有时候一个版本号既要在构建期用来下载指定版本依赖又要在运行期显示在应用日志里。这时可以ARG APP_VERSION2.8.0 ENV APP_VERSION$APP_VERSIONENV的值可以在构建期引用ARG实现一次传参全程可用。注意这种写法里ARG声明必须放在ENV之前。6. 多阶段构建一次编译产物如何优雅瘦身多阶段构建是 Dockerfile 里最能体现工程水平的一个设计。它的核心思想是构建过程借用“肥”环境包含完整编译工具链、源码、依赖缓存最终产物只把“瘦”的运行时文件拷贝进镜像。这样镜像里既没有编译器也没有源码和中间文件。6.1 一个 Java 项目的实战拆解假设我们要打包一个 Spring Boot 应用。单阶段写法FROM maven:3.9-eclipse-temurin-17 WORKDIR /app COPY . . RUN mvn package CMD [java, -jar, target/app.jar]这个镜像体积有多大它包含 Maven、JDK、全部源码和本地仓库依赖动辄 1GB 以上。用多阶段构建# 阶段一构建 FROM maven:3.9-eclipse-temurin-17 AS build WORKDIR /app COPY . . RUN mvn -B package -DskipTests # 阶段二只保留运行时 FROM eclipse-temurin:17-jre WORKDIR /app COPY --frombuild /app/target/app.jar ./app.jar EXPOSE 8080 USER nobody ENTRYPOINT [java, -jar, app.jar]第二阶段的基础镜像是纯 JRE不含 Maven、不含源码只有target/app.jar。镜像体积能缩减 60% 以上安全攻击面也大幅减少。6.2 不同语言的多阶段构建参照Go 应用第一阶段用golang:1.22编译第二阶段可以直接用scratch或者gcr.io/distroless/static。不过注意如果应用内部做证书校验、解析域名scratch里没有 CA 证书需要额外从构建阶段 COPY 一份/etc/ssl/certs/ca-certificates.crt。Python 应用第一阶段用完整工具链编译带 C 扩展的依赖第二阶段用python:slim只拷贝 site-packages 和应用代码。实际操作时要注意 Python 的 .so 依赖动态库所以第二阶段镜像最好和第一阶段是同一个系统发行版系列避免 glibc 版本不一致。6.3 多阶段构建的额外优势除了缩小镜像体积多阶段构建还有个隐性好处——每条阶段都可以拥有完全不同的基础镜像。前端项目可以这样优化FROM node:20-alpine AS build WORKDIR /app COPY package.json ./ RUN npm install COPY . . RUN npm run build FROM nginx:alpine COPY --frombuild /app/dist /usr/share/nginx/html构建阶段用 Node.js运行阶段用 Nginx。最终镜像里没有 Node 运行时只有一个静态资源服务器非常干净。6.4 命名阶段的技巧每个FROM可以用AS起一个语义化名字在后续阶段通过COPY --frombuild引用。阶段名建议用build、deps、runtime这种可读名字。如果多阶段之间还有需要共享的中间文件更优雅的方式是用COPY --frombusybox:latest /some/file /dest直接引用外部镜像做数据提取这在制作证书、编译静态资源时很常用。7. 构建上下文与 .dockerignore几乎人人踩过的“镜像膨胀”坑有一次帮同事排查一个奇怪的问题明明 Dockerfile 里只COPY app.py但构建上下文要传几十秒生成的镜像也有 800MB。原因很简单——构建上下文目录里放着一个几十 MB 的.git目录和虚拟环境目录COPY指令虽然没把它们加进镜像但构建上下文在指令执行前就已经全部打包发送给了 Docker 守护进程。7.1 构建上下文是什么执行docker build -t myapp:v1 .时Dockerfile所在目录下的所有文件都会被搜集进构建上下文发送给 Docker daemon。COPY只能访问构建上下文范围内的文件不能访问外部路径COPY ../xxx这种写法会直接报错。.dockerignore的作用就是在发送之前排除指定文件。它的语法和.gitignore基本一致但很多人经常忘了加。一个合理的 Node 项目.dockerignore长这样node_modules npm-debug.log .git .gitignore .DS_Store Dockerfile .dockerignore dist coverage .env注意这里我把Dockerfile也排除掉了。很多人会奇怪Dockerfile 不是必须的吗实际上 Dockerfile 是单独发送给 daemon 的不需要通过构建上下文传进去。把Dockerfile写进.dockerignore可以减少上下文体积也能避免某些场景下文件拷贝的奇怪行为。7.2 排除 .env 真的很重要.env文件里存放着密钥如果不加排除它会进入构建上下文之后如果在 Dockerfile 里有执行docker build --no-cache时留下的层里被读取就会泄露。更麻烦的是即使后来你在镜像里删掉它层历史里依然存在。所以.dockerignore里必须加.env、*.key、*.pem等敏感文件模式。7.3 COPY 的权限处理chown 和 chmod当你的应用以非 root 用户运行时下文会说COPY把文件拷进镜像时默认是 root:root 权限。启动容器时应用没权限读。解决办法有两种COPY --chownappuser:appuser ./app /app如果要拷贝的是压缩包并希望自动解压ADD同样支持--chown。还有一种情况是可执行权限本地开发时你的.sh脚本可能没有可执行位COPY不会改变权限需要在RUN里显式执行chmod x。7.4 WORKDIR 的隐藏作用WORKDIR的作用不只是切换目录它还会影响后续RUN、CMD、COPY的相对路径基准。如果 Dockerfile 里没有WORKDIR后续所有COPY都依赖当前工作目录一旦你修改了FROM镜像的默认工作目录Dockerfile 里的相对路径就会全部失效。规范化写法是在FROM之后立即设置一次WORKDIR /app后续路径全部基于它。同时WORKDIR不存在时会被自动创建不需要先RUN mkdir。7.5 USER 指令别让容器跑在 root 下默认情况下容器里的进程以 root 身份运行。对应用容器来说这个问题很大——一旦应用出现远程代码执行或文件上传漏洞攻击者获得的就是容器内 root 权限配合一些逃逸漏洞就能影响宿主机器。标准做法是在 Dockerfile 末尾切换到非特权用户FROM golang:1.22 AS build WORKDIR /src COPY . . RUN CGO_ENABLED0 go build -o /app/bin/app FROM gcr.io/distroless/static-debian12 WORKDIR / COPY --frombuild /app/bin/app /app USER nonroot ENTRYPOINT [/app]distroless 镜像内置了nonroot用户可以直接用。Debian/Ubuntu 基础镜像可以自己创建用户RUN groupadd -r appuser useradd -r -g appuser appuser USER appuser注意切换到非 root 用户后如果应用需要写文件日志、临时目录目标目录必须提前给这个用户授权用RUN chown appuser:appuser /app/logs解决。8. 给生产仓库准备一份 Dockerfile 模板与实测踩坑备注最后给出一份可以直接放进生产项目里参考的 Dockerfile配合每一条的“为什么这么做”。这个模板结合了前面几章涉及的所有要点实测下来在构建时间、镜像体积和可维护性上都比较均衡。# syntaxdocker/dockerfile:1 FROM node:20-alpine AS build WORKDIR /app # 依赖安装层单独做利用层缓存 COPY package.json package-lock.json ./ RUN --mounttypecache,target/root/.npm \ npm ci --prefer-offline # 业务代码放最后 COPY高频变更不破坏前面缓存 COPY . . ARG APP_VERSIONdev ENV APP_VERSION$APP_VERSION RUN npm run build # 运行阶段用 Nginx 托管静态产物 FROM nginx:1.25-alpine WORKDIR /usr/share/nginx/html COPY --frombuild /app/dist ./ RUN rm -rf /etc/nginx/conf.d/default.conf # 健康检查容器编排平台会定时探测 HEALTHCHECK --interval30s --timeout3s --start-period5s --retries3 \ CMD wget -q --spider http://127.0.0.1/ || exit 1 # 降低运行权限 RUN sed -i s#listen 80;#listen 8080;#; s#/usr/share/nginx/html;#/usr/share/nginx/html;# /etc/nginx/conf.d/default.conf \ sed -i 2i user nginx; /etc/nginx/conf.d/default.conf EXPOSE 8080 USER nginx CMD [nginx, -g, daemon off;]8.1 模板里的几个细节解释npm ci而不是npm install因为npm ci严格按照package-lock.json安装不会动态升级依赖保证构建一致性。--prefer-offline配合挂载缓存能大幅降低无缓存重建时的耗时。运行阶段切换USER nginx前我把 Nginx 监听端口从 80 改到了 8080。这主要是考虑无特权端口用 80 端口需要绑定低端口权限容器内可能受NET_BIND_SERVICEcapability 限制用 8080 这类高位端口更省事也方便在编排平台上直接映射。8.2 实测中经常翻车的几个点忘记拷贝 CA 证书用scratch或 distroless 镜像跑 Go 应用应用里用https请求就失败。第一次遇到这个问题时排查了很久最后发现就是镜像里没有/etc/ssl/certs。必须在编译阶段COPY --frombuild /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/。时区问题很多应用在日志里要打印本地时间。基础镜像默认时区是 UTC需要额外加RUN ln -sf /usr/share/zoneinfo/Asia/Shanghai /etc/localtime。但因为镜像里不带tzdata包有些精简镜像里根本没有/usr/share/zoneinfo遇到这种情况要先apk add tzdata或apt-get install -y tzdata。依赖缓存没有清理RUN apt-get install之后如果不清理/var/lib/apt/lists/*每一层都会带着大量索引文件一个镜像能悄悄多出上百 MB。同理pip install之后记得加--no-cache-dirnpm install用npm ci --prefer-offline而非直接install。备份文件打进镜像有些编辑器会在目录里生成*.bak、*.orig、*.swp文件。.dockerignore漏了这些模式路径不深但是恶心经常莫名踩到。8.3 一条值得知道的跟进技巧docker history 是排查利器看镜像到底为什么这么大别凭感觉猜。构建之后执行docker history 镜像名 --no-trunc --format table {{.ID}}\t{{.Size}}\t{{.CreatedBy}}Size列会列出每一层的增量大小。哪个层大、命中哪个RUN或COPY一眼就能定位。有时候你会在清理之后发现镜像是空的目录层根源还是与COPY把整个目录拷进去了。用这个命令排查比反复改 Dockerfile 猜效率高很多。8.4 构建的固化与验证如果你的项目要做 CI 接入建议在 Dockerfile 构建完成之后顺手做一次容器内验证docker build -t demo-app:v1 . docker run --rm --entrypoint demo-app:v1 sh -c test -f /app/config.yaml echo config ok--entrypoint可以绕过镜像的默认启动命令只跑一条测试命令来验证关键文件是否存在于镜像里。这个技巧我在排查“为什么镜像起不来”的时候几乎每次都用因为很多问题在构建时不会暴露只有到容器启动阶段才炸。写在最后回顾我自己从“能写出一个能跑的镜像”到“能写出一个长期可维护、构建快、体积小、权限安全的镜像”最关键的转变不是背下更多 Dockerfile 指令而是理解了镜像层的机制文件顺序影响缓存、基础镜像决定血统、多阶段构建负责瘦身、USER 和 .dockerignore 负责安全。顺手再说一个实用习惯每次修改 Dockerfile 之后我都会把完整构建命令贴在项目 README 的“本地构建”小节里并注释上“如果构建失败先试docker build --no-cache”。因为团队里总有新同事遇到旧缓存导致的疑难杂症一句注释能省下很多无效沟通时间。容器化这件事说白了就是把“环境即代码”落到每一条指令里你的 Dockerfile 写得越克制交付的镜像就越稳定。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →