Dockerfile指令详解
Dockerfile的指令不多, 只需要理解几个简单的指令就可以构建一个自定义的镜像. 一个Dockerfile一般具有如下的一些指令, 以下分别介绍这些指令的含义.
1 | FROM python:3.8-alpine |
指定基础镜像
自定义的镜像可以在一个基础镜像上进行构建, 从而避免一些重复性的基础工作. 使用FROM指令指定基础镜像, 可以在Docker Hub上查询可用的镜像.
很多镜像的标签都包含一些代号, 常见代号的含义如下
| 名称 | 含义 |
|---|---|
| alpine | 一种体积非常小的操作系统, 本体只有5M |
| slim | 包含指定工具的最小软件包 |
| buster | buster是Debian系统当前稳定版的代号 |
如果希望体积尽可能小, 同时不需要其他依赖, 那么可以选择alpine版本. 如果alpine缺少依赖, 则可以考虑slim版本. 虽然buster版本体积比较大, 但如果其他镜像都不行, 那么buster版本最稳妥.
Debian系统最近的版本代号分别是 Jessie(8.x) / Stretch(9.x) / Buster(10.x) / Bullseye(11.x) / Bookworm(12.x) / Trixie(13.x)
设定工作目录
使用WORKDIR指令指定镜像内的工作目录(相当于shell的当前目录), 后续的操作默认都是在当前工作目录下执行. 例如在上面的Dockerfile中, 将工作目录指定为/app, 那么后续的RUN指令和COPY指令也就将/app路径作为当前目录.
因为Dockerfile每执行一条指令就会创建一个新的层, 所以直接在SHELL上切换路径对后续指令是无效的
复制文件
COPY指令将Host中的文件复制到Guest中, 既可以复制单个文件也可以复制整个文件夹. COPY指令复制文件夹时将Host中的一个文件夹中的全部文件复制到Guest中的指定文件夹中. 因此上面的例子中将Host中的app路径下的所有文件复制到Guest的工作目录中(也就是前面指定的/app)
Dokcerfile中有两个指令可以复制文件, 分别是ADD指令和COPY指令. 两个指令没有太大区别, 一般采用COPY指令.
执行指令
使用RUN指令可以在Guest中执行任意的Shell指令, 例如进行一些参数设置或者安装需要的依赖程序. 由于每行指令都会产生一个新的层, 因此不要用写Shell的思路写RUN指令, 而应该尽可能一次性执行全部指令, 例如
1 | RUN apk update && \ |
程序入口
ENTRYPOINT指定镜像启动后需要执行的程序. 例如上面的例子指定程序启动时执行python指令.
注意:镜像中将直接启动指定的程序而不是用shell启动, 因此并不能执行shell的语法
除了使用ENTRYPOINT指定启动程序以外, 也可以使用CMD指定启动程序. 两者的区别在于ENTRYPOINT指令比较明确的表明这个镜像就应该执行ENTRYPOINT指定的唯一的程序, 而CMD指定的程序则可以在启动镜像的时候直接被命令行上的参数覆盖.
定义数据卷
如果直接在容器内写入数据, 则数据保存在容器内部. 如果容器被删除, 那么对应的数据也就一起被删除了. 使用数据卷可以将数据写入到Host的文件系统中, 从而使容器变为无状态的应用, 可以随意的创建和删除. 使用VOLUME定义镜像的数据卷, 在容器启动后, 如果用户没有手动挂载这些数据卷则自动挂在一个匿名数据卷.
用户在启动时当然还是可以用命令直接覆盖这些配置, 挂载一个命名的数据卷
声明端口
使用EXPOSE可以声明容器想要暴露的端口. 这个指令是一个纯粹的声明, 没有任何效果, 仅仅用于提示用户这个镜像希望暴露的服务端口.
深入原理:分层与联合文件系统
Docker的镜像由一系列只读层构成, 每一层只包含相对于其下一层的差异内容, 并且这些层都是只读的, 一旦完成就不可修改. 最终Docker使用联合文件系统将所有的层合并到一起, 形成一个逻辑上完整的文件系统.
使用这种方式可以更好的共享和复用资源, 例如许多镜像可能都依赖ubuntu:22.04镜像, 那么本地只需要存储一份该镜像, 即可在所有的镜像文件中共享.
在启动镜像后, 实际上是附加了一个可写的容器层, 所有对文件的修改都发生在这个容器层. 因此当容器被删除后, 所有的修改也就一起删除了. 这也是要手动挂载数据卷来实现持久化的原因.
深入原理:层和缓存
使用Docker构建镜像的一个常见的错误操作就是像写Shell脚本一样分多次执行RUN指令. Dockerfile中每执行一行指令, 都会构建一个新的层, 每个层之间是没有关系的, 上一层中创建的文件在下一层中无法删除. 因此分多次执行RUN指令只会产生大量无意义的中间层而浪费空间, 指令之间产生的临时文件也会残留在镜像中, 使得镜像体积增加.
Docker分层的另一个作用是缓存, 如果一个层没有发生变化, 则可以直接复用. 例如在上面的例子中, 先复制Python项目的依赖配置文件并使用pip安装依赖, 再复制项目的代码. 如果后续只修改了代码文件, 而没有修改依赖, 则再次构建时, 安装依赖的一层就可以直接复用, 从而节省了构建时间.
判断是否缓存的逻辑是计算上一层的哈希值+本层指令的哈希值. 如果两者均未发生变化, 则本层可以直接复用. 当然, 对于ADD和COPY等涉及从文件内容的指令, Docker也会计算文件的哈希值, 在确保文件也没有发生变化时才会复用对应的层.
扩展阅读
更多关系Dockerfile的详细信息, 可以参考如下的一些资源
Dockerfile多阶段构建
在上一节中以部署Python应用给出了一个Dockerfile的例子, 因为Python是解释执行的, 所以部署相对简单一些. 对于需要编译执行的语言, 其部署就更想对而言更复杂一些. 例如以Java语言为例, 编译Java需要JDK环境, 而运行Java只需要JRE环境. 或者对于更极端的C语言或者Go语言, 编译需要编译环境, 但运行可能不需要任何额外的依赖. 如果直接在编译环境运行程序, 虽然也可以运行, 但镜像体积就太大了.
从实践来看, 把Go语言的编译结果打包一个docker镜像有点冗余了. 但是目前也没有更好的方案实现简单的部署和管理.
对此Dockerfile提供了多阶段构建的能力, 可以分别使用两个镜像来编译和运行程序. 例如如下的Dockerfile分别使用两个镜像来构建Java镜像. 在编译阶段, 使用maven镜像将源码打包为jar, 在运行阶段直接在jre环境运行上一步打包的jar.
1 | # First stage: complete build environment |
前后端分离部署
配置前后端项目的编译和运行环境
对于前后端分离的项目, 一般有如下的几个要素
- 前端的编译环境和运行环境. 如果使用Vue.js开发, 那么编译过程需要npm环境, 运行过程则只需要nginx代理编译后的静态文件.
- 后端的编译环境和运行环境. 如果使用Java开发, 则后端编译过程需要Maven环境, 运行过程则需要JRE环境. 如果使用Python开发, 则因为解释执行只需要对应的Python环境
无论是那种情况, 都可以使用Dockerfile轻松的引入相应的依赖并执行操作
配置文件
由于前端使用nginx代理了静态文件, 所以访问后端的请求需要在配置文件中转发给后端容器. 使用docker-compose时, 可以直接把后端容器名作为域名, 在配置文件中进行转发, 例如
1 | # Smart-Todo |
参考资料
Docker镜像构建常见问题
设置时区
经过实践, 相比于使程序支持时区的切换, 还是直接使用本地时间并修改容器的时区最简单. 毕竟程序也不是真的需要支持多时区.
无论是哪种镜像, 都可以通过将Host中的时间配置文件映射到容器中实现修改时区. 即加入映射
1 | -v /etc/timezone:/etc/timezone:ro -v /etc/localtime:/etc/localtime:ro |
但对于apline镜像, 必须安装了tzdata包后才会生效, 因此还需要在构建镜像的时候执行
1 | RUN apk update && \ |
对于Ubuntu之类的系统, 更简单的方式是直接指定环境变量TZ=Asia/Shanghai实现时区的修改.
MySQL镜像配置
为了使镜像可以被远程连接, 可以设置如下的环境变量
1 | ENV MYSQL_ROOT_PASSWORD="123456" MYSQL_ROOT_HOST=% |
如果希望MySQL镜像在第一次启动时执行指定的初始化脚本, 可以将文件复制到/docker-entrypoint-initdb.d, 例如
1 | COPY sql/ /docker-entrypoint-initdb.d/ |
可以参考Docker Hub上的文档的 Initializing a fresh instance 章节
最后更新: 2026年04月18日 21:19
版权声明:本文为原创文章,转载请注明出处
原始链接: https://lizec.top/2021/08/07/Docker%E7%AC%94%E8%AE%B0%E4%B9%8B%E6%9E%84%E5%BB%BA%E9%95%9C%E5%83%8F/