Dockerfile 详解:镜像分层、核心指令与多阶段构建实战
发布时间:2026/10/23 1:05:05 锦皓数字建站

1. 为什么我建议你认真对待Dockerfile而不是只跑通先说实话很多人最初接触Docker都是从docker run一个现成镜像开始的。比如拉个 Nginx、拉个 Redis跑起来好像挺顺感觉 Docker 也就那么回事。但等你真正想把一个自己写的服务交付出去、部署到别人的机器上或者让同事一条命令就跑起你的项目时那就绕不开 Dockerfile 了。Dockerfile 简单说就是一份「镜像制作说明书」。它不像 shell 脚本那样直接执行命令而是逐行声明一个镜像的构建步骤。Docker 读这份说明书按步骤生成一个不可变的镜像然后你用这个镜像去创建容器。关键差别在于一份 Dockerfile 写清楚之后任何人、任何机器上都能构建出行为一致的镜像这才是工程化协作最核心的价值。这篇文章我会从「为什么要用 Dockerfile」讲起把最常用的指令逐个拆开说清楚再用一个完整的 Python 服务做实战演示最后聊聊我实际踩过的一些坑和平时养成的习惯。看完之后你应该能自己动手写出一份不算花哨、但足够稳健的 Dockerfile并且知道为什么要这么写。先提醒一句我见过不少人把 Dockerfile 当作「把部署步骤抄进去的记事本」动不动就往里面塞一堆 apt 安装命令最后镜像体积 2GB 起步构建还老失败。这里面有几层逻辑没有理顺包括镜像分层、构建上下文、以及指令之间的顺序影响。这块理顺了你写 Dockerfile 的功力会有质的提升。2. 镜像分层的底牌为什么 Dockerfile 每一行都算数要写好 Dockerfile先得理解镜像的分层机制。其实一点都不玄Docker 镜像不是一块完整的大硬盘映像而是一层一层堆出来的。每一层对应 Dockerfile 里的一条指令准确说是会改变文件系统的指令然后这一层只保存与上一层之间的差异。类比一下就像乐高积木FROM 给你一块底板RUN、COPY 这些指令每执行一步就往底板上叠一块积木你最后拿起这整摞积木就是完整镜像。重点是如果后面某一步出错你不需要从头开始只需要回到出错前那一层重新叠。这个机制衍生出两个重要结论第一构建缓存是分层的。如果 Dockerfile 前面某几行没变Docker 会直接用缓存跳过这几步只重新执行变化之后的部分。这极大地影响构建速度也是很多人优化 Dockerfile 的切入口。第二层的数量直接影响镜像体积和传输效率。RUN apt-get install这种指令如果没有合并同类项每写一个 RUN 就多一层中间产生的临时文件比如 apt 下载的缓存包即便后面删除了其实还是残留在中间层里。所以后面我会提到一个铁律——能合并的 RUN 尽量合并。还有一层隐藏逻辑docker build的「构建上下文」。默认情况下Docker 会把 Dockerfile 所在目录整个打成压缩包发给守护进程然后在这个上下文里执行构建。如果你的项目目录里有一个 2GB 的数据集哪怕 Dockerfile 里根本没用它构建过程也会因为发送这个大目录而奇慢无比。这也是为什么.dockerignore文件几乎和 Dockerfile 一样重要很多人会忽略。当你心里有了「每一行都在叠积木」这个模型再看下面的指令解析和实战就会顺畅得多。3. Dockerfile 核心指令先搞懂这九个就够日常使用了Dockerfile 指令不算多日常写起来高频的其实就九个。我把它们分成「必备、常用、进阶」三档逐个说人话解释。3.1 必备指令FROM、RUN、CMDFROM是每一份 Dockerfile 的第一行。它的作用是选定基础镜像——你的应用得站在某个巨人肩膀上。比如 Python 服务通常选python:3.11-slim前端的 Nginx 部署选nginx:alpine。有两个细节值得注意一是基础镜像的 tag 要尽量明确别写latest。latest是移动靶今天构建的和三个月后构建的可能不是同一个系统就失去了可复现性。某开发者在某公司的生产环境里就因为latest悄悄升级了小版本导致某跨平台系统的兼容性测试全部失败镜像拉下来之后排查了半天才发现是基础镜像变了。所以尽量固定 tag或者哪怕用python:3.11-slim也比python:latest可靠得多。二是优先选 slim、alpine 这类精简变体。同一个语言的官方镜像完整版往往带着编译器、文档、调试工具对跑应用来说是多余的重量。比如python:3.11-slim和python:3.11体积可能差出几百 MB。容器本身进程级别的隔离意味着你并不需要把调试工具也塞进镜像里。RUN是构建阶段执行的命令。比如安装依赖、创建目录、下载文件。RUN 后面跟的是一条 shell 命令也可以写成数组形式[executable, param1, param2]。构建时每执行一个 RUN 就会产生一层缓存层。CMD是容器启动时执行的命令。和 RUN 最大的区别是RUN 在构建镜像时执行结果固化成镜像的一部分CMD 在docker run启动容器时才执行不写入镜像层同一个镜像可以配合不同 CMD 跑出不同行为。另外 CMD 可以被docker run末尾的命令覆盖这个特性后面讲 ENTRYPOINT 时会展开。3.2 常用指令COPY、WORKDIR、ENV、EXPOSECOPY把构建上下文里的文件复制进镜像。日常使用最频繁也最容易被滥用。很多人喜欢整个目录一把梭COPY . /app其实更好的做法是只复制构建需要的东西。比如COPY requirements.txt /app/requirements.txt再RUN pip install最后COPY . /app。为什么分两步因为依赖安装的缓存只跟 requirements.txt 的内容有关源码只要改动一点构建镜像时依赖层还能直接用缓存不需要重新 pip install。这是利用分层缓存最典型的例子。WORKDIR设置后续指令的工作目录。相当于 shell 里的cd但是比cd更可靠。因为它不仅对当前 RUN 生效对后续所有指令包括 CMD、ENTRYPOINT、COPY 的相对路径都生效。建议在 Dockerfile 里一开始就WORKDIR /app后面所有 COPY 和 RUN 都用相对路径读起来也清爽。ENV设置环境变量。常用于配置 Python 的PYTHONUNBUFFERED1让日志立即输出或者给应用传入运行参数。注意 ENV 设置的变量会保留在容器里如果里面有密钥这种敏感内容会被docker inspect看到所以敏感信息不建议硬编码进 ENV后续可以用运行时注入的方式。EXPOSE声明容器里哪个端口是服务监听的。注意它只是声明并不真的把端口暴露到宿主机。想访问容器里的服务run 时还是要-p 宿主机端口:容器端口。但写清 EXPOSE 是给使用你镜像的人一个「这个镜像在等哪个端口」的明确提示属于良好习惯。3.3 进阶指令ENTRYPOINT、ARGENTRYPOINT和 CMD 容易混淆我单独说。简单记ENTRYPOINT定义容器启动后的主进程运行时不易被覆盖可用--entrypoint强制改。CMD给 ENTRYPOINT 提供默认参数或者在没有 ENTRYPOINT 时作为默认命令但容易被docker run后面的命令覆盖。如果用命令行类比ENTRYPOINT 是命令本身CMD 是默认的参数。比如 ENTRYPOINT 设为[python, app.py]CMD 设为[--config, prod.ini]那你docker run时如果想自定义参数只要docker run image --config dev.iniCMD 被覆盖主进程还是运行 app.py。如果压根没有 ENTRYPOINT那docker run image --config dev.ini会把--config dev.ini当成 shell 命令去执行直接报错。很多人在这里栽跟头后面我会专门再讲。ARG是构建时变量只在 build 阶段有效不像 ENV 会保留到容器里。常用于给构建过程传参比如指定基础镜像的版本或者构建环境。docker build --build-arg VERSION1.2.3配合 Dockerfile 里的ARG VERSION可以做出一个参数可配的构建过程比手动改 Dockerfile 好用得多。九条指令基本覆盖了日常 90% 的场景。接下来用一个实际例子把串起来走一遍。4. 从零到一Python 服务 Dockerfile 实战拆解光说语法是记不住的我们拿一个最简单的 Python 接口服务来实操。这个服务假设是一个模拟项目 X用 Flask 暴露一个/health接口返回 JSON 状态。项目结构非常简单my-app/ ├── app.py ├── requirements.txt └── Dockerfileapp.py长这样from flask import Flask, jsonify app Flask(__name__) app.route(/health) def health(): return jsonify({status: ok}) if __name__ __main__: app.run(host0.0.0.0, port8000)requirements.txt就一行flask3.0.3绑一个明确版本号避免拉取到新版本导致行为漂移。4.1 第一版 Dockerfile先跑起来再说FROM python:3.11-slim WORKDIR /app COPY requirements.txt /app/requirements.txt RUN pip install --no-cache-dir -r requirements.txt COPY app.py /app/app.py EXPOSE 8000 CMD [python, app.py]这一版已经能用了。逐行拆解一下我的考虑基础镜像选python:3.11-slim而不是python:3.11或python:latest。原因前面说了slim 变体体积小去掉了不必要的构建工具链固定 3.11 小版本确保行为一致。WORKDIR 设定在/app之后所有 COPY 都基于这个路径Dockerfile 本身可读性很好。先 COPYrequirements.txt再 RUN pip install最后才 COPYapp.py。这个顺序是有意为之。因为源码文件改动的频率远高于依赖清单如果先把源码全 COPY 进去再安装依赖那么你每次改动 app.py 一行代码构建时 pip install 就得重新跑一遍很浪费。而我这样分两步只有 requirements.txt 内容变化时才会重新执行 pip install其余时候直接命中构建缓存构建速度快非常多。--no-cache-dir是为了避免 pip 把下载的安装包缓存留在镜像里否则每个包都多出一份本地缓存镜像体积会明显变大。CMD 用数组形式直接指定可执行文件不走 shell避免字符串解析的歧义也更安全。构建命令docker build -t my-app:1.0.0 .注意最后这个点它代表构建上下文是当前目录。运行docker run -d -p 8000:8000 --name my-app-demo my-app:1.0.0然后访问http://localhost:8000/health看到{status: ok}说明这个镜像没问题。这段流程相信很多人已经跑通过但别急着走后面有几个细节是实际项目中一定会遇到的。4.2 给容器加上健康检查镜像能跑只是第一步。真到部署环境你希望容器管理器能知道服务是否「真的」活着而不是进程在跑但接口已挂。Dockerfile 里可以用HEALTHCHECK指令声明健康检查HEALTHCHECK --interval30s --timeout3s --start-period5s --retries3 \ CMD python -c import urllib.request; urllib.request.urlopen(http://localhost:8000/health)这里--interval指每 30 秒检查一次--timeout指单次检查超时 3 秒--start-period给服务 5 秒启动缓冲--retries连续失败 3 次判为 unhealthy。这些值要根据服务实际启动速度来调设得太短会在启动慢的服务上造成误报。加了 HEALTHCHECK 后docker ps结果里会多一列健康状态编排工具也会依据这个状态来决定是否重启容器。这个配置成本很低收益很大属于我强烈建议加的选项。4.3 用多阶段构建解决「构建环境 vs 运行环境」的矛盾上面的例子偏简单。假设你的服务需要编译一些 C 扩展甚至是一个前端项目要先 npm 构建再放进 Nginx。这类场景会面临两难构建时需要完整的编译工具链但运行时根本不需要。传统的做法是硬塞进同一个镜像里代价是镜像体积爆炸还有可能把编译器的漏洞也带进生产环境。多阶段构建就是干这个的。核心思路是Dockerfile 里可以写多个 FROM只有最后一个阶段作为最终镜像之前的阶段相当于构建用的工作台。举个例子假设我们的 Flask 服务里有一个依赖需要gcc编译# 第一阶段构建依赖 FROM python:3.11-slim AS builder WORKDIR /app COPY requirements.txt /app/requirements.txt RUN apt-get update apt-get install -y --no-install-recommends gcc build-essential \ pip install --no-cache-dir -r requirements.txt # 第二阶段运行 FROM python:3.11-slim WORKDIR /app COPY --frombuilder /usr/local/lib/python3.11/site-packages /usr/local/lib/python3.11/site-packages COPY app.py /app/app.py EXPOSE 8000 CMD [python, app.py]第一阶段装上 gcc 和编译工具把依赖装好第二阶段用COPY --frombuilder把编译好的依赖直接搬过来然后在这个干净、精简的基础镜像上运行应用。最终生产镜像里没有 gcc没有 apt 缓存的临时文件体积小、攻击面也小。如果说这一步现阶段还用不太上也没关系。关键是理解这个思路Dockerfile 不只是「部署步骤的清单」它完全可以通过多阶段构建实现构建环境和运行环境的彻底分离。只要项目里遇到编译、打包、生成产物这类需求这个方法立刻就能派上用场。5. 构建镜像时最容易踩的四个坑我帮你提前排掉跑通一个简单 Dockerfile 不难难的是跑通之后在团队协作、生产部署里不翻车。下面这几个坑我几乎在每个接手的新项目里都会见到。5.1 CMD 被覆盖导致的诡异启动错误最常见的现象是本地docker run明明没问题但到了某些部署平台、或者有人习惯性在 run 命令后面多加了参数容器启动了又立即退出。本质原因就是前面说的 CMD 被docker run后面的命令覆盖。比如镜像里写的是CMD [python, app.py]如果有人在运行时执行docker run my-app:1.0.0 /bin/bash容器执行的是/bin/bash但因为没加-itbash 没有标准输入直接退出容器也就退出了。部署平台如果默认帮你加了参数就更容易出现这种「看起来镜像有问题」的假象。如果希望容器启动的命令不可被轻易覆盖就把主进程写在 ENTRYPOINT 里ENTRYPOINT [python, app.py] CMD []这样 run 时附加的参数会追加给python app.py而不是替换它。这个设计模式在需要「定死主进程、开放可选参数」的场景下非常有用。5.2 构建上下文太大构建卡到怀疑人生我见过不少人抱怨 docker build 很慢结果一看项目目录里躺着几个 GB 的数据集、node_modules、日志文件。这些文件 Docker 会全部压缩发到守护进程这个传输过程本身就已经慢到离谱。解决办法就一个词.dockerignore。在 Dockerfile 同目录创建一个文件写法和.gitignore一样.dockerignore __pycache__ *.pyc .git .vscode node_modules logs data/ *.md养成习惯项目里只要有 Dockerfile就一定有.dockerignore。这跟项目的.gitignore的地位一样没人会质疑它的必要性。5.3 时区问题日志时间永远差 8 小时容器默认时区是 UTC日志、定时任务里的时间都比本地少 8 小时。如果服务的业务逻辑对时间有依赖这就是个隐性 bug。解决方式是在 Dockerfile 里声明环境变量ENV TZAsia/Shanghai有的基础镜像还需要装tzdata包RUN apt-get update apt-get install -y tzdata \ ln -snf /usr/share/zoneinfo/$TZ /etc/localtime \ echo $TZ /etc/timezone不过装了 tzdata 会增大镜像体积所以只是单纯跑服务ENV TZAsia/Shanghai一般够了。具体以应用框架的时区处理方式为准但养成「容器里显式声明时区」的意识很重要。5.4 用 root 用户跑应用的隐患默认情况下容器内是 root 用户。这意味着一旦容器被攻破攻击者在容器里就是最高权限配合宿主机的权限配置不当后果很容易被放大。如果应用不需要写系统目录或者监听的端口大于 1024非特权端口强烈建议在 Dockerfile 里创建一个普通用户来运行RUN useradd --create-home appuser USER appuser WORKDIR /home/appuser注意 USER 指令要放在需要权限的操作之后比如安装依赖、复制文件之后这样构建阶段该装的装完运行时才用低权限用户。这也是很多官方镜像的默认做法。别嫌麻烦这个习惯会帮你避免不少安全评审时被问到的尴尬。6. 让 Dockerfile 更好用的几个小习惯最后聊几个我在长期使用中沉淀下来的习惯。不是必须但都是能提升幸福感的小细节。一是尽量让构建缓存命中率高。除了前面说的「先 COPY 依赖清单、再 COPY 源码」还可以把「变化频率从低到高」排指令顺序基础镜像、依赖清单、其他低频变化的文件、最后才是源码。这么编排之后多数时候改代码重新构建只需要最后几步重新跑整个过程快很多。二是要会看构建缓存失效的时机。Docker 判断哪一层失效主要看指令本身和涉及的输入文件是否变化。有个细节如果COPY复制了个大目录哪怕只动了一个小文件这一层也会失效它之后的所有层都得重建。所以对不怎么变化的目录可以考虑单独 COPY 出来。像前端构建后的dist/目录如果构建流程没有变化这个内容的指纹基本不变它放在前面就会一直命中缓存。三是镜像构建完成后记得做一次基础验证。比如docker run起来之后至少要看一眼启动日志、访问一次健康检查接口、再docker exec进去确认关键配置项。很多人构建完就推仓库到部署时才发现基础镜像里某个动态库版本不对。多花两分钟验证能省下很多对接时间。四是给镜像打 tag 时尽量带上构建号或 git 提交号。比如my-app:1.0.0-a1b2c3d。这样线上万一出问题你能快速定位到是哪个版本的代码构建的而不用靠「大概昨天构建的那个」这种模糊回忆。配合 CI 流水线这个信息还可以自动生成。还有一个很多人不知道的小技巧Dockerfile 里可以用LABEL org.opencontainers.image.sourcehttps://example.com/project这类标签标注镜像的来源仓库和文档地址。镜像传到仓库之后别人docker inspect一眼能看清这个镜像从哪来的维护体验提升很大。写 Dockerfile 这件事本质上是在把「环境的搭建过程」变成「可版本化、可审查、可复现的文本」。它在初期可能只是加快本地部署到了后期整个交付链路的稳定性都会因此受益。所以我一直觉得每个写服务的人哪怕日常工作用不上容器也值得花上半天把 Dockerfile 的基本功打扎实。顺着这篇文章把基础指令过一遍再自己动手写一份你很快就能体会到这套流程的顺滑之处。
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。