Dockerfile 也是一种代码:
- 用版本控制来管理 Dockerfile
- 如果偏离常规实践,请写注释解释说明
- 命令太长的时候考虑换行和缩进
本文将借助如下这个例子说明如何编写一个还算说的过去的 Dockerfile:
FROM eclipse-temurin:17.0.3_7-jre-alpine
RUN addgroup -S app && adduser -S app -G app
RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories
RUN apk add -U tzdata
ENV TZ=Asia/Shanghai
RUN cp /usr/share/zoneinfo/Asia/Shanghai /etc/localtime
USER app:app
WORKDIR /app
RUN mkdir -p /app/logs/
VOLUME /app/logs/
EXPOSE 8080
ARG JAR_FILE
COPY ${JAR_FILE} /app/app.jar
ENTRYPOINT ["java", "-jar", "-Dspring.profiles.active=prod", "/app/app.jar"]
一次做一件事,一个容器负责一个应用就好,有依赖的容器可以用 docker compose 连起来。
FROM eclipse-temurin:17.0.3_7-jre-alpine
选择合适的镜像基底,在满足程序运行的情况下只保留需要的内容。比如运行 Java 程序有 JRE 就行,不需要 JDK。我这里挑选了 eclipse-temurin
因为开发用的就是 Temurin JDK。
Docker 曾经宣传过 alpine 作为基础镜像,主要卖点之一就是通过 busybox 和特供 lib 大幅缩小了体积。虽说如此如果你遇到了一些奇奇怪怪的兼容问题,还是可以回到主流发行版 LTS 的基底上来的。毕竟在 Docker 镜像分层缓存能力的加持下,基础镜像的额外体积也就在头一回拉取的时候作一次祟,不是需要优先考虑的东西。
不错的选择有:
debian:buster-slim
- Ubuntu/CentOS/Fedora 的一些 LTS 版本
考虑镜像层缓存,不常变动的先执行:
- 把设置环境变量、创建用户的命令放在最前面
- 然后是安装额外需要的依赖
- 添加其他构建内容
每一条构建指令都会创建一个镜像层,如果指令执行的结果不会变化,就会利用之前的构建结果(缓存命中),合并指令可以减少镜像层数,加快构建。
RUN addgroup -S app && adduser -S app -G app
...
USER app:app
在早期版本的 Docker 中必须以 root 权限启动容器,出于安全考虑推荐创建专门的用户和用户组,然后切换到该用户身份执行后续的 RUN
, CMD
以及 ENTRYPOINT
命令。
使用 Podman 等支持 rootless 模式的容器运行时则不需要添加该命令,不过最好在使用 podman run
时添加 --userns=keep-id
参数。
RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories
容器内如果要安装依赖项,先设置镜像源。这里把 alpine 的软件源换成阿里云镜像。
如果是 CentOS 基础镜像,可以在构建目录下编写 repo 文件,然后添加命令:
COPY CentOS7-Base-mirror.repo /etc/yum.repos.d/CentOS7-Base.repo
RUN apk add -U tzdata
ENV TZ=Asia/Shanghai
RUN cp /usr/share/zoneinfo/Asia/Shanghai /etc/localtime
需要正确设置时区,否则就会使用默认的 UTC 时间。Red Hat 系基础镜像可以使用:
RUN ln -sf /usr/share/zoneinfo/Asia/Shanghai /etc/localtime
而 Debian 系基础镜像可以使用:
RUN ln -sf /usr/share/zoneinfo/Asia/Shanghai /etc/localtime
RUN echo "Asia/Shanghai" >> /etc/timezone
另一个比较好的不限发行版的方式是以只读模式挂载宿主机的 /etc/localtime
:
podman run \
...
-v /etc/localtime:/etc/localtime:ro \
...
WORKDIR /app
Dockerfile 不等于 shell script,每一条 RUN
指令相当于创建了新的镜像层,可以认为相互之间在 shell session 的层面上互不相关,因此如下面这样编辑文件并不会产生 /app/world.txt
。
RUN cd /app
RUN echo "hello" > world.txt
正确的做法是使用:
WORKDIR /app
RUN echo "hello" > world.txt
构建过程会先检查 /app
是否存在,并在后续构建中使用 /app/
作为默认路径。
RUN mkdir -p /app/logs/
VOLUME /app/logs/
为了防止运行时用户忘记将动态文件所保存的目录挂载为卷,可以事先指定某些目录挂载为匿名卷,这样在运行时如果用户不指定挂载,其应用也可以正常运行,不会向容器存储层写入大量数据。
这里的 /app/logs
目录就会在容器运行时自动挂载为匿名卷,向 /app/logs
中写入的信息不会记录进容器存储层,从而保证了容器存储层的无状态化。运行容器时可以覆盖这个挂载设置,比如使用命名卷 applog
挂载到 /app/logs
位置替代定义中的匿名卷挂载配置。
docker run -d \
-v applog:/app/logs \
...
EXPOSE 8080
EXPOSE
指令声明容器运行时将会提供服务的端口,允许一次传递多个端口号,注意这不代表 Docker 会自动关联宿主机的端口。另外如果在 docker run
时允许使用随机端口映射 -P
,会随机到宿主机的某个端口。
ARG JAR_FILE
ARG
所指向的环境变量仅在构建阶段发挥作用,但是不要因此拿来保存密码,因为 docker history
还是可以看到值的。Dockerfile 中的 ARG
指令可以定义参数名称及其默认值,该默认值可以在 docker build
时用 --build-arg <key>=<value>
覆盖。
本例所属的项目使用 com.spotify:dockerfile-maven-plugin
构建测试镜像,JAR_FILE
用来传递目标制品路径。
COPY ${JAR_FILE} /app/app.jar
ADD
和 COPY
指令都支持添加文件,其中 ADD
支持更多文件来源类型,比如自动提取 tar 包,并且支持使用 URL 获取文件。COPY
只能从本地文件系统中复制文件/文件夹,不过因为更清晰明了反而推荐使用。
ENTRYPOINT ["java", "-jar", "-Dspring.profiles.active=prod", "/app/app.jar"]
CMD
和 ENTRYPOINT
指令都是容器运行的命令入口,遵循 COMMAND ["command", "param1", "param2"]
的格式。不过启动 Docker 容器时需要使用 --entrypoint
参数才能覆盖 ENTRYPOINT
指令,而使用 CMD
设置的命令则可以被 docker run
后面的参数直接覆盖。
可以参考的流行项目