Dockerfile编写实践篇

Docker通过一种打包和分发的软件,完成传统容器的封装。这个用来充当容器分发角色的组件被称为镜像。Docker镜像是一个容器中运行程序的所有文件的捆绑快照。当使用Docker分发软件,其实就是分发这些镜像,并在接收的机器上创建容器。镜像在Docker生态系统中是可交付的基本单位。

镜像的创建方式有很多种,如使用docker commit命令根据当前容器的更改创建一个新的镜像,也可以使用docker save保存镜像或使用docker export导出镜像。但是,最常用、最推荐的方式,还是使用Dockerfile定义镜像,然后使用docker build命令创建镜像。

Dockerfile使用基本的基于DSL语法的指令来定义一个Docker镜像,之后就可以使用docker build命令基于该Dockerfile中的指令构建一个新的镜像。Dockerfile具有信息表达性,且易于理解,这些要归功于Dockerfile支持注释的简洁语法。Dockerfile构建程序自身使用缓存技术来解决快速开发和迭代带来的问题。这个构建过程可追踪且可重用。它们能够很简单地和现有的构建系统、持续构建和集成工具一起工作。

Dockerfile编写及使用

Dockerfile是一个文件,它由构建镜像的指令组成,用户可以使用Dockerfile来快速创建自定义的镜像。指令由Docker镜像构建者自上而下排列,能够被用来修改镜像的任何信息。

Dockerfile具有信息表达性,且易于理解,这些都要归功于Dockerfile支持注释的简洁语法。开发者可以使用任何版本控制工具来跟踪Dockerfile文件的变动。维护多个版本的镜像就和管理多个Dockerfile一样简单。

基本结构

Dockerfile由一系列指令和其参数组成,并且支持以#开头的注释行。每条指令都建议使用大写字母,且后面跟随一个参数。Dockerfile中指令的基本使用可以参考笔者Dockerfile指令大全一文,更详细的指令使用说明建议参考官网Dockerfile reference一文。

Dockerfile中的指令会按顺序从上到下执行,所以应根据需要合理安排指令的顺序。Docker大体上按照如下流程执行Dockerfile中的指令:

(1) Docker从基础镜像运行一个容器。

(2) 执行一条指令,对容器做出修改。

(3) 执行类似docker commit的操作指令,提交一个新的镜像层。

(4) Docker再基于刚提交的镜像运行一个新容器。

(5) 执行Dockerfile中的下一条指令,直到所有指令都执行完毕。

如果Dockerfile由于某些原因(如指令执行失败)没有正常结束,那么仍将得到一个可用的镜像,只是最后一条指令执行失败,这对日常的开发和调试是有帮助的。

一般而言, Dockerfile 主体内容分为四部分:基础镜像信息、制作者信息、镜像操作指令和容器启动时执行指令。但是,考虑到容器安全,还有必要对其进行安全加固,如配置用户或用户组等。此外,构建基础镜像时,有时还需要考虑继承的镜像需要执行的一些默认操作,如创建一组默认的用户和用户组供子镜像使用。

基础镜像信息

在编写Dockerfile文件时,第一个要考虑的事情就是创建一个基础镜像供他人使用,或是基于基础镜像去构建一个新的镜像。这些诉求都可以通过FROM指令实现。一般情况下,FROM指令是一个Dockerfile的第一条指令。

FROM指令用来指定一个父镜像,以开始新的构建阶段。Dockerfile支持在一个文件中使用多个FROM指令,以创建多个镜像。FROM的指令格式如下:

FROM [--platform=] [:|@|-] [AS ]

其中,--platform参数用来指定镜像应用的平台,主要应用于多平台场景,如linux/amd64或linux/arm64等;tag和digest用来指定需要引用的镜像的tag,如果不指定,则使用latest。如果一个Dockerfile中需要创建多个镜像,会使用到多个FROM。如果下一个FROM指令中需要使用上一个FROM指令构建的镜像,可以现在上一个FROM指令中定义别名,也即使用AS 。

