written by SJTU-XHW

Reference:Docker 官方文档

本人学识有限,解析难免有错,恳请读者能够批评指正,本人将不胜感激!


前置知识:Linux、Git、虚拟机、略懂操作系统

Chapter 0. Docker 安装

Mac 安装

1
brew install --cask docker

Linux 任何 distribution 自动安装

1
2
curl -fsSL get.docker.com -o get-docker.sh    # 自动安装脚本
sudo sh get-docker.sh --mirror Aliyun # 国内阿里云,如果你在国外,删除 --mirror参数

Ubuntu 手动安装

  1. 卸载旧版本

    1
    2
    3
    sudo apt-get remove docker \
    docker-engine \
    docker.io
  2. 安装 HTTPS 必要软件包 和 CA 证书防止安装包篡改

    1
    2
    3
    4
    5
    6
    7
    8
    sudo apt-get update

    sudo apt-get install \
    apt-transport-https \
    ca-certificates \
    curl \
    gnupg \
    lsb-release
  3. 添加软件源的 GPG 密钥,以确认所下载软件包的合法性

    1
    2
    3
    4
    curl -fsSL https://mirrors.aliyun.com/docker-ce/linux/ubuntu/gpg | sudo gpg --dearmor -o /usr/share/keyrings/docker-archive-keyring.gpg

    # 如果你在国外,或者正在科学上网,请使用国外密钥:
    # curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /usr/share/keyrings/docker-archive-keyring.gpg
  4. 向 APT 源添加国内软件源(stable 版本)

    1
    2
    3
    4
    5
    6
    7
    8
    echo \
    "deb [arch=amd64 signed-by=/usr/share/keyrings/docker-archive-keyring.gpg] https://mirrors.aliyun.com/docker-ce/linux/ubuntu \
    $(lsb_release -cs) stable" | sudo tee /etc/apt/sources.list.d/docker.list > /dev/null

    # 如果你在国外……
    # echo \
    # "deb [arch=amd64 signed-by=/usr/share/keyrings/docker-archive-keyring.gpg] https://download.docker.com/linux/ubuntu \
    # $(lsb_release -cs) stable" | sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
  5. 更新 APT 缓存并安装 docker-ce

    1
    2
    sudo apt-get update
    sudo apt-get install docker-ce docker-ce-cli containerd.io docker-compose-plugin
  6. 启动 Docker 服务并建立对应用户组

    1
    2
    3
    4
    5
    sudo systemctl enable docker
    sudo systemctl start docker

    sudo groupadd docker
    sudo usermod -aG docker $USER

Chapter 1. 基本概念

1.1 具体原理、地位和性质

本部分内容涉及内容比较深,学完 Go 和 操作系统再回来填坑……

1.2 Docker 和 传统虚拟化技术的比较

如上图所示,传统虚拟化技术构建出的 Hypervisor 需要虚拟出一套硬件,并在其上运行一个完整的操作系统(Guest OS),再在该系统上运行所需进程;

而 Docker 容器中的应用进程借助 Docker Engine 直接运行于宿主内核,无需硬件虚拟;

特性 容器 虚拟机
启动 秒级 分钟级
硬盘使用 一般为 MB 一般为 GB
性能 接近原生 弱于
系统支持量 单机支持上千个容器 一般几十个
迁移和部署 容易 困难
维护和扩展 高质量官方镜像 极其困难

1.3 镜像

  • 操作系统分为 内核用户空间。对于 Linux 而言,内核启动后,会挂载 root 文件系统为其提供用户空间支持;

  • Docker 镜像Image),就相当于是一个 root 文件系统(实际上就是一个特殊的文件系统);除了提供容器运行时所需的程序、库、资源、配置等文件外,还包含了一些为运行时准备的一些配置参数(如匿名卷、环境变量、用户等);

  • Docker 镜像 不包含 任何动态数据,其内容在构建之后也不会被改变;

  • Docker 镜像采用 Union FS 技术(操作系统术语),设计为 分层存储 的架构——并非像 *.iso 一样打包成一个文件,而是一组、多层文件系统联合而成

    1. 镜像的构建时,会一层层构建;前一层是后一层的基础;

    2. 每一层构建完就不再改变,后一层的任何更改只发生在自己这层

      比如,删除前一层文件的操作,实际不是真的删除前一层的文件,而是仅在当前层标记为该文件已删除(最终容器运行时,也不会看到);

    3. 优点:分层存储的特征还使得镜像的复用、定制变的更为容易

  • 镜像的构建方法以后再说;

1.4 容器

  • Docker 镜像(Image)和容器(Container)的关系,可以看成面向对象程序设计中的 实例

    镜像是静态的定义,容器是镜像运行时的实体;

  • 容器的实质是进程(process),但和运行在 Host OS 上的普通进程不一样,有着独立的命名空间Linux namespace),包含独立的 root 文件系统、网络配置、进程空间、用户 ID 空间

    体现隔离特性,有利于保证宿主系统的安全性;

  • 容器 和 镜像一样,使用分层存储——以镜像为基础层,在其上创建一个当前容器的存储层,称这个为容器运行时访问而准备的存储层为 容器存储层

    容器存储层的生存周期和容器一样,容器消亡时,容器存储层也随之消亡。因此,任何保存于容器存储层的信息都会随容器删除而丢失;

    tips 1. 按 Docker 规范,容器不应该向其存储层内写入任何数据,容器存储层要保持无状态化

    注:Instead,应该使用 数据卷(Volume,类似 Linux 中的 mount 挂载目录)、或者 绑定宿主目录(这两种方法将在 Chapter 5 介绍),在这些位置的读写会跳过容器存储层,直接对宿主(或网络存储)发生读写,其性能和稳定性更高;

    相反,当我们运行一个容器的时候(如果不使用卷的话),我们做的任何文件修改都会被记录于容器存储层里,导致多个无关文件被修改,不利于迁移、维护

    tips 2. 数据卷的生存周期独立于容器,容器消亡,数据卷不会消亡,因此在容器被删除或重启后,在数据卷中的数据不会丢失;

1.5 仓库

  • Docker 社区提供集中的存储、分发镜像的服务,称为Docker Registry 公开服务,最常用的是 Docker Hub(可以理解成类似 GithubGitee 一样):提供 公共仓库(public repository) 服务,允许用户免费上传、下载公开的镜像;

    因此,Registry 和 Repository 是两个完全不同的概念;前者指注册的、提供这些服务的服务器,后者指可以被认为是一个具体的项目或目录;

    例如,对于仓库地址 docker.io/ubuntu 来说,docker.io 是注册服务器地址,ubuntu 是仓库名;

  • 用户也还可以借助 ① Docker 开发团队的开源 Docker Registry 镜像;或 ② 第三方软件(如 Harbor 和 Sonatype Nexus),来实现本地搭建私有 Docker Registry

Chapter 2. 使用镜像

