本文将对
Docker
的镜像,
Dockerfile
的关键指令,
Docker
的容器进行关键概念总结,整体内容不会很深,但也绝不是停留在指令介绍的层面。
本文将重点分析如下内容。
镜像的分层结构及大小;
CMD
指令和
ENTRYPOINT
指令的关系;
如何让容器中的主进程的
PID
是1;
容器的分层结构及大小。
参考:
Docker官方文档
一. Linux Namespace
1. 概念说明
Namespace
是
Linux
的
资源隔离方案
,用于将某种全局资源通过
Namespace
进行隔离,使得
Namespace
下的
资源
仅由该
Namespace
下的进程共享。
Linux
内核实现了如下六种
Namespace
。
Namespace类型
|
隔离的资源
|
容器下的效果
|
Mount
|
文件系统挂载点
|
不同容器中可以看到不同的目录结构
|
Network
|
例如网络设备,端口等网络相关资源
|
容器拥有独立的网络设备,
IP
地址,端口号等,这就使得同一宿主机上不同容器的进程可以绑定到相同端口上
|
UTS
|
Hostname
和
Domainname
|
容器可以拥有自己的主机名和域名
|
IPC
|
信号量,消息队列和共享内存等进程间通信资源
|
不同容器里的进程无法直接进行进程间通信
|
PID
|
进程编号
|
容器中的进程可以有独立的
PID
,以及容器中的进程会有两个
PID
:宿主机上一个
PID
和容器里一个
PID
|
User
|
用户和用户组
|
宿主机和容器中可以有两套用户和用户组
|
2. Docker中的使用
当
Docker
创建一个容器时,
Docker
就会去创建上述六种
Namespace
实例,然后容器中的所有进程就会被放到创建出来的
Namespace
中,从而容器中的进程只能使用当前容器的
Namespace
下的资源,不同容器之间实现了资源隔离。
二. Linux Control Groups
1. 概念说明
Namespace
让容器中的进程拥有了独立的运行环境,现在还需要限制这些进程使用的
CPU
,
内存
,
磁盘
和
网络
等资源,而
cgroup
(
Control Groups
)就能实现这样的功能。
cgroup
是
Linux
提供的用于将进程分组管理的功能,针对每个组里的进程,提供如下能力。
限制组里进程使用的
CPU
核数和使用率;
限制组里进程使用的
内存
大小;
限制组里进程对物理设备的使用,例如
磁盘
;
为组里进程分配
网络带宽
。
可以进入到宿主机的/
sys
/
fs
/
cgroup
目录,观察到有如下目录。
每个目录都是
cgroup
可以控制的资源类型,说明如下。
blkio
。限制
cgroup
中的进程对块设备(例如磁盘)的
IO
速度;
cpu
。限制
cgroup
中的进程对
CPU
的使用率;
cpuacct
。统计
cgroup
中的进程对
CPU
的使用率;
cpuset
。为
cgroup
中的进程分配
CPU
核数;
devices
。限制
cgroup
中的进程使用物理设备的权限;
freezer
。挂起或恢复
cgroup
中的所有进程;
hugetlb
。限制
cgroup
中的进程对大页的使用数量(4
KB
称为小页,反之例如2
MB
或1
GB
称为大页);
memory
。限制
cgroup
中的进程使用的内存大小,并生成使用报告;
net_cls
。为
cgroup
中的进程的所有网络包添加
classid
标记符号,用于
iptables
(网络包过滤)和
TC
(
Traffic Control
,流量控制);
perf_event
。监控
cgroup
中的进程的性能。
2. Docker中的使用
在
Docker
中,启动一个容器后,会在宿主机的/
sys
/
fs
/
cgroup
目录下的相关资源目录下创建以容器
id
为名字的目录,如下所示。
容器中的进程的
PID
(容器进程在宿主机中的
PID
)会被记录到
tasks
文件中,如下所示。
现在再查看一下
cpu.cfs_quota_us
,如下所示。
-1表示无限制,可以在使用
docker run
时通过-
c
来指定
CPU
限制相关参数,参数说明如下所示。
三. 镜像Image
镜像,是
Docker
中的容器的
静态表示
。镜像中包含着容器运行时的
代码
和
运行依赖
,是一个多层结构,且每一层都是
只读
的,所以镜像一旦创建,就无法被修改。
下面将从多个维度来说明
镜像
的概念。
1. 基础镜像
基础镜像,有如下两个特点。
不依赖其它镜像
;
其它镜像可基于基础镜像做扩展
。
那么可以联想到,通常基础镜像就是那些系统级镜像例如
CentOS
镜像,
Ubuntu
镜像等,这些镜像会作为地基,为容器中的程序运行提供操作系统(下面讨论的基础镜像均指系统级镜像)。
那么现在宿主机上有一个安装好的操作系统,宿主机上运行的一个容器中也有基础镜像提供的操作系统,这两个操作系统是否是一样的呢。说一样也行,说不一样也行,说明如下。
一样
。是因为容器中由镜像提供的操作系统底层使用的内核(
Kernel
)其实就是宿主机安装好的操作系统的内核。那么试问
Linux
的虚拟机上能运行使用
Windows
作为基础镜像的容器吗,那自然是不能的;
不一样
。是因为例如
Red Hat
系统的虚拟机上,可以运行使用
Ubuntu
作为基础镜像的容器,容器和宿主机上的
Linux
是不一样的
发行版
。
(
Linux
的不同发行版本都会采用相同的
Linux
内核,然后不同的发行版会在
Linux
内核的基础上加入各自的改动)
既然都能使用宿主机的操作系统内核了,那么为啥还需要基础镜像呢。这里其实就又会涉及到两个概念:
bootfs
和
rootfs
。
bootfs
,是
Linux
启动时需要加载的内容,由内核提供,属于
内核空间
。所以基础镜像中的操作系统需要依赖宿主机操作系统中的内核所提供的
bootfs
,才能将容器中的操作系统运行起来;
rootfs
,是基本的命令,类库或者工具(例如包管理工具
yum
,
apt-get
等),属于
用户空间
。由于容器中的文件系统通常是和宿主机上的文件系统隔离的,所以容器中运行的应用程序是无法访问到宿主机上的相关类库或者工具,基于这样的情况,基础镜像中就需要包含
rootfs
相关的内容。
到这里,基本就理清了基础镜像中的操作系统和宿主机上的操作系统的关系,简单点说就是:基础镜像中的操作系统是不完整的,缺少内核,需要使用宿主机上的操作系统的内核。(一个
Ubuntu
镜像才201
MB
,一个
Ubuntu 18.4
的安装包2
GB
,少了啥自己想)
2. 镜像组成
现在通过
docker save -o filename.tar imagename:version
将一个镜像打为
tar
并解压,解压后目录如下所示。
每种文件说明如下。
repositories
。包含镜像名称,版本号,以及最上层的哈希值;
manifest.json
。镜像(假)元数据
Json
文件,包含镜像真正的元数据文件的文件名,以及镜像每一层的哈希值等;
各种文件夹
。镜像每一层对应的文件夹,是一个镜像真正占用空间大小的内容;
ac0f3e4255c186822ea64dcb267bea7c897d44ace98c61cd3faaffdb9479114f.json
。镜像真正的元数据文件。
基于
Dockerfile
构建出来的镜像,真正占用磁盘空间的层,要么是基础镜像层,要么是使用了
ADD,COPY
或者
RUN
指令而构建出来的层,而像
ENV
,
CMD
等指令,虽然也会构建镜像的层,但其实是一个空层,这些指令的真正作用是会修改构建出来的镜像的元数据。
3. RUN指令对镜像大小的影响
首先上一张图。
在
Dockerfile
中要基于
ubuntu:18.04
基础镜像来构建镜像,其中会通过
RUN
指令执行两次
api-get update
,假设每次执行都会更改到
ubuntu:18.04
基础镜像中的
File1
和
File2
,那么此时就会出现每个
RUN
指令都会在镜像上增加一层,且里面会保存发生了更新的文件(
File1
和
File2
)。
现在再看如下一张图。
现在只会有一个
RUN
指令,且在一个
RUN
指令中执行两次
api-get update
,结果就是只会在镜像上增加一层,最终镜像大小相较于上面的分开写
RUN
指令会少40
M
,且镜像总层数也会减1。
所以,针对文件的相关操作,尽量放在一个
RUN
指令中执行。
4. 镜像构建
通常,构建镜像有两种方式。
创建容器,执行命令,最后通过
docker commit
指令基于容器生成一个镜像;
提供
Dockerfile
文件,通过
docker build
指令创建一个镜像。
实际上第2点本质的创建思路就是基于第一点。
现在有如下一个
Dockerfile
文件。
FROM ubuntu:latest
WORKDIR /
COPY ./record.log /
ENV KEY="VALUE"
EXPOSE 8080
ENTRYPOINT top -b
如果基于Dockerfile构建镜像,那么一个构建出来的镜像的分层结构示意图如下。
Dockerfile中每一个指令执行,都会生成一个中间镜像,而这个中间镜像是会被Docker进行缓存,只能通过docker images -a才能将中间镜像查询出来。还有就是当前指令执行后生成的中间镜像,会比上一条指令生成的中间镜像多一层。
现在基于docker build来创建一个镜像,打印如下。
在上述构建日志中,出现了如下两种情况。
Using cache ...。表明本地已经缓存了这条指令执行后生成的中间镜像,所以直接从缓存中获取中间镜像;
Running in ... Removing intermediate container ...。表明没有匹配到缓存,此时会先创建一个临时容器,然后执行COPY指令,接着使用docker commit基于临时容器生成中间镜像,最后移除临时容器。
5. 镜像的大小
已知镜像是一个层级结构,那么镜像的总大小实际就是每一层的大小相加。
那么在构建一个镜像时,可以观察一下什么时候会增加构建出来的镜像的大小。
FROM。因为会引用基础镜像,那么构建出来的镜像大小首先就会包含基础镜像的大小;
ADD或COPY。这两个指令主要实现构建镜像时向镜像中添加文件或目录,添加成功则在原镜像基础上新增一层,并且新增的这一层的大小就是添加的内容的大小;
RUN。运行指令,运行结果如果对文件系统产生了更改(新增,删除和更新文件),那么更新的这部分内容会作为镜像新的一层。注意,RUN指令不一定会增加镜像的大小,如果没有对文件系统产生影响,那么增加的镜像的层的大小会为0。
那么现在假如有5个镜像,每个镜像1G,那么这5个镜像在磁盘上占用的空间就是5G吗,那自然不是的。前面分析已经知道,镜像是一个层级结构,一个镜像是一层一层叠加起来的一个整体,但是每一层的内容实际是可以被复用的,图示如下。
所以关于镜像的大小,可以总结如下。
镜像的总大小由每一层的大小相加得到;
FROM,ADD,COPY和RUN指令增加的层会导致镜像大小增加;
多个镜像总大小不等于多个镜像在磁盘上占用的总大小;
镜像的复用其实是镜像之间可以复用层。
四. Dockerfile常用指令
1. ADD
ADD指令用于从源地址src复制文件,目录或远程文件,然后添加到目标地址dest的文件系统中。
ADD指令有如下两种使用格式。
ADD [--chown=<user>:<group>] [--checksum=<checksum>] <src>... <dest>
ADD [--chown=<user>:<group>] ["<src>",... "<dest>"]
说明如下。
如果路径中有空格,请使用上述的第2种格式;
--chown 用于给添加内容指定所有权。其中user和group都可以使用UID和GID来表示;
--checksum 用于校验远端文件。例如ADD --checksum=sha256:24454f830cdb571e2c4ad15481119c43b3cafd48dd869a9b2945d1036d1dc68d mirrors.edge.kernel.org/pub/linux/k… /;
src路径需要在当前构建上下文中。例如 ./learn-docker-1.0-SNAPSHOT.jar,则需要在当前目录下存在learn-docker-1.0-SNAPSHOT.jar文件,这是因为在docker build的第一步,就是将当前目录下的所有文件和子文件都上传到Docker并作为当前构建上下文(所以当前目录下的内容就称为当前构建上下文);
src如果是URL,而dest不以斜杠结尾,则会从URL下载文件然后复制到dest中;
src如果是URL,同时dest是以斜杠结尾的目录,则会从URL推断出文件名然后将文件下载为dest中的对应文件。例如ADD example.com/foobar /,那么会从example.com/foobar 下载文件为 /foobar;
src如果是目录,则会复制目录里全部内容到dest;
src如果是常用压缩格式(gzip,bzip等)的tar压缩包,那么ADD指令会解压这个tar压缩包,并且识别压缩格式仅以文件内容为准;
如果指定了多个src或者通配符匹配到了多个src,则dest必须是一个以斜杠结尾的目录;
如果dest不以斜杠结尾,则dest会被识别为文件,那么就会将src的内容写到dest中;
如果dest不存在,则会在路径中创建dest以及相关的目录。
2. COPY
COPY指令用于从src复制文件或目录,然后添加到目标地址dest的文件系统中。
COPY指令有如下两种使用格式。
COPY [--chown=<user>:<group>] <src>... <dest>
COPY [--chown=<user>:<group>] ["<src>",... "<dest>"]
上述的格式中相较于ADD少了 --checksum,是因为COPY不支持从URL下载文件,也就无需校验远端文件。
对于COPY指令,说明如下。
如果路径中有空格,请使用上述的第2种格式;
--chown用于给添加内容指定所有权。其中user和group都可以使用UID和GID来表示;
src路径需要在当前构建上下文中。例如 ./learn-docker-1.0-SNAPSHOT.jar,则需要在当前目录下存在learn-docker-1.0-SNAPSHOT.jar文件,这是因为在docker build的第一步,就是将当前目录下的所有文件和子文件都上传到Docker并作为当前构建上下文(所以当前目录下的内容就称为当前构建上下文);
src如果是目录,则会复制目录里全部内容到dest,但是目录本身不会被复制;
如果指定了多个src或者通配符匹配到了多个src,则dest必须是一个以斜杠结尾的目录;
如果dest不以斜杠结尾,则dest会被识别为文件,那么就会将src的内容写到dest中;
如果dest不存在,则会在路径中创建dest以及相关的目录。
关于ADD指令和COPY的区别,有如下两点。
ADD指令支持从URL下载文件到目标路径,而COPY不支持;
ADD指令会识别压缩文件并解压到目标路径中,而COPY不支持。
3. CMD
CMD指令用于指定容器被创建时所需要执行的命令。
CMD指令有如下三种格式。
CMD ["executable","param1","param2"]。exec格式,不会以shell形式来执行;
CMD ["param1","param2"]。为ENTRYPOINT提供参数;
CMD command param1 param2。shell格式,以shell来执行command的命令。
对于CMD指令的说明如下。
Dockerfile中只能有一条CMD指令,如果出现多条则以最后一条为准;
CMD指令主要是用来为容器提供默认值(上述第一种和第二种格式),这些默认值中可以有可执行文件(第一种格式中的"executable"),也可以没有(第二种格式就没有可执行文件),如果没有可执行文件时,则需要再指定ENTRYPOINT指令;
如果CMD指令用于给ENTRYPOINT提供参数,那么CMD需要使用上述第二种格式,ENTRYPOINT也需要使用ENTRYPOINT ["executable", "param1", "param2"] 这种格式;
如果CMD使用shell格式,则命令将以 /bin/sh -c 'command param1 param2' 形式执行。
CMD与ENTRYPOINT有很大的相似性和关联,将在下面介绍完ENTRYPOINT后一起进行说明。
4. ENTRYPOINT
ENTRYPOINT指令用于为容器配置可执行文件(什么是可执行文件,top,echo这种就是可执行文件)(官网的解释)。其实就是配置每次创建容器时需要执行的指令,和CMD较为相似。
ENTRYPOINT指令有如下两种格式。
ENTRYPOINT ["executable", "param1", "param2"]。这种称为exec格式;
ENTRYPOINT command param1 param2。这种称为shell格式。
对ENTRYPOINT指令,有如下说明。
docker run <image> <command line arguments>里面的command line arguments会作为exec格式的ENTRYPOINT的参数,并附加在最后面;
docker run <image> <command line arguments>里面的command line arguments会覆盖CMD指令的所有内容;
如果要覆盖ENTRYPOINT,需要使用docker run --entrypoint,覆盖后是exec格式;
shell格式的ENTRYPOINT不会使用任何command line arguments或者任何CMD指令提供的内容;
shell格式的ENTRYPOINT会以 /bin/sh -c <ENTRYPOINT> 的形式执行,此时可执行文件不是容器中的PID为1的进程,PID不是1的进程将无法接收宿主机发送的SIGTERM信号,也就无法实现优雅退出(docker stop会从宿主机向容器发送SIGTERM信号),此时只能通过SIGKILL信号被强行退出(docker kill会从宿主机向容器发送SIGKILL信号);
Dockerfile中只能有一条ENTRYPOINT指令,如果出现多条则以最后一条为准。
(下面将对官网上对于ENTRYPOINT的使用进行翻译)
可以使用ENTRYPOINT指令的exec格式来设置稳定的默认命令和参数,然后使用CMD指令的任何一种格式来设置可能更改的默认命令和参数。
FROM ubuntu
ENTRYPOINT ["top", "-b"]
CMD ["-c"]
当运行容器时,可以看到top是唯一的进程(且PID是1)。
docker run -it --rm --name test top -H
top - 08:25:00 up 7:27, 0 users, load average: 0.00, 0.01, 0.05
Threads: 1 total, 1 running, 0 sleeping, 0 stopped, 0 zombie
%Cpu(s): 0.1 us, 0.1 sy, 0.0 ni, 99.7 id, 0.0 wa, 0.0 hi, 0.0 si, 0.0 st
KiB Mem: 2056668 total, 1616832 used, 439836 free, 99352 buffers
KiB Swap: 1441840 total, 0 used, 1441840 free. 1324440 cached Mem
PID USER PR NI VIRT RES SHR S %CPU %MEM TIME+ COMMAND
1 root 20 0 19744 2336 2080 R 0.0 0.1 0:00.04 top
要进一步验证,可以使用docker exec。
docker exec -it test ps aux
USER PID %CPU %MEM VSZ RSS TTY STAT START TIME COMMAND
root 1 2.6 0.1 19752 2352 ? Ss+ 08:24 0:00 top -b -H
root 7 0.0 0.1 15572 2164 ? R+ 08:25 0:00 ps aux
此时可以使用docker stop test来优雅的停止top指令。
下面的Dockerfile展示了使用ENTRYPOINT在前台运行Apache(PID是1)。
FROM debian:stable
RUN apt-get update && apt-get install -y --force-yes apache2
EXPOSE 80 443
VOLUME ["/var/www", "/var/log/apache2", "/etc/apache2"]
ENTRYPOINT ["/usr/sbin/apache2ctl", "-D", "FOREGROUND"]
如果需要为可执行文件编写启动脚本,可以使用exec和gosu命令确保最终可执行文件接收到宿主机发送的信号。
#!/usr/bin/env bash
set -e
if [ "$1" = 'postgres' ]; then
chown -R postgres "$PGDATA"
if [ -z "$(ls -A "$PGDATA")" ]; then
gosu postgres initdb
exec gosu postgres "$@"
exec "$@"
(本段话非翻译)上述是我们如果一定要基于一个脚本来启动我们的程序(可执行文件),并且还希望我们的程序的进程的PID是1,则Docker官方建议我们在脚本中最终启动程序时,要使用exec和gosu来启动程序。
因为脚本在运行的时候也是一个进程(会占用PID为1的进程号),所以使用exec可以使得我们程序的进程可以取代运行脚本的进程从而成为PID为1的进程;
如果运行程序时权限不够,通常可以使用sudo提升权限,但是使用sudo会创建sudo进程(会占用PID为1的进程号),而gosu不会创建新的进程(不会占用PID为1的进程号),所以建议使用gosu来提升权限。
(继续翻译)最后,如果需要在关闭进程时做一些额外的清理工作,那么需要启动脚本能够接收到宿主机发送的信号。
#!/bin/sh
trap "echo TRAPed signal" HUP INT QUIT TERM
/usr/sbin/apachectl start
echo "[hit enter key to exit] or run 'docker stop <container>'"
echo "stopping apache"
/usr/sbin/apachectl stop
echo "exited $0"
现在使用run -it --rm -p 80:80 --name test apache来运行容器,此时可以基于docker exec或docker top来查看容器里进程情况。
docker exec -it test ps aux
USER PID %CPU %MEM VSZ RSS TTY STAT START TIME COMMAND
root 1 0.1 0.0 4448 692 ? Ss+ 00:42 0:00 /bin/sh /run.sh 123 cmd cmd2
root 19 0.0 0.2 71304 4440 ? Ss 00:42 0:00 /usr/sbin/apache2 -k start
www-data 20 0.2 0.2 360468 6004 ? Sl 00:42 0:00 /usr/sbin/apache2 -k start
www-data 21 0.2 0.2 360468 6000 ? Sl 00:42 0:00 /usr/sbin/apache2 -k start
root 81 0.0 0.1 15572 2140 ? R+ 00:44 0:00 ps aux
docker top test
PID USER COMMAND
10035 root {run.sh} /bin/sh /run.sh 123 cmd cmd2
10054 root /usr/sbin/apache2 -k start
10055 33 /usr/sbin/apache2 -k start
10056 33 /usr/sbin/apache2 -k start
/usr/bin/time docker stop test
real 0m 0.27s
user 0m 0.03s
sys 0m 0.03s
ENTRYPOINT的shell格式中,可以将ENTRYPOINT指定为纯字符串,此时ENTRYPOINT将基于 /bin/sh -c来执行。这种情况可以使用shell的方式来进行shell环境变量替换(比如echo $HOME),并且不会使用任何CMD或docker run传递的命令行参数。为了确保docker stop能够正确的向任何长期运行的可执行文件传递SIGTERM信号,那么启动可执行文件时,需要用exec来启动。
FROM ubuntu
ENTRYPOINT exec top -b
当基于上述Dockerfile构建的镜像来启动容器时,只能看到一个PID为1的进程。
docker run -it --rm --name test top
Mem: 1704520K used, 352148K free, 0K shrd, 0K buff, 140368121167873K cached
CPU: 5% usr 0% sys 0% nic 94% idle 0% io 0% irq 0% sirq
Load average: 0.08 0.03 0.05 2/98 6
PID PPID USER STAT VSZ %VSZ %CPU COMMAND
1 0 root R 3164 0% 0% top -b
这个容器可以使用docker stop来优雅的退出。
/usr/bin/time docker stop test
real 0m 0.20s
user 0m 0.02s
sys 0m 0.04s
如果没有在ENTRYPOINT的开头添加exec,就像下面这样。
FROM ubuntu
ENTRYPOINT top -b
CMD -- --ignored-param1
当基于上述Dockerfile构建的镜像来启动容器时,显示如下。
docker run -it --name test top --ignored-param2
top - 13:58:24 up 17 min, 0 users, load average: 0.00, 0.00, 0.00
Tasks: 2 total, 1 running, 1 sleeping, 0 stopped, 0 zombie
%Cpu(s): 16.7 us, 33.3 sy, 0.0 ni, 50.0 id, 0.0 wa, 0.0 hi, 0.0 si, 0.0 st
MiB Mem : 1990.8 total, 1354.6 free, 231.4 used, 404.7 buff/cache
MiB Swap: 1024.0 total, 1024.0 free, 0.0 used. 1639.8 avail Mem
PID USER PR NI VIRT RES SHR S %CPU %MEM TIME+ COMMAND
1 root 20 0 2612 604 536 S 0.0 0.0 0:00.02 sh
6 root 20 0 5956 3188 2768 R 0.0 0.2 0:00.00 top
可以发现可执行文件top的PID不是1,PID为1的进程是sh。
如果此时执行docker stop test,那么容器将无法优雅退出(top无法接收到SIGTERM指令),最终在超时后docker stop被迫发送SIGKILL指令来强制终止top。
docker exec -it test ps waux
USER PID %CPU %MEM VSZ RSS TTY STAT START TIME COMMAND
root 1 0.4 0.0 2612 604 pts/0 Ss+ 13:58 0:00 /bin/sh -c top -b --ignored-param2
root 6 0.0 0.1 5956 3188 pts/0 S+ 13:58 0:00 top -b
root 7 0.0 0.1 5884 2816 pts/1 Rs+ 13:58 0:00 ps waux
/usr/bin/time docker stop test
real 0m 10.19s
user 0m 0.04s
sys 0m 0.03s
(翻译结束,感觉官网写得很清晰了)
现在是不是对CMD和ENTRYPOINT有了较为清晰的认识,下面是对CMD和ENTRYPOINT的(说人话的)总结。
CMD和ENTRYPOINT指令都可以定义运行容器时需要执行的命令;
CMD会被docker run <arguments>指定的arguments内容覆盖,ENTRYPOINT不会;
如果想覆盖ENTRYPOINT,需要通过docker run --entrypoint来实现,此时覆盖后的ENTRYPOINT默认是exec格式;
CMD和ENTRYPOINT在Dockerfile中只能有一个生效,并且都是最后一个生效;
当要在启动容器时通过CMD或ENTRYPOINT直接运行一个程序,使用exec格式能让启动的程序的进程PID是1;
当要在启动容器时通过CMD或ENTRYPOINT直接运行一个程序,使用shell格式会导致启动的程序的进程PID不是1,但是也有例外,在shell格式中也可以使用exec,例如ENTRYPOINT exec top -b,此时效果和exec格式的ENTRYPOINT ["top" "-b"] 效果是一样的;
如果是通过CMD或者ENTRYPOINT来运行一个脚本,然后在脚本中来启动我们的程序,那么脚本中启动的地方需要使用exec和gosu,才能确保我们的程序的进程PID是1;
CMD和ENTRYPOINT在Dockerfile中同时存在时,CMD的作用就是给ENTRYPOINT提供参数,此时CMD和ENTRYPOINT都应该是exec格式;
CMD和ENTRYPOINT在Dockerfile中同时存在时,如果ENTRYPOINT是shell格式,那么CMD的全部内容会被忽略。
最后是CMD的三种格式和ENTRYPOINT的两种格式交叉使用时的一个测试结果。
Dockerfile如下所示(v1)。
FROM ubuntu:latest
CMD ["cmd"]
ENTRYPOINT echo entrypoint
启动时打印如下。
entrypoint
启动命令如下所示。
/bin/sh -c 'echo entrypoint' cmd
Dockerfile如下所示(v2)。
FROM ubuntu:latest
CMD ["cmd"]
ENTRYPOINT ["echo","entrypoint"]
启动时打印如下。
entrypoint cmd
启动命令如下所示。
echo entrypoint cmd
Dockerfile如下所示(v3)。
FROM ubuntu:latest
CMD ["echo","cmd"]
ENTRYPOINT echo entrypoint
启动时打印如下。
entrypoint
启动命令如下所示。
/bin/sh -c 'echo entrypoint' echo cmd
Dockerfile如下所示(v4)。
FROM ubuntu:latest
CMD ["echo","cmd"]
ENTRYPOINT ["echo","entrypoint"]
启动时打印如下。
entrypoint echo cmd
启动命令如下所示。
echo entrypoint echo cmd
Dockerfile如下所示(v5)。
FROM ubuntu:latest
CMD echo cmd
ENTRYPOINT echo entrypoint
启动时打印如下。
entrypoint
启动命令如下所示。
/bin/sh -c 'echo entrypoint' /bin/sh -c 'echo cmd'
Dockerfile如下所示(v6)。
FROM ubuntu:latest
CMD echo cmd
ENTRYPOINT ["echo","entrypoint"]
启动时打印如下。
entrypoint /bin/sh -c echo cmd
启动命令如下所示。
echo entrypoint /bin/sh -c 'echo cmd'
那么,可以CMD和ENTRYPOINT都没有吗,自然是不可以的。
5. EXPOSE
EXPOSE指令用于告诉Docker容器在运行时需要监听的网络端口,默认监听TCP端口。
EXPOSE指令实际上没有为Docker容器发布端口,该指令更像是一种约定,镜像构建方通过EXPOSE约定基于当前镜像构建的容器应该发布哪个端口,这份约定保存在镜像的元数据中,那么容器创建方就可以通过docker run -p hostPort:containerPort来进行容器端口的发布和与宿主机端口的映射,或者通过docker run -P来将约定的端口发布并且与宿主机上49000-49900之间的随机一个端口映射。
那么现在对EXPOSE做一个总结。
EXPOSE用于提供容器运行时需要监听的端口的元数据信息,并不是真正的为容器发布端口,举个例子,EXPOSE 8080,首先并没有为容器发布8080端口,也并不保证容器中运行的程序是否在监听容器的8080端口;
docker -p hostPort:containerPort其实是比较粗暴的,就是单纯的先发布容器的containerPort端口(如果容器的containerPort端口已经被发布则这一步不做),然后完成容器的containerPort端口与宿主机上hostPort端口的绑定;
docker -P会去检测元数据并拿到被EXPOSE约定的容器端口,然后为容器发布这个端口,然后将容器发布的这个端口与宿主机上49000-49900之间的随机一个端口做映射。
6. WORKDIR
CD:今天我画了一个妆,你觉得我好看吗。
WORKDIR指令用于为Dockerfile中任何RUN,COPY,ADD,CMD和ENTRYPOINT指令设置工作目录(进入这个目录),并且当WORKDIR指定的目录不存在时会创建指定的目录。
WORKDIR可以在一份Dockerfile中使用多次,比如。
WORKDIR /a
WORKDIR b
WORKDIR c
RUN pwd
那么最终pwd的输出结果是**/a/b/c**,也就是RUN指令执行时的目录是**/a/b/c**。
WORKDIR在设置工作目录时,可以使用在Dockerfile中通过ENV设置的环境变量。
ENV DIRPATH=/path
WORKDIR $DIRPATH/$DIRNAME
RUN pwd
那么最终pwd的输出结果是/path/$DIRNAME。
7. ENV
ENV格式如下。
ENV <key>=<value> ...
ENV指令用来设置环境变量,ENV指令设置的环境变量,可用在镜像构建阶段的后续的所有指令中例如RUN,WORKDIR等指令,并且最终会作为容器中的环境变量。
注意,关于设置的环境变量,有如下注意点。
引号如果没有被转义,那么会被删除;
docker run --env <key>=<value>可以覆盖ENV设置的环境变量;
ENV设置的环境变量会作用于构建阶段和最终的容器中,可能会导致一些副作用,如果只是希望设置作用于构建阶段的环境变量,可以使用ARG指令;
ENV设置的环境变量中,<value>中的空格要么存在于引号(单双引号)中,要么被转义。
下面是针对ENV指令的一些测试。
Dockerfile如下所示(v1)。
FROM ubuntu:latest
ENV TEST_MSG="HolloGo"
ENTRYPOINT echo $TEST_MSG
启动打印如下。
HolloGo
也就是没有加转义符的引号会被删除。
Dockerfile如下所示(v2)。
FROM ubuntu:latest
ENV TEST_MSG=\"HolloGo\"
ENTRYPOINT echo $TEST_MSG
启动打印如下。
"HolloGo"
加了转义符的引号才会被保留。
Dockerfile如下所示(v3)。
FROM ubuntu:latest
ENV TEST_MSG=Hello\ Go\ Lang
ENTRYPOINT echo $TEST_MSG
启动打印如下。
Hello Go Lang
空格需要使用转义符转义。
Dockerfile如下所示(v4)。
FROM ubuntu:latest
ENV TEST_MSG=Hello Go Lang
ENTRYPOINT echo $TEST_MSG
上述方式是非法的,空格要么在双引号中间,要么使用转义符进行转义。
Dockerfile如下所示(v5)。
FROM ubuntu:latest
ENV TEST_MSG='Hello Go Lang'
ENTRYPOINT echo $TEST_MSG
启动打印如下。
Hello Go Lang
单引号和双引号一样,会被删除。
Dockerfile如下所示(v6,v7,v8和v9)。
FROM ubuntu:latest
ENV TEST_MSG=\'Hello Go Lang\'
ENTRYPOINT echo $TEST_MSG
FROM ubuntu:latest
ENV TEST_MSG=\"Hello Go Lang\"
ENTRYPOINT echo $TEST_MSG
上述方式都是非法的。
FROM ubuntu:latest
ENV TEST_MSG=\'Hello\ Go\ Lang\'
ENTRYPOINT echo $TEST_MSG
启动打印如下。
'Hello Go Lang'
FROM ubuntu:latest
ENV TEST_MSG=\"Hello\ Go\ Lang\"
ENTRYPOINT echo $TEST_MSG
启动打印如下。
"Hello Go Lang"
8. VOLUME
VOLUME用于设置挂载点。
首先准备如下的Dockerfile。
FROM ubuntu:latest
VOLUME ["/data"]
ENTRYPOINT echo "Hello Go Lang"
启动容器,然后使用docker inspect来查看容器。
上述中Source表示宿主机上某一个路径,Destination表示容器中的挂载点。
9. USER
USER指令用于设置用户名(UID)和可选的用户组(GID),用作容器的主进程的用户名和用户组,以及用作当前构建阶段后续的RUN,CMD和ENTRYPOINT指令执行时的用户。
五. 容器Contianer
1. 容器存储分层结构
当基于一个镜像,运行一个容器时,这个容器的存储分层结构如下。
创建容器时,镜像会作为容器的镜像层(基础层),是只读的,然后在镜像层上会添加一层可写层,称为容器层,在容器运行时,对容器所作的更改(增删改文件)都会写入到容器层。
对于容器层,有如下注意点。
容器层是可写的。在容器中做的文件增删改都会写入到容器层;
容器层在容器删除时也会被删除。容器层是不做持久化的,容器销毁,容器层也会一并被销毁,相应的对容器的修改也会删除,但是镜像层可以保持不变,不会随容器销毁而销毁;
每个容器都有自己的容器层。基于相同镜像构建的容器会共享同一个底层镜像,同时每个容器自己的变化会写入到容器自己的容器层,因此多个容器可以共享同一个底层镜像,同时也拥有自己的状态。
2. 容器的大小
要查看运行中的容器的大小,可以使用docker ps -s命令,一个示例如下。
说明如下。
SIZE。表示容器的容器层的大小;
virtual。表示镜像层加上容器层的大小。
也就是多个基于相同镜像的容器占用的磁盘实际大小是远小于这些镜像的virtual大小之和的。
3. CoW写时复制策略
关于CoW写时复制策略的概念如下所示。
如果一个文件在镜像的较底层,而镜像的较高层或者容器层要读取这个文件,则直接读取这个文件即可。但如果镜像的较高层或者容器层要修改这个文件(构建镜像或者容器运行的时候),则需要将这个文件从镜像较低层拷贝到当前层再做修改。
已知在容器运行时,在镜像层之上会有一个容器层。还已知容器对文件系统所作的更改会存储在容器层,容器未更改的文件则不会拷贝到容器层,这样的策略使得容器层非常小,非常薄。
当容器要修改容器中的文件时(无论容器层还是镜像层中的文件),整体的一个执行策略如下。
修改的文件在容器层中,则直接修改;
从镜像层的上层到下层依次遍历,直到找到需要修改的文件(这一步操作的结果会添加缓存,用于下次遍历提速);
对遍历到的第一个文件执行复制操作,并复制到容器层;
后续这个文件的用户视角只会是容器层中修改后的文件。
特别注意:如果容器存在大量的写操作,则尽量不要写在容器(容器层)中,而应该为容器挂载持久化卷并写在持久化卷中。
4. docker commit
将容器的容器层中的文件更改作为镜像新的一层然后生成新镜像。注意点如下。
镜像新的一层不包含挂载的持久化卷里面的数据;
docker commit时会暂停容器中的进程。
六. 其他内容
1. 构建缓存的使用
在构建镜像时,中间镜像是可以被缓存和复用的,规则如下。
非ADD和COPY指令。如果某一个中间镜像(A镜像)已经是被缓存的,而此时正要在A镜像基础上执行下一条指令(m指令),那么会遍历A镜像的所有子镜像,以查看这些子镜像中是否有一个子镜像是基于m指令构建的;
ADD和COPY指令。会基于父镜像的所有文件和本次指令添加的文件计算checksum(校验和),然后和当前所有中间镜像的checksum做比较,有相等的则命中缓存的镜像;
一旦某一层没有命中缓存,那么从这一层开始,后续所有指令都不会使用缓存。
2. RUN指令使用建议
复杂的RUN语句需要拆分到多行,并用反斜杠 \ 分隔,以提升RUN语句的可读性;
需要将apt-get update和apt-get install组合到同一个RUN语句中(缓存破坏技术)。
FROM ubuntu:18.04
RUN apt-get update
RUN apt-get install -y curl
FROM ubuntu:18.04
RUN apt-get update # 这里的apt-get update会去使用缓存里的中间镜像,导致apt-get没有被更新
RUN apt-get install -y curl nginx
如下是一个apt-get的最佳编写实践
RUN apt-get update && apt-get install -y \ # apt-get update和apt-get install组合
aufs-tools \
automake \
build-essential \
curl \
dpkg-sig \
libcap-dev \
libsqlite3-dev \
mercurial \
reprepro \
ruby1.9.1 \ # 指定固定版本
ruby1.9.1-dev \
s3cmd=1.1.* \
&& rm -rf /var/lib/apt/lists/* # 清理apt缓存,可以减小层大小
3. 多阶段构建
假如要构建一个GO语言程序的镜像,并且也不想将GO源代码打到镜像中,最终还希望镜像大小尽可能的小。
要实现上述要求,有一种做法如下。
先在标准的容器环境中完成GO的编译,build.Dockerfile如下所示。
FROM golang:1.16
WORKDIR /go/src/github.com/alexellis/href-counter/
COPY app.go ./
RUN go get -d -v golang.org/x/net/html \
&& CGO_ENABLED=0 go build -a -installsuffix cgo -o app .
编译得到GO可执行文件后,最终基于alpine的镜像的Dockerfile如下所示。
FROM alpine:latest
RUN apk --no-cache add ca-certificates
WORKDIR /root/
COPY app ./
CMD ["./app"]
同时还需要一个shell脚本来完成将编译得到的GO可执行文件从容器中拷贝到宿主机上,然后再执行最终镜像的Dockerfile,脚本如下所示。
#!/bin/sh
echo Building alexellis2/href-counter:build
docker build -t alexellis2/href-counter:build . -f build.Dockerfile
docker container create --name extract alexellis2/href-counter:build
docker container cp extract:/go/src/github.com/alexellis/href-counter/app ./app
docker container rm -f extract
echo Building alexellis2/href-counter:latest
docker build --no-cache -t alexellis2/href-counter:latest .
rm ./app
上述做法是繁琐且容易出错的,如果使用docker的多阶段构建(Multi-stage builds),那么可以简化如下。
FROM golang:1.16 AS builder
WORKDIR /go/src/github.com/alexellis/href-counter/
RUN go get -d -v golang.org/x/net/html
COPY app.go ./
RUN CGO_ENABLED=0 go build -a -installsuffix cgo -o app .
FROM alpine:latest
RUN apk --no-cache add ca-certificates
WORKDIR /root/
COPY --from=builder /go/src/github.com/alexellis/href-counter/app ./
CMD ["./app"]
也就是允许在一个Dockerfile中使用多个FROM语句,每个FROM语句都可以使用一个基础镜像并开启构建的一个新阶段,最为有用的就是可以方便的将一个构建阶段的产出内容复制到另外一个构建阶段(未复制的内容全部丢弃)。
4. K8S command和args
如果设置了K8S command和K8S args,则容器启动时会使用K8S command和K8S args;
如果没设置K8S command和K8S args,则容器启动时使用Docker镜像中的启动命令;
如果设置了K8S command,没设置K8S args,则容器启动时只会使用K8S command;
如果没设置K8S command,设置了K8S args,则镜像如果有设置ENTRYPOINT,则K8S args作为ENTRYPOINT的参数(并覆盖CMD),如果镜像只设置了CMD,则K8S args覆盖CMD作为启动命令。
(本文没有总结)
如果觉得本篇文章对你有帮助,求求你点个赞,加个收藏最后再点个关注吧。创作不易,感谢支持!