某些场景下,不需要父镜像,如构建一个操作系统镜像,这时可以使用"FROM scratch"这个指令来表示不需要父镜像。

制作者信息

对于一个镜像,无论是镜像开发者还是镜像的继承者,有时需要直到这个镜像的作者相关的信息。对于这个需求,可以通过MAINTAINER指令或LABEL指令来指定镜像作者信息。这里不推荐使用MAINTAINER指令,更推荐LABEL指令。相比MAINTAINER指令,LABEL指令会将这部分信息保存到元数据,这个就可以通过docker image inspect命令方便的查询该部分信息。示例如下:

bash 复制代码
$ docker image inspect --format='{{json .Config.Labels}}' target-image-name-or-id

使用LABEL记录镜像制作者信息的示例如下:

dockerfile 复制代码
# Base image to use, this must be set as the first line
FROM ubuntu:18.04

# Maintainer: docker user <docker_user@email.com>
LABEL maintainer docker_user<docker_user@email.com>

...

上述示例中,首先使用FROM指令指明所基础镜像的名称,接下来就是使用LABEL指令说明制作者信息。这里,制作者信息并不是一个必须的信息,但建议都添加上。注意,这个Dockerfile只是编写了一部分,还不算一个完整的Dockerfile。

镜像操作指令

Dockerfile文件的主体部分就是基于基础镜像的进一步操作,如使用RUN指令执行特定的命令,使用ADD/COPY指令将特定的产物复制到容器文件系统,等等。如这里使用RUN指令下载软件包,然后将构建上下文中的基于Java源码生成的JAR包复制到容器文件系统的示例如下:

dockerfile 复制代码
# Base image to use, this must be set as the first line
FROM ubuntu:18.04

# Maintainer: docker user <docker_user@email.com>
LABEL maintainer docker_user<docker_user@email.com>

RUN apt-get update \
&& apt-get install -y libsnappy-dev \ 
&& rm -rf /var/cache/apt

...

上述示例中,使用RUN指令下载特定的软件包,并在下载完毕后,删除了遗留的缓存。每运行一条RUN指令,镜像添加新的一层,所以这里是将在多个RUN指令执行的命令合并成了一行。同样的,到这个阶段,Dockerfile也只是编写了一部分,还不算一个完整的Dockerfile。

容器启动时执行指令

容器启动时,可以指定默认执行的命令或指定默认的可执行文件。这对于提供后端服务的应用来说很有必要。如需要在容器中运行一个Java Web应用,可以在容器启动时指定可执行的shell脚本,以运行一个Java应用。对于这个需求,可以通过CMD指令或ENTRYPOINT指令来实现。

CMD指令和ENTRYPOINT指令均用来定义启动容器时需要执行的命令,推荐优先使用ENTRYPOINT指令。对于同时出现CMD指令和ENTRYPOINT指令的场景,遵循如何规则:

No ENTRYPOINT ENTRYPOINT exec_entry p1_entry ENTRYPOINT ["exec_entry", "p1_entry"]
No CMD error, not allowed /bin/sh -c exec_entry p1_entry exec_entry p1_entry
CMD ["exec_cmd", "p1_cmd"] exec_cmd p1_cmd /bin/sh -c exec_entry p1_entry exec_entry p1_entry exec_cmd p1_cmd
CMD exec_cmd p1_cmd /bin/sh -c exec_cmd p1_cmd /bin/sh -c exec_entry p1_entry exec_entry p1_entry /bin/sh -c exec_cmd p1_cmd

上表中,对同时存在CMD指令和ENTRYPOINT指令的场景,要根据指令使用的exec模式或shell模式,进行如下划分:如果ENTRYPOINT指令是shell模式,则会忽略CMD指令;如果ENTRYPOINT指令是exec模式,CMD指令是exec模式,则CMD指令的命令失效,参数会追加到ENTRYPOINT指令上;如果ENTRYPOINT指令是exec模式,CMD指令是shell模式,则先执行ENTRYPOINT指令,再执行CMD指令。

