添加链接
link管理
链接快照平台
  • 输入网页链接,自动生成快照
  • 标签化管理网页链接
相关文章推荐
细心的泡面  ·  Hashicorp Vault : “ ...·  5 天前    · 
发财的太阳  ·  GitHub - ...·  4 天前    · 
勤奋的鸭蛋  ·  RUN Instruction Using ...·  14 小时前    · 
豁达的跑步鞋  ·  DeepL Translate: The ...·  4 月前    · 
狂野的松树  ·  OSS报SignatureDoesNotMa ...·  5 月前    · 

本文将对 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。因为会引用基础镜像,那么构建出来的镜像大小首先就会包含基础镜像的大小;
  • ADDCOPY。这两个指令主要实现构建镜像时向镜像中添加文件或目录,添加成功则在原镜像基础上新增一层,并且新增的这一层的大小就是添加的内容的大小;
  • RUN。运行指令,运行结果如果对文件系统产生了更改(新增,删除和更新文件),那么更新的这部分内容会作为镜像新的一层。注意,RUN指令不一定会增加镜像的大小,如果没有对文件系统产生影响,那么增加的镜像的层的大小会为0。
  • 那么现在假如有5个镜像,每个镜像1G,那么这5个镜像在磁盘上占用的空间就是5G吗,那自然不是的。前面分析已经知道,镜像是一个层级结构,一个镜像是一层一层叠加起来的一个整体,但是每一层的内容实际是可以被复用的,图示如下。

    所以关于镜像的大小,可以总结如下。

  • 镜像的总大小由每一层的大小相加得到;
  • FROMADDCOPYRUN指令增加的层会导致镜像大小增加;
  • 多个镜像总大小不等于多个镜像在磁盘上占用的总大小;
  • 镜像的复用其实是镜像之间可以复用层。
  • 四. Dockerfile常用指令

    1. ADD

    ADD指令用于从源地址src复制文件,目录或远程文件,然后添加到目标地址dest的文件系统中。

    ADD指令有如下两种使用格式。

  • ADD [--chown=<user>:<group>] [--checksum=<checksum>] <src>... <dest>
  • ADD [--chown=<user>:<group>] ["<src>",... "<dest>"]
  • 说明如下。

  • 如果路径中有空格,请使用上述的第2种格式;
  • --chown 用于给添加内容指定所有权。其中usergroup都可以使用UIDGID来表示;
  • --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如果是常用压缩格式(gzipbzip等)的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用于给添加内容指定所有权。其中usergroup都可以使用UIDGID来表示;
  • 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 param2shell格式,以shell来执行command的命令。
  • 对于CMD指令的说明如下。

  • Dockerfile中只能有一条CMD指令,如果出现多条则以最后一条为准;
  • CMD指令主要是用来为容器提供默认值(上述第一种和第二种格式),这些默认值中可以有可执行文件(第一种格式中的"executable"),也可以没有(第二种格式就没有可执行文件),如果没有可执行文件时,则需要再指定ENTRYPOINT指令;
  • 如果CMD指令用于给ENTRYPOINT提供参数,那么CMD需要使用上述第二种格式,ENTRYPOINT也需要使用ENTRYPOINT ["executable", "param1", "param2"] 这种格式;
  • 如果CMD使用shell格式,则命令将以 /bin/sh -c 'command param1 param2' 形式执行。
  • CMDENTRYPOINT有很大的相似性和关联,将在下面介绍完ENTRYPOINT后一起进行说明。

    4. ENTRYPOINT

    ENTRYPOINT指令用于为容器配置可执行文件(什么是可执行文件,topecho这种就是可执行文件)(官网的解释)。其实就是配置每次创建容器时需要执行的指令,和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在前台运行ApachePID是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"]
    

    如果需要为可执行文件编写启动脚本,可以使用execgosu命令确保最终可执行文件接收到宿主机发送的信号。

    #!/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官方建议我们在脚本中最终启动程序时,要使用execgosu来启动程序。

  • 因为脚本在运行的时候也是一个进程(会占用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 execdocker 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
    

    ENTRYPOINTshell格式中,可以将ENTRYPOINT指定为纯字符串,此时ENTRYPOINT将基于 /bin/sh -c来执行。这种情况可以使用shell的方式来进行shell环境变量替换(比如echo $HOME),并且不会使用任何CMDdocker 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
    

    可以发现可执行文件topPID不是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

    (翻译结束,感觉官网写得很清晰了)

    现在是不是对CMDENTRYPOINT有了较为清晰的认识,下面是对CMDENTRYPOINT的(说人话的)总结。

  • CMDENTRYPOINT指令都可以定义运行容器时需要执行的命令;
  • CMD会被docker run <arguments>指定的arguments内容覆盖,ENTRYPOINT不会;
  • 如果想覆盖ENTRYPOINT,需要通过docker run --entrypoint来实现,此时覆盖后的ENTRYPOINT默认是exec格式;
  • CMDENTRYPOINTDockerfile中只能有一个生效,并且都是最后一个生效;
  • 当要在启动容器时通过CMDENTRYPOINT直接运行一个程序,使用exec格式能让启动的程序的进程PID是1;
  • 当要在启动容器时通过CMDENTRYPOINT直接运行一个程序,使用shell格式会导致启动的程序的进程PID不是1,但是也有例外,在shell格式中也可以使用exec,例如ENTRYPOINT exec top -b,此时效果和exec格式的ENTRYPOINT ["top" "-b"] 效果是一样的;
  • 如果是通过CMD或者ENTRYPOINT来运行一个脚本,然后在脚本中来启动我们的程序,那么脚本中启动的地方需要使用execgosu,才能确保我们的程序的进程PID是1;
  • CMDENTRYPOINTDockerfile中同时存在时,CMD的作用就是给ENTRYPOINT提供参数,此时CMDENTRYPOINT都应该是exec格式;
  • CMDENTRYPOINTDockerfile中同时存在时,如果ENTRYPOINTshell格式,那么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'
    

    那么,可以CMDENTRYPOINT都没有吗,自然是不可以的。

    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中任何RUNCOPYADDCMDENTRYPOINT指令设置工作目录(进入这个目录),并且当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指令设置的环境变量,可用在镜像构建阶段的后续的所有指令中例如RUNWORKDIR等指令,并且最终会作为容器中的环境变量。

    注意,关于设置的环境变量,有如下注意点。

  • 引号如果没有被转义,那么会被删除;
  • 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如下所示(v6v7v8v9)。

    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),用作容器的主进程的用户名和用户组,以及用作当前构建阶段后续的RUNCMDENTRYPOINT指令执行时的用户。

    五. 容器Contianer

    1. 容器存储分层结构

    当基于一个镜像,运行一个容器时,这个容器的存储分层结构如下。

    创建容器时,镜像会作为容器的镜像层(基础层),是只读的,然后在镜像层上会添加一层可写层,称为容器层,在容器运行时,对容器所作的更改(增删改文件)都会写入到容器层。

    对于容器层,有如下注意点。

  • 容器层是可写的。在容器中做的文件增删改都会写入到容器层;
  • 容器层在容器删除时也会被删除。容器层是不做持久化的,容器销毁,容器层也会一并被销毁,相应的对容器的修改也会删除,但是镜像层可以保持不变,不会随容器销毁而销毁;
  • 每个容器都有自己的容器层。基于相同镜像构建的容器会共享同一个底层镜像,同时每个容器自己的变化会写入到容器自己的容器层,因此多个容器可以共享同一个底层镜像,同时也拥有自己的状态。
  • 2. 容器的大小

    要查看运行中的容器的大小,可以使用docker ps -s命令,一个示例如下。

    说明如下。

  • SIZE。表示容器的容器层的大小;
  • virtual。表示镜像层加上容器层的大小。
  • 也就是多个基于相同镜像的容器占用的磁盘实际大小是远小于这些镜像的virtual大小之和的。

    3. CoW写时复制策略

    关于CoW写时复制策略的概念如下所示。

    如果一个文件在镜像的较底层,而镜像的较高层或者容器层要读取这个文件,则直接读取这个文件即可。但如果镜像的较高层或者容器层要修改这个文件(构建镜像或者容器运行的时候),则需要将这个文件从镜像较低层拷贝到当前层再做修改。

    已知在容器运行时,在镜像层之上会有一个容器层。还已知容器对文件系统所作的更改会存储在容器层,容器未更改的文件则不会拷贝到容器层,这样的策略使得容器层非常小,非常薄。

    当容器要修改容器中的文件时(无论容器层还是镜像层中的文件),整体的一个执行策略如下。

  • 修改的文件在容器层中,则直接修改;
  • 从镜像层的上层到下层依次遍历,直到找到需要修改的文件(这一步操作的结果会添加缓存,用于下次遍历提速);
  • 对遍历到的第一个文件执行复制操作,并复制到容器层;
  • 后续这个文件的用户视角只会是容器层中修改后的文件。
  • 特别注意:如果容器存在大量的写操作,则尽量不要写在容器(容器层)中,而应该为容器挂载持久化卷并写在持久化卷中。

    4. docker commit

    将容器的容器层中的文件更改作为镜像新的一层然后生成新镜像。注意点如下。

  • 镜像新的一层不包含挂载的持久化卷里面的数据;
  • docker commit时会暂停容器中的进程。
  • 六. 其他内容

    1. 构建缓存的使用

    在构建镜像时,中间镜像是可以被缓存和复用的,规则如下。

  • ADDCOPY指令。如果某一个中间镜像(A镜像)已经是被缓存的,而此时正要在A镜像基础上执行下一条指令(m指令),那么会遍历A镜像的所有子镜像,以查看这些子镜像中是否有一个子镜像是基于m指令构建的;
  • ADDCOPY指令。会基于父镜像的所有文件和本次指令添加的文件计算checksum(校验和),然后和当前所有中间镜像的checksum做比较,有相等的则命中缓存的镜像;
  • 一旦某一层没有命中缓存,那么从这一层开始,后续所有指令都不会使用缓存。
  • 2. RUN指令使用建议

  • 复杂的RUN语句需要拆分到多行,并用反斜杠 \ 分隔,以提升RUN语句的可读性;
  • 需要将apt-get updateapt-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 commandK8S args,则容器启动时会使用K8S commandK8S args
  • 如果没设置K8S commandK8S args,则容器启动时使用Docker镜像中的启动命令;
  • 如果设置了K8S command,没设置K8S args,则容器启动时只会使用K8S command
  • 如果没设置K8S command,设置了K8S args,则镜像如果有设置ENTRYPOINT,则K8S args作为ENTRYPOINT的参数(并覆盖CMD),如果镜像只设置了CMD,则K8S args覆盖CMD作为启动命令。
  • (本文没有总结)

    如果觉得本篇文章对你有帮助,求求你点个赞,加个收藏最后再点个关注吧。创作不易,感谢支持!