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构建镜像的一个常见的错误操作就是像写Shell脚本一样分多次执行RUN指令. Dockerfile中每执行一行指令, 都会构建一个新的层, 每个层之间是没有关系的, 上一层中创建的文件在下一层中无法删除. 因此分多次执行RUN指令只会产生大量无意义的中间层而浪费空间, 指令之间产生的临时文件也会残留在镜像中, 使得镜像体积增加.

Docker分层的一个作用是缓存, 如果一个层没有发生变化, 则可以直接复用. 例如在上面的例子中, 先复制Python项目的依赖配置文件并使用pip安装依赖, 再复制项目的代码. 如果后续只修改了代码文件, 而没有修改依赖, 则再次构建时, 安装依赖的一层就可以直接复用, 从而节省了构建时间.

扩展阅读

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

Dockerfile多阶段构建

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

对此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应用

docker-compose.yml文件详解

在启动Docker镜像时, 如果镜像的配置比较复杂, 则命令行中需要附带大量的参数才可以启动. docker-compose.yml文件可将相关的配置固化到文件之中, 从而可以一键启动镜像. 此外, 基于yml文件还可以配置多个镜像的依赖关系, 使得我们能够便捷的将一组镜像按照一定规则启动, 组合成我们需要的应用.

一个基本的docker-compose.yml文件通常具有如下的一些属性

1
2
3
4
5
6
7
8
9
10
11
12
version: '3.0'
services:
todo:
container_name: smart-todo
image: ghcr.io/lizec123/smart-todo:latest
restart: always
environment:
TZ: Asia/Shanghai
ports:
- "8080:80"
volumes:
- ./config:/app/config

文件中的各个配置与命令行中的数据基本一一对应, 此处不再赘述

前后端分离部署

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

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

  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 章节

最后更新: 2024年04月24日 15:50

版权声明:本文为原创文章,转载请注明出处

原始链接: 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/