使用示例如下:

dockerfile 复制代码
# Base image to use, this must be set as the first line
FROM ubuntu:18.04

# Maintainer: docker user <docker_user@email.com>
LABEL maintainer docker_user<docker_user@email.com>

# COPY and rename application jar file to the container's filesystem
COPY app-*.jar /app.jar

# EXECUTE jar file
ENTRYPOINT ["java", "-jar", "/app.jar"]

上述示例中,使用COPY指令将构建上下文中的软件包复制到容器的文件系统。然后,使用ENTRYPOINT声明容器启动时需要执行的命令。这样当容器启动时,就可以运行JAR包,从而启动这个应用。到这个阶段,一个基本的Dockerfile文件就编写完了。

用户权限加固

默认情况下,容器以root用户运行,root用户权限太高,对于业务应用来说,存在安全风险,如常见的容器逃离手段,都是依赖于获得容器的root权限。对于用户权限分配来说,最佳实践就是尽可能地消减用户的特权,也即遵循最小权限原则。当一个Docker用户创建容器时,是能够覆盖镜像的默认配置的。因此,并不存在某个方法来完全防止容器以root用户运行。但是,镜像的制作者可以创建非root用户,并以非root用户来执行应用。

Dockerfile通过提供USER指令、docker run指令或docker create指令来设置用户和用户组,从而限制用户的访问。推荐使用USER指令设置用户和用户组,使用docker run命令后docker create指令来更改设置用户和用户组。这里重点介绍下使用USER指令设置用户和用户组。示例如下:

dockerfile 复制代码
# Base image to use, this must be set as the first line
FROM ubuntu:18.04

# Maintainer: docker user <docker_user@email.com>
LABEL maintainer docker_user<docker_user@email.com>

# Creat application directory
RUN mkdir -p /opt/app

# COPY and rename application jar file and set new owner and mode to the container's filesystem
COPY --chown=1000:1000 --chmod=644 app-*.jar /opt/app/app.jar

# SET new user id and group id
USER 1000:1000

# EXECUTE jar file
ENTRYPOINT ["java", "-jar", "/opt/app/app.jar"]

这里,要注意把握设置用户UID和GID的时机。如果过早地设置,可能导致当前用户没有权限完成Dockerfile中的其他指令。如需要执行一个root目录下的文件,但是先使用USER指令设置了用户UID和GID,则会导致当前用户权限过低,导致root目录下的文件执行失败。示例如下:

dockerfile 复制代码
# Base image to use, this must be set as the first line
FROM ubuntu:18.04

# SET new user id and group id
USER 1000:1000

# Creat application directory
RUN mkdir -p /opt/app

因为/opt目录属于root用户,所以不能在UID是1000的用户中在/opt目录创建新目录。

注意,如果是编写一个基础镜像的Dockerfile文件,则不建议遵循最小权限原则,而是尽量提供root权限,让业务镜像去考虑最小权限的问题。

注入下游镜像在构建时发生的操作

在构建基础镜像时,有时需要考虑继承的镜像需要执行的一些默认操作,如创建一组默认的用户和用户组供子镜像使用。对于这个需求,可以通过ONBUILD指令实现。ONBUILD指令不会在包含它们的Dockerfile被构建时执行。这些指令会被记录在生成镜像的元数据ContainerConfig.OnBuild下。这个元数据会一直被保留,直到生成的镜像被另外的Dockerfile作为基础镜像。这样,当子镜像构建时,ONBUILD后跟随的指令将会在FROM指令后,下一条指令前被执行。示例如下:

dockerfile 复制代码
# Base image to use, this must be set as the first line
FROM ubuntu:18.04