2.1 镜像获取

  • Docker Hub 上获取:docker pull [options] [addr:port/]<RepositoryName>[:tag]

    • [options]:请运行 docker pull --help 查看;
    • [addr:port/]:默认 docker.io:443Docker Hub 服务器);
    • <RepositoryName>:格式为 <userName>/<softwareName>如果仅有 softwareName,则默认官方镜像(userName = library;
    • [:tag]镜像标签,可以理解成 Git 中的 tag,起到标识镜像版本或分类的作用;同一个镜像必须有相同的 ID,可以有不同的 tag

    下载会一层层下载(分层存储),完成后会显示每层 ID 前 12 位和镜像整体的 sha256 摘要;

2.2 列出镜像

  • 列出顶层镜像:docker image ls,显示 仓库名、标签、镜像 ID、创建时间和占用空间大小

  • 关于 “占用空间大小”:由于 Docker 使用 Union FS 和多层存储结构,不同层间可以继承、复用,相同的层只需要保存一份即可,因此实际镜像硬盘占用空间很可能要比上面命令显示的小很多;

    想要查看镜像、容器、数据卷所占用的真实空间,请运行:docker system df

  • 虚悬镜像(dangling image):一种特殊的顶层镜像,这个镜像既没有仓库名,也没有标签,均为 <none>

    • 产生原因:① 同一标签的镜像因为官方的维护,ID 发生变更,这样在 docker pull 同个镜像 tag 时,此镜像名被转移到了新下载的镜像身上,而旧的镜像上的这个名称则被取消;② 自己本地构建镜像(docker build,以后介绍)时,由于新旧镜像同名,旧镜像名称被取消;
    • 查找虚悬镜像docker image ls -f dangling=true(加 -f 参数,--filter 过滤);
    • 删除虚悬镜像:虚悬镜像已经失去了存在的价值,可以随意删除:docker image prune
  • 中间层镜像:想要看中间层镜像,需要使用 docker image ls -a(加 -a 参数);

    此时可能会出现没有标签的镜像,但它们不是虚悬镜像,而是中间层镜像,由于多层存储结构,它们相互依赖,千万不能盲目删除

  • 按条件筛选列出镜像docker image ls -f <dangling=bool/since=repositoryName/label=labelName>

  • 输出格式化docker image ls --format "<GoTemplate>"

    这里的 GoTemplate 是 Go 语言的模板语法,可以简单使用常见变量:{{.ID}}{{.Repository}}{{.Tag}}

2.3 删除镜像

  • 镜像 ID 删除:docker image rm <IMAGE_ID>

    一般只有脚本使用完整 ID,人工一般都使用短 ID,对长度没有要求,只要能区分就行(一般 长度大于 3 就能区分)

  • repositoryName 删除:docker image rm <RepositoryName>

  • 镜像 sha256 摘要删除:docker image <name@sha256:VALUE>

    查询镜像 sha256 摘要:docker image ls --digests

  • 补充:UntaggedDeleted 的区别:请结合 Git 自行品味;Git 和这个很相似;

  • 查询删除法docker image rm $(docker image ls -q <之前的查询条件>)

2.4 镜像定制和构建

注:第一次学习感觉有难度的可以先跳过本节,直接进入下一章 Chapter 3!

镜像的定制实际上就是定制每一层所添加的配置、文件

正如官方文档所说:如果我们可以把每一层修改、安装、构建、操作的命令都写入一个脚本,用这个脚本来构建、定制镜像,那么之前提及的无法重复的问题、镜像构建透明性的问题、体积的问题就都会解决;

这个脚本(文本文档)就是 Dockerfile。和 MakefileCMakeLists 有异曲同工之妙,名字必须是 Dockerfile

2.4.1 Dockerfile 定制

Dockerfile 包含了一条条的 指令(Instruction),每条指令构建一层,每行一条指令,末尾没有分号;

FROM 指令:指定基础镜像
  • 语法:FROM <REPO_NAME><REPO_NAME> 为之前提到的存储库名;

  • 所谓定制镜像,那一定是以一个镜像为基础,在其上进行定制

  • 因此一个 DockerfileFROM必备的指令,并且必须是第一条指令

  • Docker Hub 上有很多高质量基础镜像:

    1. 服务类镜像:nginxredismongomysqlhttpdphptomcat 等;
    2. 语言应用类镜像:nodeopenjdkpythonrubygolang 等;
    3. 操作系统类镜像(具有对应软件库可更自由地配置):ubuntudebiancentos 等;
  • 空白基础镜像scratch(意味着不以任何镜像为基础,接下来所写的指令将作为镜像第一层开始存在);

    不以任何系统为基础,直接将可执行文件复制进镜像的做法并不罕见:

    1. Linux 下静态编译的程序(所需的一切库都已经在可执行文件中,无需操作系统的运行时支持);
    2. 大部分使用 Go 语言开发的应用(Go 特别适合容器微服务架构的原因之一);
RUN 指令:执行命令
  • 语法:RUN <SH_COMMAND>RUN ["executableName", "param1", ...]

    • RUN <SH_COMMAND> 参数会自动翻译为 RUN ["sh", "-c", "<SH_COMMAND>"]
  • 注意:每一个 RUN 指令都会新建一层镜像层,因此从重用性角度出发,同一个目的的操作尽量写在一个 RUN 语句中,例如:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    # 这样的 Dockerfile 编写方式是不恰当的!!!结果就是产生非常臃肿、非常多层的镜像,不仅仅增加了构建部署的时间,也很容易出错。而且很多运行时不需要的东西,都被装进了镜像里,比如编译环境、更新的软件包等等
    FROM debian:stretch

    RUN apt-get update
    RUN apt-get install -y gcc libc6-dev make wget
    RUN wget -O redis.tar.gz "http://download.redis.io/releases/redis-5.0.3.tar.gz"
    RUN mkdir -p /usr/src/redis
    RUN tar -xzf redis.tar.gz -C /usr/src/redis --strip-components=1
    RUN make -C /usr/src/redis
    RUN make -C /usr/src/redis install
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    # 这段代码只有一个目的,就是编译、安装 redis 可执行文件,所以没有必要建立很多层,这只是一层的事情。
    FROM debian:stretch

    RUN set -x; buildDeps='gcc libc6-dev make wget' \
    && apt-get update \
    && apt-get install -y $buildDeps \
    && wget -O redis.tar.gz "http://download.redis.io/releases/redis-5.0.3.tar.gz" \
    && mkdir -p /usr/src/redis \
    && tar -xzf redis.tar.gz -C /usr/src/redis --strip-components=1 \
    && make -C /usr/src/redis \
    && make -C /usr/src/redis install \
    # 以下清理文件是非常重要的步骤,确保每一层只添加真正需要添加的东西,任何无关的东西都应该清理掉。
    && rm -rf /var/lib/apt/lists/* \ # 清理了 apt 缓存文件
    && rm redis.tar.gz \ # 清理了所有下载、展开的文件
    && rm -r /usr/src/redis \
    && apt-get purge -y --auto-remove $buildDeps # 删除了为了编译构建所需要的软件和库

    更重要的是,每个 RUN 指令由于所处的层数不一样,所以执行这个命令的命令行也不一样,这样就相当于开了新的命令行窗口,有的时候第一种写法甚至是错误的、无法运行的,例如:

    1
    2
    RUN cd /app
    RUN echo "hello" > world.txt

    你会发现 world.txt 根本不在 /app/ 下面,因为下一个 RUN 的 console 已经不在 /app/ 下面了;这就是对 Docker 分层存储理解不透的结果。也正是这个原因,请不要把 Dockerfile 作为 shell 脚本使用!

    想要实现上面的操作,可以改成:RUN cd /app && echo "hello" > world.txt

    ⚠ 如果你实在需要分成多个 RUN 步骤(例如为了容器层的可重用性——中间多一层可以有其他用处),但又希望命令行固定在某个目录下进行,请参考下文 WORKDIR 指令的使用

COPY 指令:复制文件
  • 语法:COPY [--chown=<user:group>] <src1>[, src2, ...], <dist1>[, dist2, ...]

  • 支持 --chown 参数更改文件所属用户和组;

  • 使用 COPY 指令,源文件的各种元数据都会保留。比如读、写、执行权限、文件变更时间等;

  • src 源路径必须在上下文路径中,关于什么是上下文路径,建议立即查看 下文🔗

  • 不仅可以指定多个文件,src 还支持 Go 语言的 filepath.Match 通配符规则,可以简单认为:

    Regex 正则 Go::filepath.Match
    .* *
    .? ?
    [a-zA-Z] [{a-zA-Z}]
  • 如果源路径为文件夹,复制的时候不是直接复制该文件夹,而是将文件夹中的内容复制到目标路径;举个例子:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    .
    |
    |--- Dockerfile
    |--- ABC.txt
    |--- testDir/
    |
    |--- DEF.txt
    |--- testDir2/
    |
    |--- FGH.txt
    |--- IJK.txt

    上面的项目目录,如果在 Dockerfile 中有这么一段:

    1
    2
    3
    ...
    COPY . /app/
    ...

    并且构建镜像以 testDir 为上下文根目录:docker build -f Dockerfile -t XXX ./testDir/,那么在容器中,testDir 本身不会在里面

    1
    2
    3
    4
    5
    6
    app/
    |--- DEF.txt
    |--- testDir2/
    |
    |--- FGH.txt
    |--- IJK.txt
ADD 指令:自动解压并复制文件
  • COPY 语法几乎相同,真正需要 ADD 的场景是自动解压缩,即 <src> 路径是一个 *.zip/*.bz/*.xz/*.gz/*.tar 时会自动解压缩;其他时候应该使用语义明确的 COPY
CMD 指令:设置容器启动命令
  • 用于指定默认的容器主进程的启动命令(容器启动指令 docker run 将在 3.1 介绍),语法和 RUN 相同;

  • 如果指定了该命令,那么当运行 docker run 后,默认运行容器中的 /bin/bash 的行为将被更改为 CMD 后的指令;

  • 易错点:前台执行 && 后台执行;先问大家伙一个问题,下面的指令有意义吗?

    1
    2
    3
    FROM nginx
    RUN echo "<h1>Hello, nginx!</h1>" > /usr/share/nginx/html/index.html
    CMD systemctl start nginx

    如果你认为没有任何问题的话,恭喜你,你成功地将 传统虚拟机 和 docker 容器搞混了! 请你好好复习 1.2 和 1.4 的内容:容器本身就是一种进程,它不是虚拟机、内部不存在守护进程,因此不允许在“后台”启动应用,必须在前台,否则会直接退出

    引用官方文档的一句话:对于容器而言,其启动程序就是容器应用进程,容器就是为了主进程而存在的,主进程退出,容器就失去了存在的意义,从而退出;

    上面的 CMD systemctl start nginx 会被解释成:CMD ["sh", "-c", "service nginx start"],这样运行完这条指令后主进程直接结束!

    想要在容器生存周期内持续运行 nginx,必须以指定前台的方式运行,如下:

    1
    2
    3
    FROM nginx
    RUN echo "<h1>Hello, nginx!</h1>" > /usr/share/nginx/html/index.html
    CMD ["nginx", "-g", "daemon off;"]
ENTRYPOINT 指令:设置容器入口点
  • 语法和 RUNCMD 都相同,它存在的主要作用有两个:① 更方便地从外部添加运行参数;② 更方便地进行容器运行前准备工作

  • 想理解主要作用,需要知道 ENTRYPOINT 如何工作:当指定 ENTRYPOINT 后,CMD 指令的意义发生变化:CMD 默认值变为空,并且CMD 将作为 ENTRYPOINT 的参数进行传递;这使得 ENTRYPOINT 实现了上述两个优点;分别举一个栗子🌰说明:

    假设有一个镜像是这么设计的:

    1
    2
    3
    4
    5
    FROM ubuntu:18.04
    RUN apt-get update \
    && apt-get install -y curl \
    && rm -rf /var/lib/apt/lists/*
    CMD [ "curl", "-s", "http://myip.ipip.net" ]

    如果想向里面的 curl 命令添加参数,例如 -i,是做不到的;

    (之后会介绍 docker run [options] <containerName> [CMD] 启动容器的命令,在”容器名“后面的参数是 CMD,会覆盖 Dockerfile 中的 CMD 指令)

    但是如果这么写就不一样了:

    1
    2
    3
    4
    5
    FROM ubuntu:18.04
    RUN apt-get update \
    && apt-get install -y curl \
    && rm -rf /var/lib/apt/lists/*
    ENTRYPOINT [ "curl", "-s", "http://myip.ipip.net" ]

    这样在 docker run 最后指定 CMD 时,这个参数会传递给 ENTRYPOINT 作为参数,完美解决这个传参问题;

    再假设我们在使用一个数据库镜像,可能需要以 root 身份完成一些数据库配置、初始化的工作,再根据需要切换用户以保证安全性比如官方 Redis 镜像是这么做的

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    # File: Dockerfile
    FROM alpine:3.4
    ...
    # root 身份建立用户组相关设置
    RUN addgroup -S redis && adduser -S -G redis redis
    ...
    ENTRYPOINT ["docker-entrypoint.sh"] # 容器启动后执行该脚本

    EXPOSE 6379
    CMD [ "redis-server" ] # 因为上文有 ENTRYPOINT,所以这里不是指容器的启动命令,
    # 而是指传给 ENTRYPOINT 的默认参数是 "redis-server"
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    #!/bin/sh
    ...
    # allow the container to be started with `--user`
    if [ "$1" = 'redis-server' -a "$(id -u)" = '0' ]; then
    find . \! -user redis -exec chown redis '{}' +
    exec gosu redis "$0" "$@"
    fi

    exec "$@"

    # 这个脚本指的就是根据 CMD 参数的内容来判断,如果是 redis-server 的话,
    # 则切换到 redis 用户身份启动服务器,否则依旧使用 root 身份执行
ENV 指令:设置环境变量
  • 语法:ENV <key> <value>ENV <key1>=<value1> [<key2>=<value2> ...](语法特殊,只有多个环境变量参数间不用加逗号),环境变量将停留在容器的全生命周期中

  • 它的作用:设置合适的环境变量可以让维护工作变得轻松,例如 node 官方镜像的 Dockerfile:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    # ...

    ENV NODE_VERSION 7.2.0

    RUN curl -SLO "https://nodejs.org/dist/v$NODE_VERSION/node-v$NODE_VERSION-linux-x64.tar.xz" \
    && curl -SLO "https://nodejs.org/dist/v$NODE_VERSION/SHASUMS256.txt.asc" \
    && gpg --batch --decrypt --output SHASUMS256.txt SHASUMS256.txt.asc \
    && grep " node-v$NODE_VERSION-linux-x64.tar.xz\$" SHASUMS256.txt | sha256sum -c - \
    && tar -xJf "node-v$NODE_VERSION-linux-x64.tar.xz" -C /usr/local --strip-components=1 \
    && rm "node-v$NODE_VERSION-linux-x64.tar.xz" SHASUMS256.txt.asc SHASUMS256.txt \
    && ln -s /usr/local/bin/node /usr/local/bin/nodejs

    # ...
ARG 指令:设置构建参数
  • 语法与 ENV 相同,不过 ARG 只是构建镜像时临时存在的环境变量,并且只在 FROM 中生效,想要在其他语句中使用,必须重新指定,举几个栗子🌰:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    # 情况 1 ----------------------------
    ARG DOCKER_USERNAME=library

    FROM ${DOCKER_USERNAME}/alpine

    RUN set -x ; echo ${DOCKER_USERNAME} # 这步是无效输出

    # 情况 2 -----------------------------
    ARG DOCKER_USERNAME=library

    FROM ${DOCKER_USERNAME}/alpine

    # 要想在 FROM 之后使用,必须再次指定
    ARG DOCKER_USERNAME=library

    RUN set -x ; echo ${DOCKER_USERNAME} # 这样才是有效输出

    # 情况 3(多阶段构建,后面说) -----------
    # 这个变量在每个 FROM 中都生效(使用的场合必须要都是 FROM)
    ARG DOCKER_USERNAME=library

    FROM ${DOCKER_USERNAME}/alpine

    RUN set -x ; echo 1

    FROM ${DOCKER_USERNAME}/alpine

    RUN set -x ; echo 2

    # 情况 4(多阶段构建)-------------------
    ARG DOCKER_USERNAME=library

    FROM ${DOCKER_USERNAME}/alpine

    # 在FROM 之后使用变量,必须在每个阶段分别指定
    ARG DOCKER_USERNAME=library

    RUN set -x ; echo ${DOCKER_USERNAME} # 有效输出

    FROM ${DOCKER_USERNAME}/alpine

    # 在FROM 之后使用变量,必须在每个阶段分别指定
    ARG DOCKER_USERNAME=library

    RUN set -x ; echo ${DOCKER_USERNAME} # 有效输出
VOLUME 指令:定义匿名卷
  • 语法:VOLUME <inContainerPath>VOLUME "<inPath1>", "<inPath2>, ..."

  • 它的作用:在 之前 提到过,容器运行时应该尽量保持容器存储层不发生写操作,所以数据库类需要保存动态数据的应用,其数据库文件应该保存于卷(volume)中(后面 5.1 会介绍数据卷),而为了防止用户运行容器 docker run 忘记加 -v 参数(后面说)来指定目录挂载卷,可以在 Dockerfile 中写 VOLUME 匿名卷,相当于“没有指定目录名的默认卷”

    这样在运行时如果用户不指定挂载,其应用也可以正常运行,不会向容器存储层写入大量数据;

    注意:匿名卷正因为匿名、没有指定在 Host OS 上的挂载目录,docker engine 会自动选择诸如: /var/lib/docker/ 这样的一些位置存起来,在容器删除后也不会自动删除,得自己找,比较麻烦;

    所以尽量自己记得挂载数据卷,不要依赖匿名卷

EXPOSE 指令:声明端口
  • 语法:EXPOSE <port1> [port2 ...] (和 ENV 一样,多个参数没逗号);
  • 注意:是声明,而不是直接暴露,意味着在容器运行时并不会因为这个声明应用就会开启这个端口的服务;那它有什么用呢?
  • 它的作用:① 帮助镜像使用者理解这个镜像服务的守护端口,以方便自行配置映射;② 当使用 docker run-P(大写) 参数(后面介绍,是将容器端口开放到 Host OS 的随机端口)时,默认使用 EXPOSE 声明的端口;
  • 辨析:EXPOSE 不能和 docker run-p(小写) 参数(也是后面介绍)作用搞混,后者是真的会进行从宿主到容器的端口映射,而前者只是声明
WORKDIR 指令:指定工作目录
  • 语法:WORKDIR <inContainerDir>(如果容器内该目录不存在,则会自动创建);

  • 它的作用:改变以后各层的工作目录位置,确保每一层的命令行默认位置都在该目录下;如果参数是相对路径,那么和之前的工作目录有关,例如:

    1
    2
    3
    4
    5
    WORKDIR /a
    # ...
    WORKDIR b
    WORKDIR c
    RUN pwd # 此时在容器内命令行中打印的应该是 /a/b/c/
USER 指令:指定当前用户
  • 语法:USER <userName>[:userGroup]

  • 它的作用:改变之后层的执行 RUN, CMD 以及 ENTRYPOINT 这类命令的身份(之前说过,docker 容器有一套完整独立的命名空间);

  • WORKDIR 的异同比较:

    • WORKDIR 的作用效果类似,都是改变环境状态并影响以后的层
    • WORKDIR 不同的是,如果指定用户不存在,则无法切换——此指令不会自动创建用户
  • ⚠ 如果要建立一个用户、用户组(大多数时候用 RUN),并且在 RUN 的中途想要切换用户(不添加中间层的情况,就没法使用 USER),请 一定不要 使用 susudo,因为通常在刚下载的原始镜像中都需要比较麻烦的配置,而且在 TTY 缺失的环境下经常出错;

    尤其是这种情况:如果容器内的应用需要 “优雅停机”(接收信号量 SIGABRT,那么一定需要这个应用的进程在容器内的 PID = 1;而运行 su/sudo 会先建立 sudo 进程,再建立后面的进程,不能保证容器应用的 PID = 1!

    正确做法之一是下载并使用 gosu,以 redis 镜像中用户设置和切换为例:

    1
    2
    3
    4
    5
    6
    7
    8
    # 建立 redis 用户和用户组
    RUN groupadd -r redis && useradd -r -g redis redis
    # 下载 gosu
    RUN wget -O /usr/local/bin/gosu "https://github.com/tianon/gosu/releases/download/1.12/gosu-amd64" \
    && chmod +x /usr/local/bin/gosu \
    && gosu nobody true
    # 设置 CMD,并使用 gosu 换另外的用户传参(gosu 使用方法:gosu + user + cmd)
    CMD [ "exec", "gosu", "redis", "redis-server" ]

    至于使用 gosu 而不是 su/sudo 的深层原因,我看有篇博客写的不错,如果感兴趣这方面的原理可以戳 这里🔗;不过看懂英文的话,最好看 gosu 官方解释:github 传送门

    另一种正确方法比较方便,是使用 chroot--userspec 参数chroot --userspec=<userName>,可以完成相似效果;

HEALTHCHECK 指令:健壮性检查
  • 语法:HEALTHCHECK [OPTIONS] CMD <HOST_COMMAND>HEALTHCHECK NONE

    • HEALTHCHECKCMDENTRYPOINT 一样,只可以出现一次,如果写了多个,只有最后一个生效
    • [options]--interval=<带单位的数值>--timeout=<...> 前面两个都默认 30s--retries=<N> 默认 3 次,超过则认为不健康;
    • CMD <...>:检查时由宿主机向容器内执行的指令,指令返回值代表本次检查是否成功:0:成功, 1:失败, 2:放弃结果
    • NONE:如果基础镜像有健康检查指令,使用这行可以屏蔽掉其健康检查指令;
  • 它的作用:引用官方文档的话:

    在没有 HEALTHCHECK 指令前,Docker engine 只可以通过容器内主进程是否退出来判断容器是否状态异常。很多情况下这没问题,但是如果程序进入死锁状态,或者死循环状态,应用进程并不退出,但是该容器已经无法提供服务了。

    而自 1.12 之后,Docker 提供了 HEALTHCHECK 指令,通过该指令指定一行命令,用这行命令来判断容器主进程的服务状态是否还正常,从而比较真实地反应容器实际状态。

  • 举个例子🌰:对于一个网络服务而言,如果需要检查 WEB 服务是否还有响应,那么可以这么写:

    1
    2
    3
    4
    FROM nginx
    RUN apt-get update && apt-get install -y curl && rm -rf /var/lib/apt/lists/*
    HEALTHCHECK --interval=5s --timeout=3s \
    CMD curl -fs http://localhost/ || exit 1 # 使用 curl 来判断
  • 健康检查的日志可以使用 docker inspect --format '{{json .State.Health}}' <containerName> 来查看(json 格式);

ONBUILD 指令:多级构建准备
  • 语法:ONBUILD <所有其他指令>

  • 它的效果:它后面跟的是其它指令,而在当前镜像构建时并不会被执行。只有当以当前镜像为基础镜像,去构建下一级镜像的时候才会被执行;

  • 它的作用:提升镜像多级构建的重用性和扩展性;这句非常抽象,需要举例说明;首先说明什么是多级构建——利用已构建好的镜像为基础,构建下一级镜像(事实上所有镜像都是这么构建的,但多级构建更强调这几个镜像都是自己写出来的

    以下的例子涉及 node.js 相关知识,只需了解 node.js 使用 npm 作为包管理器之一,相关包依赖和启动信息存在 package.json 中,可以类比成 Python 中的 piprequirements.txt;部署项目时需要在根目录下执行 npm install 安装依赖,才能运行项目;

    🌰 假设我们在一个 node.js 项目中,目录情况如下:

    1
    2
    3
    4
    5
    .
    |
    |--- Dockerfile
    |--- package.json
    |--- ... # 其他项目文件

    因此 Dockerfile 应该这么写:

    1
    2
    3
    4
    5
    6
    FROM node:slim
    WORKDIR /app # 创建并以 /app 为工作目录
    COPY ./package.json /app # 将当前上下文目录下的 package.json 文件复制到镜像中的 /app/ 下
    RUN [ "npm", "install" ] # 执行 sh -c npm install 安装项目依赖
    COPY . /app/ # 将当前上下文目录中(即客户端中的项目根目录)所有文件复制到 /app/ 下
    CMD [ "npm", "start" ] # 设置容器默认启动命令为 npm start

    很好。那如果还有一个项目,项目文件不一样,但依赖的包和它一模一样,如果想要定制为镜像,应该怎么办?你可能会说,简单!把 Dockerfile 直接复制过去不就行了?好,那如果还有 5 个、10个呢?直接复制 Dockerfile 一定不现实,会给版本控制造成极大阻碍(例如,如果你想把这些所有项目的镜像基础 node:slim 换成 node:alpine,那你得一个个改);

    聪明的人会想,这也好办,直接把和项目无关的部分提出来——这样公共部分只要修改一次就行(这就是多级构建的思想,注意和后面的 多阶段构建区分):

    1
    2
    3
    4
    # 公共镜像的 Dockerfile
    FROM node:slim
    WORKDIR /app
    CMD [ "npm", "start" ]

    假设这个生成的镜像名叫 my-node,那么某一个用到这个镜像的项目的 Dockerfile 可以这么写:

    1
    2
    3
    4
    5
    # 用到公共镜像的某个项目镜像的 Dockerfile
    FROM my-node
    COPY ./package.json /app
    RUN [ "npm", "install" ]
    COPY . /app/

    这么进行 “多级构建” 是没有问题的;但是,如果项目创建容器时,我想给每个项目的启动命令 npm install 都加个相同的参数呢?哦吼,完了,问题又回去了,是不是要一个个改呢……有些同学会说:那把 RUN 挪到公共镜像的 Dockerfile 中?不行。因为下面的 COPYRUN 和项目有关,如果这样构建,某个项目的文件就进入公共镜像中,显然不符合重用的要求

    这个时候,应该用 ONBUILD 完成:

    1
    2
    3
    4
    5
    6
    7
    8
    # 公共镜像的 Dockerfile
    FROM node:slim
    RUN mkdir /app
    WORKDIR /app
    ONBUILD COPY ./package.json /app
    ONBUILD RUN [ "npm", "install" ]
    ONBUILD COPY . /app/
    CMD [ "npm", "start" ]

    这样 ONBUILD 这几步在构建公共镜像时不会运行,而各个项目只需要写:

    1
    FROM my-node

    这一句话就行!是不是非常方便!

LABEL 指令:添加镜像元数据 metadata
  • 一般是用来申明镜像的作者、文档地址等:

    1
    2
    LABEL org.opencontainers.image.authors="XXX"
    LABEL org.opencontainers.image.documentation="https://XXX"
SHELL 指令:指定命令行运行参数
  • 语法:SHELL ["executable", "params"]

  • 作用:用来指定 RUN ENTRYPOINT CMD 指令的 shell,Linux 中默认为 ["/bin/sh", "-c"]

    1
    2
    3
    SHELL ["/bin/sh", "-cex"]
    # 命令转为 /bin/sh -cex "nginx"
    ENTRYPOINT nginx
很重要的网站
  • Docker 官方镜像 DockerfileDockerfile contents - github

    在你不知道官方镜像的设计,或者想学习官方 Dockerfile 的用法时,这是极好的了解途径;

2.4.2 从容器快照定制

见 3.4 容器的导入和导出

2.4.3 构建镜像

  • 语法:docker build [options] <contextPath>

  • [options]最常用的是 -t <tagName>,给生成的镜像固定一个标签-f <DockerfilePath> 指定 Dockerfile

  • <contextPath>上下文路径,想要理解它,就需要理解部分 docker build 的工作原理

    • Docker 采用 C/S 架构设计,在运行时分为 Docker Engine(也就是服务端守护进程 containerd)和客户端工具(图片最上面的 4 个);

    • Docker 的引擎提供了一组 REST API,被称为 Docker Remote API,而如 docker 命令这样的客户端工具,则是通过这组 API 与 Docker 引擎交互,从而完成各种功能;这种 C/S 分离式的设计,使得操作远程服务器的 docker engine 和本地一样轻松

    • 所以什么是上下文路径(contextPath)?

      1. 构建镜像时,构建操作一定是在服务器端进行(C/S 架构),因此服务端会请求将 Dockerfile 所在目录(这不是 contextPath,因为默认不用 -f DockerfileName 指定的,引擎默认是上下文目录下的 Dockerfile)下的所有文件打包,并从客户端传递给 Docker Engine

        正因如此,① 应该将 Dockerfile 置于一个空目录下,或者项目根目录下;

        ② 如果该目录下没有所需文件,那么应该把所需文件复制一份过来

        ③ 如果目录下有些东西确实不希望构建时传给 Docker 引擎,那么可以用 .gitignore 一样的语法写一个 .dockerignore,该文件是用于剔除不需要作为上下文传递给 Docker 引擎的;

      2. 真正的 contextPath 是docker build 指定要压缩给服务器的目录并且从此以后,Dockerfile 中的所有的相对路径(如 COPY 等命令,也强烈建议是相对路径)的 “.” 都代表 docker build 中指定的上下文目录

    • 举个两个栗子🌰🌰帮助理解上面内容:

      1. 假设有一个项目结构是这样的,想要添加到 nginx 镜像中合成新的镜像:

        1
        2
        3
        4
        5
        6
        7
        .
        |
        |--- Dockerfile
        |--- .dockerignore
        |
        |--- index.html
        |--- webConfig # nginx 配置文件

        由于 Dockerfile 恰在项目根目录下,因此可以这么写(这里只是演示什么是上下文目录,不建议这么做,建议使用数据卷或挂载主机目录):

        1
        2
        3
        FROM nginx
        COPY ./index.html /usr/share/nginx/html/index.html
        COPY ./webConfig /etc/nginx/site-avaliable/webConfig

        那么指令应该这么写:docker build -t my_nginx:v1 .

        这里:

        ① 无需指定 Dockerfile,是因为上下文目录恰好指定的是当前目录,该目录下又恰好有 Dockerfile,因此不用指定;

        DockerfileCOPY 语句中的 “源目录(第一参数)” 中的 “.” 指的是 contextPath,也是在服务器端解压后的 “.”,而非客户端的“当前目录”

      2. 再假设有一个项目结构是这样的,想以 src/ 为整个项目打包进容器:

        1
        2
        3
        4
        5
        6
        7
        8
        .
        |
        |--- Dockerfile
        |--- .dockerignore
        |
        |--- src/
        |--- index.html
        |--- webConfig # nginx 配置文件

        那么如果 Dockerfile 这么写:

        1
        2
        3
        FROM nginx
        COPY ./index.html /usr/share/nginx/html/index.html
        COPY ./webConfig /etc/nginx/site-avaliable/webConfig

        则指令应该这么写:docker build -f ./Dockerfile -t my_nginx:v1 ./src/

        这里:

        指定了上下文路径是 src/,意味着只会给 docker engine 压缩打包这个目录,仅此而已

        ② 在这个上下文目录(src/)中,没有 Dockerfile,因此需要指定 Dockerfile 在客户端的位置; -f 参数指定 Dockerfile这个路径和 contextPath 无关,真的是客户端的路径

        ③ 在 Dockerfile 中,COPY 的源路径(第一参数)中的 “.” 可以理解为的是在服务器端刚解压后的当前路径,如下:

        1
        2
        3
        4
        . <--- 它就是上下文路径在 Dockerfile 中表示的含义
        |
        |--- index.html
        |--- webConfig

2.4.4 多阶段构建

  • 多阶段构建和多级构建的对比:后者的目的是为了提升重用性,多写了几个镜像并依赖构建;前者是因为原本的镜像过于庞大,要拆解镜像层次,人为降低镜像的体积;前者一次只会一次构建出一个镜像,后者强调分步构建好几个镜像;

  • 使用方法:就是一个 Dockerfile 文件中写好几个 FROM,引入 as 关键字,使得外部可以通过 docker build--target 参数指定构建的阶段;并且多阶段之间的镜像可以相互复制文件

    举个例子🌰:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    FROM golang:alpine as builder

    RUN apk --no-cache add git

    WORKDIR /go/src/github.com/go/helloworld/

    RUN go get -d -v github.com/go-sql-driver/mysql

    COPY app.go .

    RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o app .


    FROM alpine:latest as prod

    RUN apk --no-cache add ca-certificates

    WORKDIR /root/

    # 这里是使用上一阶段(--from=0) “builder” 镜像的文件
    # 0 是“上一个”的简写,还可以 --from=builder
    COPY --from=0 /go/src/github.com/go/helloworld/app .

    CMD ["./app"]

    也可以只构建 builderdocker build --target builder -t XXX:XX .

  • 当需要的容器服务过于多的时候,写 Dockerfile 变得很繁琐(例如一个镜像中同时需要 python、nginx、redis 服务等),建议使用 docker compose(见 Chapter 7)

Chapter 3. 操作容器

3.1 创建和启动容器

3.1.1 新建容器并启动

  • 语法:docker run [options] <IMAGE_NAME or ID> [CMD]

    • [options]:① -i 启动容器内 bash 并绑定于 stdin 允许交互;

      -t 分配伪终端(显示命令提示符,常和 -i 联用将容器 I/O 绑定在当前 TTY 上:-it);

      如果使用 -i 参数,[CMD] 为必须项,且一般填写 /bin/bash 或 sh;

      -d 拒绝和当前终端连接(detach,在启动容器后不与当前 TTY 关联,与 -it 互斥,此时 stdout 的内容可以使用 docker container logs 查看);

      -p <host_port:container_port> 将容器指定端口映射到主机指定端口;

      -P 将容器端口开放到 Host OS 的随机端口;

      -v <host_dir:container_dir> 将主机指定目录作为数据卷挂载到容器指定目录上,不会覆盖 Dockerfile 中的 VOLUME 指令。因为如果有 VOLUME 指令,那么在构建镜像时匿名卷已被创建;(详细见 Chapter 5

      --name <containerName> 为容器命名,在容器互联、集群时非常重要

      --network 参数见 6.2 容器互联

    • [CMD]会覆盖 Dockerfile 中的 CMD 指令

  • 运行该命令后 docker engine 在干什么:

    1. 检查本地是否存在指定的镜像,不存在就从 Docker Registory 下载;
    2. 利用镜像创建并启动一个容器;
    3. 分配一个文件系统,并在只读的镜像层外面挂载一层可读写层;
    4. 从宿主主机配置的网桥接口中桥接一个虚拟接口到容器中去;
    5. 从地址池配置一个 ip 地址给容器;
    6. 执行用户指定的应用程序;
    7. 执行完毕后容器被终止;

3.1.2 启动已终止的容器

就一句:docker container start <ID>(如果容器没有exit,应该用 restart);

3.2 终止容器

也就一句:docker container stop <ID>

此外,查看所有正在运行的容器:docker container ls

处于终止状态的容器需要在后面加 -a 参数:docker container ls -a

3.3 进入容器

本质上就是在容器中创建一个 bash 程序,并把 I/O 绑定到当前 TTY 上。总共两种方法:

  • docker attach <ID>,不建议使用,因为执行 exit 退出这个 bash 后,会给整个容器发送 SIGABRT 信号,导致终止;
  • docker exec -it <ID> bash:使用 exit 退出 bash 不影响原来容器;显然 docker exec 不止可以进入容器,还能干其他事(把 -it 去掉,最后一个参数换成要运行的命令等等,详细内容请参阅官方文档,或者运行 docker exec --help);

3.4 导入和导出

  • 将容器保存为快照(这会丢弃容器的元数据):docker export <ID> > compressFile.tar

  • 容器快照展开为镜像(这是镜像的另一种创建方式:从容器创建):cat compressFile.tar | docker import - <REPO_NAME>

    复习一下,REPO_NAME = userName/IMAGE_NAME:tag

    除了管道流方法以外,还可以从指定地址展开镜像:docker import http://example.com/exampleimage.tgz example/imagerepo

3.5 删除容器

  • 删除处于终止状态的容器:docker container rm <ID>

    • 如果需要删除任何状态的容器(包括正在运行的),则需添加 -f 参数,这会给正在运行的容器先发送 SIGKILL 信号;
    • 如果实在需要在停止容器的同时删除挂载的数据卷:添加 -v 参数;
  • 清除所有处于终止状态的容器:docker container prune

    复习一下:清除所有虚悬镜像:docker image prune

Chapter 4. 访问仓库

4.1 Docker Hub

  • 登录/登出 docker 账户:docker login/logout

  • 查找相关镜像:docker search [--filter=starts=N] <...>(里面示例筛选器是筛选 N 星以上的镜像);

  • 推送镜像到自己账户的公共仓库中(需登录):docker push <userName/IMGAE_NAME:tag>

    提示:目前自动构建(Automated build)仅支持付费用户,没钱没用过,不讨论;

4.2 私有仓库管理

docker-registry 相关命令,平时没啥人用,不作介绍;

Chapter 5. 数据管理

5.1 数据卷

在之前已经无数次接触到数据卷的概念,现在详细说明数据卷的概念和使用;

  • 概念和特征:是一个可供一个或多个容器使用的特殊目录,可以理解为 Linux 下对目录或文件进行 mount

    • 数据卷可以在容器之间共享和重用

    • 对数据卷的修改会立马生效;但对数据卷的更新,不会影响镜像(挂载的特征);

    • 数据卷默认会一直存在,即使容器被删除(没有自动回收机制,独立于容器,需要手动删除

      数据无价,谨慎删除;

    • 匿名卷也是一个数据卷

  • 管理数据卷:docker 支持在没有启动容器时,手动对数据卷进行管理;

    • 查看所有数据卷:docker volume ls

    • 查看特定数据卷详细信息:docker volume inspect <volumeName>

    • 创建数据卷:docker volume create <volumeName>

      和匿名卷一样,默认挂载于 /var/lib/docker/containers/ 中;

    • 启动一个挂载指定数据卷的容器:3.1.1 介绍过,-v 参数,等价于:--mount source=<volumeName>,target=<containerPath>

    • 手动删除数据卷:docker volume rm <volumeName>

    • 清理没有被当前容器使用的数据卷:docker volume prune

5.2 挂载主机目录

  • 和数据卷比较:挂载主机目录和挂载数据卷极其类似,不过前者是自定义位置,后者默认和匿名卷都放在一起;

  • 使用:docker run-v 参数,如果 Host OS 中没有指定目录,会自动创建

    或者:docker run--mount type=bind,source=<dir>,target=<cDir> ,如果本地目录不存在,则报错;

    也可以仅赋予只读权限:--mount type=bind,source=<dir>,target=<cDir>,readonly

  • 甚至可以只挂载一个主机文件

Chapter 6. 网络配置

6.1 外部访问

绝大部分内容已在 3.1.1 启动容器时介绍,这里仅介绍一些高级用法;

  • -p 参数可以多次使用来绑定多个端口:docker run -d -p 80:80 -p 443:443 nginx:alpine

  • -P 参数的 “随机” 是针对 Host OS 的端口随机分配,一般会按照 Dockerfile 中的 EXPOSE 分配指定的容器内部端口;

    等价于在 docker run 中加入参数:-p 127.0.0.1::<EXPOSE_port>

6.2 容器互联

以前会使用docker run--link 参数,进行点对点的连接;但随着结点数的增大,这么做不容于配置;正确做法是自行配置容器间的网络

  • 新建容器网络:docker network create -d bridge <netName>

    -d 参数指定网络类型,参数值有 bridge(网桥) 和 overlay,前者用的多,后者需要配合 swarm mode 配置集群,暂时不介绍;

  • 运行容器同时将容器连接到网络:docker run--network <netName>

    注意:如果要容器连接网络,强烈建议自己为容器命名--name 参数,因为容器间交互识别就靠容器名——在一个容器中可以访问同网络的另一个容器:ping <containerName>

其实更方便的做法是:使用 Docker compose(Chapter 7)

6.3 DNS 配置

这个操作在特殊场合有用(因为一般默认容器中的 DNS 设置就够用了),比如某些同学想用 Grasscutter 建立 Ys 私服的时候,包装成 Docker 镜像时就需要将一系列网络代理到本机上——如果使用 DNS 劫持的方法就需要在容器内配置 DNS;

最方便的方法是构建镜像时自己写一个 HOST 文件在放到/etc/hosts

当然,也可以通过 docker run-h HOSTNAME 写入 /etc/hosts/etc/hostname ,还可以通过 docker run--dns=IP_ADDR 写入 DNS 服务器地址(在 /etc/resolv.conf);

还可以通过修改主机 /etc/docker/daemon.json同时修改所有容器的 DNS 服务器

1
2
3
4
5
6
{
"dns" : [
"114.114.114.114",
"8.8.8.8"
]
}

Chapter 7. Docker Compose

7.1 简介

  • 地位:由 Python 编写的 Docker 官方的开源项目;

  • 定位:定义和运行多个 Docker 容器的应用(Defining and running multi-container Docker applications);

  • 作用:允许用户通过一个单独的 docker-compose.yml 模板文件(YAML 格式)来定义一组相关联的应用容器为一个项目(project),很方便地实现容器运行设置、互联

  • 两个重要概念:服务和项目

    • 服务 (service):一个应用的容器,实际上可以包括若干运行相同镜像的容器实例;
    • 项目 (project):由一组关联的应用容器组成的一个完整业务单元,在 docker-compose.yml 文件中定义;

    Compose 的默认管理对象是项目,通过子命令对项目中的一组容器进行便捷地生命周期管理;

  • 和 docker 普通用法的比较【重要】:docker compose 不在于构建镜像,而在于直接一条龙拉取已有镜像/按 Dockerfile 构建自定义镜像(前面章节的内容),并按配置运行一组容器

  • 举例认识🌰:假设有一个项目,使用 Python 建立一个记录页面访问次数的 Web 服务,使用 Flask 框架、redis 服务器;

    (本部分无需看懂,只需要感受 docker compose 使用便捷就行)

    如果使用 docker 原来的方法,编写 Dockerfile 来构建容器,那么需要作很多事:从一个 Python 镜像中开始构建、安装 flask、安装 redis、复制项目文件、删除安装的 apt 缓存和安装包等文件,运行 docker run 还要加各种参数,例如挂载、端口、网络互联……

    但如果使用 docker compose 就不一样了:

    编写模板文件:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    # File: docker-compose.yml
    version: '3'
    services: # 容器内的所有服务

    web: # web 服务
    build: . # 镜像构建方法是当前页面的自定义镜像:./Dockerfile
    ports: # 运行该 web 服务的容器时,进行端口映射,相当于 docker run 的 -p
    - "5000:5000"

    redis: # redis 服务
    image: "redis:alpine" # 镜像构建方法是直接从 redis:alpine 拉取

    编写 web 服务的镜像构建 Dockerfile(python + flask + 项目文件):

    1
    2
    3
    4
    5
    FROM python:3.6-alpine
    ADD . /code
    WORKDIR /code
    RUN pip install redis flask
    CMD ["python", "app.py"]

    最后直接运行:docker compose up -d ,结束!

7.2 安装

需要已经安装 Docker 及其服务;

1
2
3
4
5
6
7
DOCKER_CONFIG=/usr/local/lib/docker/cli-plugins
sudo mkdir -p $DOCKER_CONFIG/cli-plugins
# 国内加速:
# sudo curl -SL https://download.fastgit.org/docker/compose/releases/download/v2.6.1/docker-compose-linux-x86_64 -o $DOCKER_CONFIG/cli-plugins/docker-compose
sudo curl -SL https://github.com/docker/compose/releases/download/v2.6.1/docker-compose-linux-x86_64 -o $DOCKER_CONFIG/cli-plugins/docker-compose
sudo chmod +x $DOCKER_CONFIG/cli-plugins
docker compose version

7.3 compose 命令

对于 Compose 来说,大部分命令的对象既可以是项目本身,也可以指定为项目中的服务或者容器。如果没有特别的说明,命令对象将是项目,这意味着项目中所有的服务都会受到命令影响;

语法:docker compose [options] [compose-command]

[options] 参数

  • -f template 指定 docker compose 模板,默认 docker-compose.yml
  • -p Name 指定项目名称;
  • --verbose 运行时调试信息更详细;

[compose-command] 命令

构建&运行命令
  • pull:拉取模板中的基础镜像;

  • build [options]:按模板构建项目中的所有镜像并组合为容器;

    • --force-rm:删除构建过程中产生的临时镜像;
    • --no-cache: 不使用缓存构建;
  • start [serviceName]:启动已存在的指定服务容器
  • restart [-t TIMEOUT]:重启项目中的服务;
    • -t TIMEOUT:重启前停止容器;
  • run [options] <serviceName> [CMD]:手动docker run 一样的参数启动指定容器,用的少,因为不如在 docker-compose 里设置并且 up 启动;
  • up [options]:强大的命令,可以一次性实现上述所有命令(构建镜像、(重新)创建服务、启动服务,并关联服务相关容器)
    • -d(detach):后台运行;
    • 其他选项和上面的前 4 条命令一样;
停止&管理命令
  • down:停止 up 所启动的容器,同时移除网络;
  • stop [serviceName]:仅停止指定服务容器,不删除任何东西,可以通过 start 再启动;
  • kill [-s SIGNAL] [serviceName]:通过 -s 传递终止信号结束服务容器 ;
  • pause/unpause [serviceName]:暂停指定服务容器 / 恢复被暂停的服务容器;
  • rm [options] [serviceName]:删除所有处于停止状态的服务容器,options 参数和 docker 一样;
  • exec [options]:进入指定容器,参数和 docker 一样;
维护命令
  • images:列出模板中所含的所有镜像;
  • ps:列出项目中当前所有容器
  • top:查看各服务容器里运行的进程(这样就不用在容器里装 procps 包了);
  • logs [serviceName]:查看某个服务容器的 stdout 输出,对调试有用;
  • version:版本信息;
  • help:帮助;

7.4 docker-compose 模板

和 7.1 里说的一样,docker-compose.yml 同时完成指定构建什么镜像(自己不会设计镜像)和 docker run 的工作,所有可以将下面的选项和前几章的指令一一对照

下面直接在文件中说明:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
version: "3"    # 指定一个版本

services: # 必不可缺的部分,是容器运行的核心——服务

webApp: # 服务名,可以自己取,代表容器的其中一个服务。
# 注意,每个服务都可以指定一个构建镜像
image: <XX/XX> # image 和 build 选项二选一(必选,因为容器必须要从镜像开始)
build: <dir> # image 指这个容器从已存在的镜像构建,值是 REPO_NAME
# build 指这个容器将自行构建镜像,值是上下文目录(必须包含 Dockerfile)
build: # 但如果上下文目录和 Dockerfile 不在一起,请详细指定,如左
context: <dir>
dockerfile: <path>
args:
# 这里相当于 docker build 的参数,不过不能缩写,没有“--”

container_name: <name> # 谨慎使用,会降低模板文件的扩展性,因为容器名唯一

command: <CMD> # 这里覆盖 Dockerfile 里的 CMD 指令 或者是默认的 CMD 指令

devices: # 这里在 Dockerfile 中没讲过,可以映射硬件设备
- "/dev/<host_device>:/dev/<container_device>"

depends_on: # 这里可以解决依赖问题,会先启动依赖服务
- <serviceName>

dns: # DNS 服务器,对应 docker run 的 --dns 参数
- <IP_ADDR>

tmpfs: # 对应 Dockerfile 中的 VOLUME 指令
- <dir>

env_file: # 对应 Dockerfile 的 ENV 指令
# 通过文件载入环境变量,环境变量文件规范如下:
- <ENV_file> # 以 “#” 作行注释、后缀名 *.env
# 内容 “key=value” 顶格、中间不能有空格
# 有表达特殊含义的 value 必须用引号引起,特殊词包括:
# y|Y|yes|Yes|YES|n|N|no|No|NO|true|True|TRUE|false|False|FALSE|on|On|ON|off|Off|OFF
environment: # 环境变量少可以这么用,多的话建议用上面的 env_file
- key=value

expose: # 对应 Dockerfile 的 EXPOSE 指令
- "<port>"

ports: # 对应 docker run 的 -p 参数
- "<host_port>:<container_port>"

extra_hosts: # 对应 docker run 的 -h 参数,可以进行 DNS 劫持
- "domain:IP" # 会在 /etc/hosts 文件中添加:"IP domain"

healthcheck: # 对应 Dockerfile 的 HEALTHCHECK 指令
test: ["CMD", "<COMMAND>"]
interval: <time>
timeout: <time>
retries: N

labels: # 对应 Dockerfile 的 LABEL 指令
name: value

user: <name> # 对应 Dockerfile 的 USER 指令

working_dir: <dir> # 对应 Dockerfile 的 WORKDIR 指令

entrypoint: <COMMAND> # 对应 Dockerfile 的 ENTRYPOINT 指令

restart: <options> # 指定容器退出后的重启策略
# 相当有用的选项,对保持服务始终运行十分有效
# 在生产环境中推荐配置为 always 或者 unless-stopped

logging: # 设置容器输出日志
driver: <"json-file"/"syslog"/"none"> # 写成 json 还是和系统一致
options: # 日志轮替选项
max-size: "Nk" # 或 "Nm"
max-file: N

# 如此还可以写下一个服务……

还有两个重要选项 和 一个重要的行为需要单独拎出来说:

volumes 挂载选项:对应 Dockerfile 的 VOLUME 指令,用法如下:

1
2
3
4
5
6
7
8
9
10
11
version: "3"
services:
<serviceName>:

volumes:
- <host_dir>:<container_dir> # 对应 docker run 本机目录挂载(可选)
- <volumName>:<container_dir> # 对应 docker run 数据卷挂载(可选)

# 如果上面使用了数据卷名(volumnName),那么需要声明创建一个数据卷:
volumes:
<volumeName>: # 和匿名卷放在一个位置,不用给它值

networks 网络选项:对应 docker run 的 --network 参数,用法如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
version: "3"
services:
<serviceName>:

networks: # 容器加入某个指定网络,可以同时加入多个网络
- <netName>

# 上面使用了 networks 就必须指定网络名称
networks:
<netName1>: # 如果名字是 default,设置默认网络,一般不用设置
driver: <bridge/overlays> # 对应 docker network create 的 -d 参数

<netName2>:
external: # 如果不希望 docker compose 创建网络,想自己 docker network create,那么引入外部设置网络需要 external 选项
name: <external_netName>

重要行为:docker-compose.yml 读取环境变量

如果在模板文件中使用 ${XXX} 的变量,模板会先搜索系统环境变量,再搜索之前设置的 env_files/environment 选项


docker 入门基础篇完【EOF】

预计将会跟进 docker 底层实现分析、Kubernetes 集群 等内容;