Dockerfile指令详解

Dockerfile的指令不多, 只需要理解几个简单的指令就可以构建一个自定义的镜像. 一个Dockerfile一般具有如下的一些指令, 以下分别介绍这些指令的含义.

1
2
3
4
5
6
7
8
FROM python:3.8-alpine
WORKDIR /app
COPY app/requirements.txt /app/requirements.txt
RUN pip3 install -r requirements.txt
COPY app .
VOLUME [ "/app/config", "/app/data"]
EXPOSE 4231
ENTRYPOINT [ "python3", "app.py"]

指定基础镜像

自定义的镜像可以在一个基础镜像上进行构建, 从而避免一些重复性的基础工作. 使用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
2
RUN apk update && \
apk add --no-cache tzdata

程序入口

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安装依赖, 再复制项目的代码. 如果后续只修改了代码文件, 而没有修改依赖, 则再次构建时, 安装依赖的一层就可以直接复用, 从而节省了构建时间.

判断是否缓存的逻辑是计算上一层的哈希值+本层指令的哈希值. 如果两者均未发生变化, 则本层可以直接复用. 当然, 对于ADDCOPY等涉及从文件内容的指令, Docker也会计算文件的哈希值, 在确保文件也没有发生变化时才会复用对应的层.

扩展阅读

更多关系Dockerfile的详细信息, 可以参考如下的一些资源

Dockerfile多阶段构建

在上一节中以部署Python应用给出了一个Dockerfile的例子, 因为Python是解释执行的, 所以部署相对简单一些. 对于需要编译执行的语言, 其部署就更想对而言更复杂一些. 例如以Java语言为例, 编译Java需要JDK环境, 而运行Java只需要JRE环境. 或者对于更极端的C语言或者Go语言, 编译需要编译环境, 但运行可能不需要任何额外的依赖. 如果直接在编译环境运行程序, 虽然也可以运行, 但镜像体积就太大了.

从实践来看, 把Go语言的编译结果打包一个docker镜像有点冗余了. 但是目前也没有更好的方案实现简单的部署和管理.

对此Dockerfile提供了多阶段构建的能力, 可以分别使用两个镜像来编译和运行程序. 例如如下的Dockerfile分别使用两个镜像来构建Java镜像. 在编译阶段, 使用maven镜像将源码打包为jar, 在运行阶段直接在jre环境运行上一步打包的jar.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
# First stage: complete build environment
FROM maven:3.5.0-jdk-8-alpine AS builder

# add pom.xml and source code
ADD ./pom.xml pom.xml
ADD ./src src/

# package jar
RUN mvn clean package

# Second stage: minimal runtime environment
From openjdk:8-jre-alpine

# copy jar from the first stage
COPY --from=builder target/my-app-1.0-SNAPSHOT.jar my-app-1.0-SNAPSHOT.jar

EXPOSE 8080

CMD ["java", "-jar", "my-app-1.0-SNAPSHOT.jar"]

-在Dockerfile中使用多阶段构建打包Java应用

前后端分离部署

配置前后端项目的编译和运行环境

对于前后端分离的项目, 一般有如下的几个要素

  1. 前端的编译环境和运行环境. 如果使用Vue.js开发, 那么编译过程需要npm环境, 运行过程则只需要nginx代理编译后的静态文件.
  2. 后端的编译环境和运行环境. 如果使用Java开发, 则后端编译过程需要Maven环境, 运行过程则需要JRE环境. 如果使用Python开发, 则因为解释执行只需要对应的Python环境

无论是那种情况, 都可以使用Dockerfile轻松的引入相应的依赖并执行操作

配置文件

由于前端使用nginx代理了静态文件, 所以访问后端的请求需要在配置文件中转发给后端容器. 使用docker-compose时, 可以直接把后端容器名作为域名, 在配置文件中进行转发, 例如

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
# Smart-Todo
upstream backend {
server backend:4231;
}

server {
listen 80;

# 所有的请求默认转发到前端的index文件, 由Vue进行代理
location / {
root /app;
try_files $uri $uri/ /index.html;
}

# API相关的路径是后端的接口, 转发给后端
location /api {
proxy_pass http://backend;
proxy_set_header User-Agent $http_user_agent;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
}

参考资料

Docker镜像构建常见问题

设置时区

经过实践, 相比于使程序支持时区的切换, 还是直接使用本地时间并修改容器的时区最简单. 毕竟程序也不是真的需要支持多时区.

无论是哪种镜像, 都可以通过将Host中的时间配置文件映射到容器中实现修改时区. 即加入映射

1
-v /etc/timezone:/etc/timezone:ro -v /etc/localtime:/etc/localtime:ro

但对于apline镜像, 必须安装了tzdata包后才会生效, 因此还需要在构建镜像的时候执行

1
2
RUN apk update && \
apk add --no-cache tzdata

对于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/