# Maintainer: docker user <docker_user@email.com>
LABEL maintainer docker_user<docker_user@email.com>

# ONBUILD command 
ONBUILD RUN echo "This is an ONBUILD trigger."  

# 其他的设置或命令
...

编写Dockerfile文件

介绍完了编写Dockerfile的基本结构,接下来就根据真实的业务场景,介绍下如何编写特定于业务场景的Dockerfile文件。由于笔者目前主要参与的是Java Web后端应用的开发,所以这里重点介绍下一个商用Java Web后端应用的Dockerfile的编写示例,其他场景的Dockerfile编写示例还请自行学习。

首先是基础镜像的选择。对于一个业务服务来说,为了保证业务服务不依赖内核的操作系统,在基础镜像的选择上,可以使用一个简化版本的操作系统。这个操作系统是一个精简版本,只包含操作系统自带的一些功能。一些软件包的安装,如python、jdk等,均留给业务镜像去按需补充。接着是JRE的版本选择。注意,在Java应用运行时,只有JRE即可。目前,主流的Java版本还是Java 8和Java 11。不同公司根据业务需要选择合适的JDK版本。对于基于Java的业务服务来说,可以使用精简操作系统+JRE构成的基础镜像。

Java Web后端应用依赖的基础镜像已经选择完毕,接下来就是把Java Web后端应用依赖的工具安装到操作系统。对于不同的Java应用,可能会使用到一些实用工具。如对网络服务来说,可能使用nmap来进行网络发现。

考虑完Java Web后端应用依赖的工具,下面要考虑的就是Java Web后端应用的lib的依赖。使用Maven进行打包时,默认情况下不会将依赖包打入jar中。但是,可以通过配置Maven插件来将依赖包和项目本身打包到一个jar文件中。这样,无论是二方库,还是三方库,这部分依赖都会作为Java Web后端应用构建产物的一部分。对于基于Spring Boot构建的Java Web后端应用来说,其依赖会自动打包到jar文件中,无需特殊处理。

对于Java Web后端应用来说,容器启动后,需要启动一个进程在运行Java Web后端应用。这个应用会一直活跃,直到容器意外中止或容器正常终止为止。也即是说,容器启动后,要立即执行Java Web后端应用。为了聚合启动Java Web后端应用相关的操作,使用shell脚本来编写启动相关的命令。这样,在容器启动时,只需要执行下shell脚本即可。

编写一个Java Web后端应用的主要事项都已考虑完毕,接下来简单描述下构建上下文的目录结构:

text 复制代码
--docker              # docker目录,存储docker镜像制作相关资源
----package             # package目录,存储业务应用打包相关资源
------start.sh            # start.sh,用来启动业务服务
------service.jar         # service.jar,业务服务代码构建的jar
--  app.dockerfile      # app.dockerfile,业务服务的Dockerfile,描述构建镜像的过程

介绍完构建上下文的目录结构,接下来说明下app.dockerfile文件中的内容:

dockerfile 复制代码
# 使用centos操作系统 + jre(Java 1.8)的基础镜像,镜像仓库并不包含该镜像,可以基于centos镜像制作一个包含jre的镜像
FROM centos_jre_1.8:centos8_jar8

# 记录镜像的制作者,方便后期的运维
LABEL maintainer custom_java_web<custom_java_web@email.com>

# jar包和运行jar包的shell脚本等复制到容器中
## 将构建上下文的package目录下的内容拷贝到/opt/app目录下,注意这里并没有预先在容器的文件系统创建/opt/app/目录
COPY package  /opt/app/  
## 对于可执行文件,在使用前,赋予可执行权限
RUN chmod 544 /opt/app/start.sh \
&& chmod 544 /opt/app/start.jar

# 设置ENTRYPOINT来直接执行脚本  
ENTRYPOINT ["/opt/app/start.sh"]

