说实话,我第一次被 DockerFile 教育到,是在迁移一个跑了三年的老项目时。项目代码写得再漂亮,到了新机器上照样装依赖装到怀疑人生,系统库缺这个缺那个,环境变量配来配去,折腾了整整两天才把服务重新拉起来。后来我花了一个下午把构建过程整理成几行 DockerFile 指令,从那以后不管换到哪台机器,docker build 加 docker run 两步走完,跑出来的结果和原来一模一样,再也不会出现“代码在我电脑上能跑啊”这类经典对话。
这篇文章就像聊天一样,把 DockerFile 的常用语法、核心指令、一个完整的小项目实战,以及我在实际使用中踩过的坑都捋一遍。内容面向刚接触 Docker 的初学者,也适合写过几个 DockerFile 但总觉得哪里差口气的朋友。如果你之前只是照着别人配置抄,不明白为什么这样写,那这一篇应该能帮你把很多疑惑补上。
1. 先搞清楚 DockerFile 在解决什么问题
1.1 环境不一致是开发者的长期噩梦
我见过太多的项目,成员之间协作时最大的矛盾根本不是代码逻辑,而是环境不一致。A 同事机器上是 Python 3.8,B 同事是 3.11,生产环境又是另一种版本,一跑起来全是奇怪的报错。还有系统级的依赖,比如某些库需要 libssl-dev,有的人系统装了,有的人没装,于是“能跑”和“不能跑”根本不在同一个起跑线上。
DockerFile 就是专门来处理这个问题的。它把“安装什么系统包、用什么语言版本、有哪些环境变量、启动命令是什么”全部写成一个文本文件。然后 Docker 读取这个文件,自动执行每一条指令,构建出一个包含完整运行环境的镜像。镜像一旦构建出来,它里面的环境就是固定的,不管放到哪台机器上运行,结果都一致。
很多人误以为 Docker 是虚拟机,其实不是。Docker 本质上是进程隔离,它共享宿主机的内核,但通过命名空间和控制组把文件系统、网络、进程空间隔开。所以它的体量比虚拟机小得多,启动速度也快很多。DockerFile 做的不是“模拟一台机器”,而是“描述一个应用运行时需要的所有条件”。
1.2 镜像、容器、DockerFile 三者的关系
刚开始接触 Docker 的这些概念时,我也绕了不少弯路。这里用最简单的方式梳理一下三者的关系:
- DockerFile 是“蓝图”。它是一份构建说明书,里面一条一条地写着怎么准备环境、怎么拷贝代码、怎么启动应用。
- 镜像是“模板”。docker build 执行 DockerFile 之后,会把每一步操作的结果固化下来,形成一个静态的、不可修改的镜像文件。
- 容器是“实例”。根据镜像启动一个进程,叫“运行一个容器”。容器是活的,可以启动、停止、删除,你可以在里面做各种操作。
打个比方,DockerFile 就像蛋糕的配方,镜像就像按配方烤出来的蛋糕模型,容器则是从蛋糕模型翻出来的具体蛋糕。你只需要配方,就可以源源不断地烤出同样的蛋糕,不需要每次重新发明配方。
还有一个容易混淆的点:容器是可以被修改的。你在容器里装一个包,然后执行 docker commit,就可以把这个修改过的容器保存成一个新的镜像。虽然这样做也能得到镜像,但我强烈不建议把这个当常规流程。原因很简单:DockerFile 是文本,可以放在代码仓库里做版本管理;而 docker commit 生成的是黑盒,别人根本不知道镜像里面发生了什么改动。能用 DockerFile 描述的就用 DockerFile 描述,这是我一直坚持的原则。
2. DockerFile 核心指令逐条拆解
2.1 FROM:镜像的地基
DockerFile 的第一条有效指令几乎永远是 FROM,它指定了新镜像从哪个基础镜像开始构建。基础镜像可以是一个操作系统,也可以是带好运行时环境的小型镜像。
FROM python:3.9-slim这行指令的意思是:以 Python 3.9 的精简版镜像作为基础,后面所有指令都在这层之上叠加。选基础镜像时我建议优先考虑 slim 或者 alpine 版本,体积小很多,减少攻击面,构建也更快。
点一下 alpine 和 slim 的区别。alpine 镜像非常小,包管理器是 apk,但有些软件在 musl libc 环境下会有兼容问题,尤其是用到一些底层二进制扩展的时候。slim 版本是官方发行版的精简版,绝大多数场景下兼容性更好。我自己的习惯是:默认用 slim,只有在镜像体积要求特别极端时才用 alpine,而且会提前把项目里的所有依赖都测试一遍。基础镜像选错,后面的坑能让你抓到怀疑人生。
还有一点要注意,FROM 可以出现多次,这叫多阶段构建。后面我会专门讲这个技巧,它可以在同一个 DockerFile 里完成“编译”和“运行”两个阶段,最终只保留运行阶段需要的产物。
2.2 RUN、CMD、ENTRYPOINT:最容易搞混的三个指令
这三个指令是新手最容易混淆的地方。RUN 是在构建镜像时执行的命令,比如安装依赖、创建目录、修改配置文件。CMD 和 ENTRYPOINT 则是在容器启动时执行的命令。
RUN 的执行时机特别关键,它发生在 docker build 过程中,每一条 RUN 都会在当前镜像层上执行,把结果固化成新的一层。举个例子:
RUN apt-get update && apt-get install -y curl在构建时执行这两条命令,安装好的 curl 会直接进入镜像。注意我用了 && 连接两条命令,这是刻意的。DockerFile 里每一条 RUN 就是一层,如果你写两条 RUN,就会多生成一个中间层,镜像体积也会变大。能用 && 合并的尽量合并。
CMD 和 ENTRYPOINT 的区别,我总结成一句话:ENTRYPOINT 是“这个容器终究要跑的进程”,CMD 是“给这个进程的默认参数”。
CMD ["python", "app.py"] ENTRYPOINT ["python"]如果只写 CMD,那么 docker run my_image arg 中的 arg 会替换掉整个 CMD。如果只写 ENTRYPOINT,docker run my_image arg 中的 arg 会作为参数追加到 ENTRYPOINT 后面。两者同时写,CMD 的内容会成为 ENTRYPOINT 的默认参数,而且 docker run 时传的新参数可以覆盖 CMD。
我自己常用的方式是这样的:
ENTRYPOINT ["python"] CMD ["app.py"]这样容器默认执行 python app.py,如果需要调试,可以用 docker run my_image -c "import flask; print(flask.version)" 来把 CMD 覆盖成其他参数,非常灵活。记住一点:一个镜像里尽量只用一个进程,不要用 shell 脚本里那种 nohup 方式起一堆后台进程。
2.3 COPY 与 ADD:把文件送进镜像的两种姿势
COPY 和 ADD 都是把宿主机的文件复制到镜像里。区别是 ADD 多了两个额外功能:支持远程 URL 下载,以及能自动解压本地 tar 包。
COPY requirements.txt /app/ ADD app.tar.gz /app/看起来 ADD 功能更强大,但实际工作中我几乎只用 COPY。原因很简单:ADD 的自动解压和下载功能在某些情况下会造成意外行为,尤其是当你只是想把一个压缩包原样拷贝进去,不希望它被解压的时候。而且 ADD 的隐式逻辑让构建过程不那么透明,出了问题更难排查。Docker 官方文档也推荐:普通文件复制优先用 COPY,只有明确需要自动解压时才考虑 ADD。
COPY 还有一个被很多人忽略的特性:它可以配合 .dockerignore 文件使用。就像 .gitignore 一样,.dockerignore 可以指定哪些文件不进入构建上下文。
# .dockerignore __pycache__/ *.pyc .git/ .env venv/ .venv/构建上下文是什么,简单理解就是 docker build 命令后面那个路径目录,Docker 会把整个目录打包发送给守护进程。如果项目里有无用的临时文件、日志目录、甚至几百 MB 的数据集,构建速度会非常慢,还会拖慢你的镜像构建缓存。养成写 .dockerignore 的习惯,对每个项目都有好处。
2.4 WORKDIR、ENV、EXPOSE、VOLUME:决定生活质量的小指令
这四个指令单看都很简单,但组合起来用好了,体验会提升一大截。
WORKDIR 指定工作目录,后续的 RUN、CMD、ENTRYPOINT、COPY 都会在这个目录下执行。如果目录不存在,WORKDIR 会自动创建。我通常在 FROM 之后立刻设置它:
WORKDIR /app有了这行,后面就不用每条指令都写绝对路径了。这里有一个容易忽视的细节:有人习惯用RUN cd /app && ...来切换目录,但这只对当前的 RUN 有效,下一条指令默认目录还是镜像默认目录。所以工作目录一定要用 WORKDIR 声明,而不是在 RUN 里 cd。
ENV 设置环境变量,既影响构建过程,也在容器运行时生效。
ENV PYTHONUNBUFFERED=1 ENV TZ=Asia/ShanghaiPYTHONUNBUFFERED=1这个非常实用。Python 在标准输出不是 tty 的时候会把输出缓冲起来,容器里日志会延迟,排错时非常难受。设置了这个,日志就能实时输出。TZ 则解决时区问题,后面常见问题里我还会详细说。
EXPOSE 只是声明容器运行时监听的端口,是文档性质的。它不会自动映射端口,真正对外提供服务时,docker run 还要用 -p 参数做端口映射。很多人以为写了 EXPOSE 就能从宿主机访问,这是误区。
VOLUME 声明挂载点,用于持久化数据,或者让容器和宿主机共享文件。像数据库类的应用,数据目录几乎必挂:
VOLUME ["/data"]3. 动手实操:把一个 Web 服务打包成镜像
3.1 准备一个最小的示例项目
光说不练没有用,这里我带大家完整走一遍。项目是一个简单的 Web 服务,总共就三个文件:一个 Python 文件、一个依赖清单、一个 .dockerignore。
目录结构:
demo-app/ ├── app.py ├── requirements.txt └── .dockerignoreapp.py 内容:
from flask import Flask app = Flask(__name__) @app.route("/") def index(): return "Hello from DockerFile demo" if __name__ == "__main__": app.run(host="0.0.0.0", port=5000)requirements.txt 内容:
flask==3.0.0.dockerignore 内容:
__pycache__/ *.pyc .env这个项目虽然只有一个接口,但足够演示 DockerFile 的核心流程。
3.2 编写第一版 DockerFile
在项目根目录新建 DockerFile 文件,注意文件名默认就是 DockerFile。也可以叫别的,比如 Dockerfile.dev,但构建时要额外指定文件名,不推荐新手用。
FROM python:3.9-slim WORKDIR /app COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt COPY . . EXPOSE 5000 CMD ["python", "app.py"]逐行解释一下每一部分的意图。FROM 指定基础镜像,WORKDIR 把工作目录切到 /app。这里先 COPY requirements.txt,再执行 pip install,最后 COPY 源码。这个顺序是刻意的,后面讲构建缓存时我会详细说明它带来的收益。RUN 里加了--no-cache-dir,避免 pip 把下载的中间缓存留在镜像里,这个参数能明显减小镜像体积。
EXPOSE 5000 声明端口,CMD 指定启动命令。注意启动命令用了列表形式的 exec 格式,不要写成字符串形式CMD python app.py。exec 格式会直接启动 python 进程,而字符串形式实际上会用 shell 去解析命令,多一层 shell 进程,信号处理和退出逻辑都可能出现问题。
3.3 构建、查看、运行,三步验证
在项目根目录执行:
docker build -t demo-app:v1 .-t 是给镜像打个标签,demo-app 是镜像名,v1 是版本号,最后的点表示构建上下文是当前目录。如果一切顺利,你会看到 Docker 按照 DockerFile 逐条执行指令,每一条执行完都会生成一个层,最后输出类似 “Successfully built” 和镜像 ID。
构建完成之后,可以用 docker images 查看镜像信息:
docker images demo-app运行容器:
docker run -d -p 5000:5000 --name demo demo-app:v1这里 -d 让容器在后台运行,-p 把宿主机的 5000 端口映射到容器的 5000 端口,--name 给容器起个名字。浏览器访问 http://localhost:5000 就能看到返回的内容。
其实这里有一个我一再强调的小细节:-p 是必须的。DockerFile 里的 EXPOSE 只是声明,不写 -p 的话宿主机根本访问不到容器内的服务。很多新手卡在这一步,翻来覆去怀疑 DockerFile 写错了,其实只是没做端口映射。
查看日志用:
docker logs demo停止和删除容器:
docker stop demo docker rm demo3.4 镜像瘦身实战:从基础镜像是第一道关卡
先看一个容易被人忽视的事实:DockerFile 里每一条 RUN 都会新增一层,层多了镜像自然就大。而且每一层都保存了和上一层之间的差异,哪怕你在 RUN 里删除了一个临时文件,也只会增加一层“删除记录”,文件依然占据底层空间。
来做个对比实验。还是那个 demo 项目,看两种写法:
写法一,不合并命令:
FROM ubuntu:22.04 RUN apt-get update RUN apt-get install -y python3 python3-pip RUN pip install flask==3.0.0写法二,合并命令并清理缓存:
FROM python:3.9-slim RUN apt-get update && apt-get install -y --no-install-recommends build-essential \ && pip install --no-cache-dir flask==3.0.0 \ && apt-get purge -y build-essential \ && apt-get autoremove -y \ && rm -rf /var/lib/apt/lists/*写法一每一条指令都生成一层,而且 apt-get update 产生的包索引和 apt 缓存全部留在镜像里,体积很容易超过 400 MB。写法二把所有临时操作合并到一条 RUN,最后还清理了缓存,体积能控制在 200 MB 以内。
多阶段构建是另一个更实用的瘦身技巧。比如你要编译一个 Go 或 C++ 程序,编译阶段需要完整的编译器工具链,但运行阶段只需要编译出来的二进制文件和运行库。多阶段构建可以让你在同一个 DockerFile 里先用一个大的基础镜像完成编译,再把产物复制到一个精简的运行时镜像里。
# 第一阶段:编译 FROM golang:1.21 AS builder WORKDIR /src COPY . . RUN go build -o app . # 第二阶段:运行 FROM debian:stable-slim COPY --from=builder /src/app /usr/local/bin/app CMD ["app"]第二阶段完全没有 Go 工具链,镜像只有 Debian 精简版加一个二进制文件,体积完全可以接受。COPY --from=builder 的作用就是从第一阶段拿文件,这是多阶段构建的核心写法。
另一个常见建议:尽量不去修改容器内部文件。如果项目用到的某些数据文件很大,考虑在运行时通过 volume 挂载,而不是打进镜像。镜像负责保证一致性,数据文件用卷来管理,两者边界划清楚,后续维护会省很多心。
4. 构建缓存机制:指令顺序凭什么重要
4.1 分层的原理
Docker 的镜像是由多个只读层叠起来的。每一条 RUN、COPY、ADD 指令执行完,都会生成一个新的层。这些层按顺序一层叠一层,最终形成一个完整的镜像。
构建时,Docker 会检查当前要执行的指令和之前构建的是否一致。如果基础镜像没变,这条指令之前的所有层也都没变,而且这条指令本身的内容也没变,Docker 就会直接复用之前的层,不再执行这条指令。这时候你会看到日志里有 CACHED 字样。
这个机制和 Git 的增量存储很像,只记录变化的部分。但有一个关键点:只要某一层的输入变化,从这一层开始,后面的所有层都必须重新构建。比如你把一条 RUN 放在了后面,前面 COPY 的代码一改动,后面所有指令全都要重新跑一遍。
4.2 把频繁变更的指令放后面
这正是我在实战 DockerFile 里先 COPY requirements.txt 再 COPY 源码的原因。依赖文件相对于源代码来说,变更频率低得多。如果先 COPY 整个项目目录再执行 pip install,那么代码每改一次,pip install 就要重新执行一次。项目依赖比较多的时候,一次安装可能就是好几分钟,非常浪费时间。
调整后的顺序效果是:requirements.txt 没变,pip install 层直接命中缓存;只有源代码变化时,才重新 COPY 源码一层,随后重启容器,又是秒级完成。
同理,其他项目中出于降低缓存失效频率的目的,最常见的写法是:先复制锁定依赖的文件,然后安装依赖,最后再复制业务代码。这个方法适用任何语言:Node 项目先 COPY package.json 再 npm install,Java 项目先 COPY pom.xml 再 mvn dependency:go-offline。
4.3 合理利用构建缓存的注意事项
利用缓存不只是为了节约时间,还和线上发布的稳定性挂钩。比如你每次都重新构建依赖,那么依赖下载的网络波动、源站的版本更新,都可能导致新镜像和旧镜像不一致。
应对方法也很简单:依赖锁定。Python 项目用 requirements.txt 锁定精确版本号,Node 项目用 package-lock.json。如果再配合 hash 校验,基本能保证每次构建出来的环境是一致的。
踩过的坑也提一下:如果基础镜像本身用了 latest 标签,Docker 会自动拉取最新的基础镜像,缓存就很容易失效。如果你希望稳定可复现,从官方源拉取基础镜像时,请使用具体的版本标签,比如 python:3.9-slim。永远不要在生产环境里使用 latest 标签,你不知道它什么时候会变成什么样子。
还有一个容易被忽略的小问题:构建缓存不是永远有效的,容器里执行的命令如果有副作用,比如往镜像里写入了日期信息,那就可能导致每次构建结果不一致。所以 DockerFile 里要尽量减少这类不确定性的操作。
5. 常见问题与排查技巧实录
5.1 构建时报错 “No such file or directory”
这个报错出现频率非常高。最常见的场景是 DockerFile 里写了COPY ./data /data,但实际上构建上下文里根本没有 data 目录,或者路径写错了。记住 COPY 的路径是相对于构建上下文的,不是相对于 DockerFile 所在位置的。
另一个相关问题是文件在 .dockerignore 里被忽略了。我遇到过同事写了个.dockerignore把dist/忽略掉了,然后 DockerFile 里偏偏又 COPY dist,构建时报找不到。排查时先看构建上下文,再检查 .dockerignore。
排错的通用思路:先在项目里执行ls,确认文件确实存在;再检查 .dockerignore 是否把文件过滤了;最后确认 docker build 时指定的上下文路径是不是项目根目录。
5.2 RUN 指令执行时访问网络失败
如果构建时需要在 RUN 里执行 apt-get install 或 pip install,最常见的表现是超时或者连接失败。可能的原因包括软件源太慢、DNS 解析有问题、网络策略限制。
我常用的处理办法是换一个可靠的镜像源。举例来说,apt 源可以换成国内常用的镜像源,pip 源可以换成阿里云或者腾讯云源。
RUN sed -i 's|/archive.ubuntu.com/|/mirrors.aliyun.com/|g' /etc/apt/sources.list \ && apt-get update \ && apt-get install -y curlpip 可以这么写:
RUN pip install --index-url https://mirrors.aliyun.com/pypi/simple/ -r requirements.txt注意这类改动尽量写到 DockerFile 里,不要临时在容器里改,否则下次构建又得重新改一遍。另外,如果团队有内部镜像仓库,优先使用内网源,稳定性和速度都比公网好。
5.3 CMD 和 ENTRYPOINT 混用时的坑
很多新手写 DockerFile 时,对 CMD 和 ENTRYPOINT 的边界把握不住,导致容器行为诡异。我自己最常遇到的一个问题:明明 CMD 写了CMD ["python", "app.py"],但 docker run image 之后容器里却没有任何进程在跑,进入容器一看根本没有执行。
排查后发现问题出在 shell 格式和 exec 格式的混用上。比如写了CMD python app.py,它等价于CMD ["sh", "-c", "python app.py"]。如果你的 ENTRYPOINT 已经指定了某个程序,CMD 的内容就会作为参数传给 ENTRYPOINT,此时如果 ENTRYPOINT 不是 shell,参数可能没法被解释。
规矩是这样的:如果你希望用户可以通过 docker run 传参数来控制启动行为,就用 ENTRYPOINT 指定主程序,CMD 指定默认参数;如果希望每次启动都执行固定命令,直接 CMD 或者 ENTRYPOINT 都可以,但别混成四不像。实际调试时用docker inspect查看容器的 Config.Cmd 和 Config.Entrypoint 是最直接的。
5.4 容器启动后立刻退出
这是新手最常见的困惑:docker run 之后,容器状态一直是 Exited。原因其实很简单,容器里必须有一个前台进程持续运行,如果那个进程执行完就退出,容器也会跟着退出。
比如你把 app 写成了后台启动方式:
CMD ["sh", "-c", "python app.py &"]这样 python 会被放到后台,sh 立刻执行完毕并退出,容器自然就停掉了。Docker 本质上希望每个容器只跑一个前台进程,这个进程结束,容器就结束。
如果你的服务本身没有守护进程机制,最简单的解决办法是不要加 &,直接用前台方式启动。如果你是为了在容器里同时启动多个服务,更好的做法是用进程管理工具,或者干脆拆成多个容器,用 docker-compose 来编排。我在实践中越来越倾向于后者,Docker 的思路本来就是服务拆开跑。
日志排查也很重要。容器退出前可能已经输出了错误信息,用docker logs <容器名>一般能看到上一次退出时的日志。如果看不到任何输出,再看看是不是应用把日志写到文件而不是标准输出了。
5.5 镜像里的时区不对
默认基础镜像很多是 UTC 时区,直接导致容器里打印的日志时间和本地时间差了 8 个小时。之前排查一个问题时,看容器日志明明显示是凌晨 3 点,但真正出问题的时间是上午 11 点,被时区干扰了好一阵。
解决方式很简单,在 DockerFile 里设置环境变量:
ENV TZ=Asia/Shanghai有些基础镜像没有安装 tzdata 包,只有设置环境变量还不够,还需要安装并生成本地时区文件:
RUN apt-get update && apt-get install -y tzdata \ && cp /usr/share/zoneinfo/Asia/Shanghai /etc/localtime \ && echo "Asia/Shanghai" > /etc/timezone \ && rm -rf /var/lib/apt/lists/*这个处理最好在基础镜像层面解决。如果你所在团队每个月都要写新的服务,建议把这些通用的时区、编码配置做进团队自己的基础镜像里,不用每个项目重复写。
6. 写在最后的实操体会
回头再看,DockerFile 本身不复杂,复杂的是写的时候有没有想清楚每一行到底在干什么。以我自身的体会来说,真正把 DockerFile 用好,核心在三点:一是依赖和代码分离,利用层缓存加速构建和保证稳定;二是尽量使用官方精简基础镜像,配合多阶段构建控制体积;三是持续保持镜像构建的可重复性,任何包都要锁定版本。
我不建议一开始就去追求最极致的优化,先把一个能跑、能复现、能维护的 DockerFile 写出来,再逐步优化体积和构建速度。最后分享一个我个人很常用的小习惯:每次修改 DockerFile 时,都先在本地跑一遍完整构建,确认没问题再提交到代码仓库。镜像这一类的东西,一旦有问题,影响的通常是整个团队的上游流程,多花这几分钟,能省掉后面一大堆麻烦。