怎样写 Dockerfile

2023-02-16, 星期四, 17:57

DevOpsContainer培训

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 版本

考虑镜像层缓存,不常变动的先执行:

  1. 把设置环境变量、创建用户的命令放在最前面
  2. 然后是安装额外需要的依赖
  3. 添加其他构建内容

每一条构建指令都会创建一个镜像层,如果指令执行的结果不会变化,就会利用之前的构建结果(缓存命中),合并指令可以减少镜像层数,加快构建。

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

ADDCOPY 指令都支持添加文件,其中 ADD 支持更多文件来源类型,比如自动提取 tar 包,并且支持使用 URL 获取文件。COPY 只能从本地文件系统中复制文件/文件夹,不过因为更清晰明了反而推荐使用。

ENTRYPOINT ["java", "-jar", "-Dspring.profiles.active=prod", "/app/app.jar"]

CMDENTRYPOINT 指令都是容器运行的命令入口,遵循 COMMAND ["command", "param1", "param2"] 的格式。不过启动 Docker 容器时需要使用 --entrypoint 参数才能覆盖 ENTRYPOINT 指令,而使用 CMD 设置的命令则可以被 docker run 后面的参数直接覆盖。

可以参考的流行项目