这样,一个Java Web应用的Dockerfile文件就编写完毕了。考虑到安全问题,还有必要调整下用户,使用USER指令改造后的Dockerfile文件如下:

dockerfile 复制代码
# 使用centos操作系统 + jre(Java 1.8)的基础镜像,镜像仓库并不包含该镜像,可以基于centos镜像制作一个包含jre的镜像
FROM centos_jre_1.8:centos8_jar8

# 记录镜像的制作者,方便后期的运维
LABEL maintainer custom_java_web<custom_java_web@email.com>

# jar包和运行jar包的shell脚本等复制到容器中
## 将构建上下文的package目录下的内容拷贝到/opt/app目录下,注意这里并没有预先在容器的文件系统创建/opt/app/目录
COPY package  /opt/app/  
## 对于可执行文件,在使用前,赋予可执行权限
RUN chown -R 1000:1000 /opt/app \  
&& chmod 644 /opt/app \
&& chmod 544 /opt/app/start.sh \
&& chmod 544 /opt/app/start.jar

# 设置用户及用户组
USER 1000:1000

# 设置ENTRYPOINT来直接执行脚本  
ENTRYPOINT ["/opt/app/start.sh"]

在ENTRYPOINT指令中,使用exec格式执行可执行文件时,不会启动一个shell进程,docker会直接运行可执行文件,这里是start.sh。需要注意的是,这里的start.sh脚本的第一行应该是#!/bin/sh或者其它希望用来执行脚本的解释器路径。start.sh脚本的示例如下:

bash 复制代码
#!/bin/bash

# 指定JAR包的路径
JAR_PATH="/opt/app/service.jar"

# 使用nohup在后台启动JAR包,并将输出重定向到日志文件
nohup java -jar "$JAR_PATH" &

使用docker build命令生成镜像

编写完Dockerfile文件后,接下来就是使用docker build命令构建镜像。docker build命令的基本格式如下:

bash 复制代码
docker image build [OPTIONS] PATH | URL | -

该命令将读取指定路径下(包括子目录)的Dockerfile,并将该路径下所有数据作为构建上下文(Context)发送给 Docker服务器端。Docker服务端在校验Dockerfile格式通过后,逐条执行其中定义的指令,如果碰到ADD、COPY和RUN指令则会生成一层新的镜像。最终如果创建镜像成功,会返回最终镜像的ID。

在构建过程中会有新层被加入到要产生的镜像中。这不仅意味着开发者能够从任意一步开始创建分支,更重要的是构建过程能够缓存每一步的结果,当运行完几个指令后,如果下一条指令出现问题,构建过程能够在问题被修复后,从同一步重新启动。如果构建过程需要下载资源或包含某些需要消耗大量时间的任务,那么缓存可以起到节省时间的效果。如果需要从零开始构建,可以使用--no-cache选项来禁止缓存的使用。

将执行目录切换到上述目录结构的docker目录下,上述示例的dockerfile文件,就可以使用如下的docker build命令生成镜像:

bash 复制代码
$ docker build -f app.docerfile -t service-image:202403102100 .

在docker build命令执行完毕后,如果镜像制作者可以看见"Successfully built XXX"字样,则说明镜像构建成功。且会生成一个service-image的镜像,镜像的版本是202403102100。可以使用docker images查看该镜像,或使用docker start命令启动该镜像。

Dockerfile最佳实践

所谓Dockerfile最佳实践,就是从需求出发,来定制适合当前业务场景、高效方便的镜像。首先,要尽量吃透每个指令的含义和执行效果,多编写一些简单的例子进行测试,弄清楚了再撰写正式的Dockerfile文件。此外,Docker Hub官方仓库中提供了大量的优秀镜像和对应的Dockefile,可以通过阅读它们来学习如何撰写高效的Dockerfile。

这里梳理一些实践经验。建议读者在生成镜像过程中,尝试从如下角度进行思考,完善所生成镜像:

