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中

相关推荐
大G哥2 小时前
记一次K8S 环境应用nginx stable-alpine 解析内部域名失败排查思路
运维·nginx·云原生·容器·kubernetes
feng_xiaoshi2 小时前
【云原生】云原生架构的反模式
云原生·架构
大道归简3 小时前
Docker 命令从入门到入门:从 Windows 到容器的完美类比
windows·docker·容器
zeruns8023 小时前
如何搭建自己的域名邮箱服务器?Poste.io邮箱服务器搭建教程,Linux+Docker搭建邮件服务器的教程
linux·运维·服务器·docker·网站
爱跑步的程序员~3 小时前
Docker
docker·容器
程序那点事儿4 小时前
k8s 之动态创建pv失败(踩坑)
云原生·容器·kubernetes
疯狂的大狗4 小时前
docker进入正在运行的容器,exit后的比较
运维·docker·容器
长天一色4 小时前
【Docker从入门到进阶】01.介绍 & 02.基础使用
运维·docker·容器
伊玛目的门徒4 小时前
docker 搭建minimalist-web-notepad
运维·docker·notepad
叶北辰CHINA5 小时前
nginx反向代理,负载均衡,HTTP配置简述(说人话)
linux·运维·nginx·http·云原生·https·负载均衡