(1) 精简镜像用途:尽量让每个镜像的用途都比较集中单一,避免构造大而复杂、多功能的镜像。

(2) 选用合适的基础镜像:容器的核心是应用。选择过大的父镜像(如Ubuntu系统镜像)会造成最终生成应用镜像的膝肿,推荐选用瘦身过的应用镜像(如node:slim),或者较为小巧的系统镜像(如alpine、busybox或debian)。

(3) 提供必要的注释和维护者信息: Dockerfile也是一种代码,需要考虑方便后续的扩展和他人的使用。

(4) 正确使用版本号:使用明确的版本号信息,如1.0,2.0,而非依赖于默认的latest。通过版本号可以避免环境不一致导致的问题。

(5) 减少镜像层数:如果希望所生成镜像的层数尽量少,则要尽量合并RUN、ADD和COPY指令。通常情况下,多个RUN指令可以合并为一条RUN指令。

(6) 恰当使用多步骤创建:通过多步骤创建,可以将编译和运行等过程分开,保证最终生成的镜像只包括运行应用所需要的最小化环境。当然,用户也可以通过分别构造编译镜像和运行镜像来达到类似的结果,但这种方式需要维护多个Dockerfile。

(7) 使用.dockerignore文件:使用该文件可以标记在执行docker build时忽略的路径和文件,避免发送不必要的数据内容,从而加快整个镜像创建过程。

(8) 及时删除临时文件和缓存文件:特别是在执行 apt-get 指令后,/var/cache/apt下面会缓存了一些安装包。

(9) 提高生成速度:如合理使用 cache, 减少内容目录下的文件,或使用.dockerignore 文件指定等。

(10) 调整合理的指令顺序:在开启cache的情况下,内容不变的指令尽量放在前面,这样可以尽量复用。

(11) 减少外部源的干扰:如果确实要从外部引入数据,需要指定持久的地址,并带版本信息等,让他人可以复用而不出错。

参考

《Docker技术入门与实战》 杨保华 戴王剑 曹亚仑 著

《Docker实战》 Jeff Nickoloff 著, 胡震,杨润青 黄帅 译
https://yiyan.baidu.com/ 文心一言
https://docs.docker.com/reference/dockerfile/ Dockerfile reference
http://www.dockerinfo.net/3328.html 7 步精简 Docker 镜像
https://www.runoob.com/docker/docker-install-ubuntu.html Docker 安装 Ubuntu
https://blog.csdn.net/u014163312/article/details/127330574 Maven打包所有依赖到一个可执行jar中

相关推荐
Linux运维老纪5 小时前
DNS缓存详解(DNS Cache Detailed Explanation)
计算机网络·缓存·云原生·容器·kubernetes·云计算·运维开发
杨浦老苏7 小时前
开源音乐管理软件Melody
docker·群晖·多媒体
杨浦老苏9 小时前
面向npm的实时仪表板Dashly
网络·docker·群晖·导航
JunLan~10 小时前
Docker 部署 GLPI(IT 资产管理软件系统)
运维·docker·容器
特立独行的猫a12 小时前
使用 Docker(Podman) 部署 MongoDB 数据库及使用详解
数据库·docker·podman
LUCIAZZZ14 小时前
基于Docker以KRaft模式快速部署Kafka
java·运维·spring·docker·容器·kafka
特立独行的猫a17 小时前
Golang 应用的 Docker 部署方式介绍及使用详解
开发语言·docker·golang
康世行19 小时前
Windows环境下MaxKB大模型 Docker部署图文指南
windows·docker·容器
Elastic 中国社区官方博客19 小时前
使用 Ollama 和 Kibana 在本地为 RAG 测试 DeepSeek R1
大数据·数据库·人工智能·elasticsearch·ai·云原生·全文检索
程序员石磊1 天前
学术总结Ai Agent中firecrawl(大模型爬虫平台)的超简单的docker安装方式教程
人工智能·爬虫·docker