Docker

Docker

2019年9月28日

Docker 通过其易用性和预构建镜像的注册表普及了 Linux 容器,并成为了一个经常与“Linux 容器”互换使用的词语。

Docker 镜像包含多个在运行时合并以构成容器文件系统的层。Docker 通过运行 Dockerfile 中的命令来创建这些层,每个命令都会创建一个新的层。层在镜像之间共享,节省了空间,并且可以用作缓存以加快镜像构建速度。与虚拟机 (VM) 等其他选项相比,通过不在镜像中包含 Linux 内核,可以节省额外的空间。镜像的大小比我们部署的打包 Erlang 版本的大小略大。

体积小巧且能够像普通 Linux 进程一样运行(不会启动新的内核)的特点,使得容器的启动时间更快,并且比使用传统 VM 进行隔离消耗更少的资源。由于开销很小,因此在打包和运行程序时,隔离的优势可以成为标准实践,而不是像必须为每个程序运行一个 VM 那样带来负担。

运行带有文件系统和网络隔离的容器的优势在于,无需执行在程序未隔离时常见的操作

  • 预安装共享库
  • 更新配置
  • 查找空闲端口
  • 为节点名称查找唯一名称

注意

您可能会注意到,在使用 Docker 时,我们根本不会使用 latest 标签。这个标签经常被误解和误用。它被分配给最后一个没有特定标签的镜像,而不是最新的创建的镜像。除非您真的不在乎将使用哪个版本的镜像,否则很少,甚至永远不应该依赖它。

在本章中,我们将介绍如何有效地构建用于运行 service_discovery 项目以及用于运行测试和 dialyzer 的镜像。然后,我们将更新持续集成管道以构建和发布新的镜像。

本章所需的最低 Docker 版本为 19.03,并已安装 buildx。可以使用以下命令安装 buildx

$ export DOCKER_BUILDKIT=1
$ docker build --platform=local -o . git://github.com/docker/buildx
$ mv buildx ~/.docker/cli-plugins/docker-buildx

构建镜像

官方 Erlang Docker 镜像 针对每个新的 OTP 版本发布。它们包含 Rebar3,并提供 AlpineDebian 版本 - 镜像也会针对 Rebar3 和 Alpine/Debian 的新版本进行更新。由于标记的镜像会针对新版本更新,因此建议同时使用镜像的 sha256 摘要,并将使用的镜像镜像到您自己的存储库,即使您的存储库也在 Docker Hub 上。拥有一个副本可以确保基础镜像不会在没有开发人员干预的情况下发生更改,并且在与 Docker Hub 分开的注册表中拥有一个镜像意味着您不依赖于它的可用性。此最佳实践是以下示例和 service_discovery 存储库使用 ghcr.io/adoptingerlang/service_discovery/us.gcr.io/adoptingerlang/ 中的镜像的原因。

私有依赖项

在工作环境中构建 Docker 镜像时,许多人遇到的第一个障碍是访问私有依赖项。如果您有私有的 git 存储库或 Hex 组织包 作为依赖项,则在构建期间,Docker 容器将无法获取它们。通常,这会导致人们不将 _build 包含在 .dockerignore 中,并冒着使用本地工件污染构建的风险,这可能在其他地方无法重现,因此可以在运行 docker build 之前使用 Rebar3 获取依赖项。另一种选择是将主机 SSH 凭据和/或 Hex apikey 复制到构建容器中,但这不建议这样做,因为它将保存在 Docker 层中,并在您推送镜像的任何地方泄露。相反,在最近的 Docker 版本(18.06 及更高版本)中,能够以安全的方式挂载机密和 SSH 代理连接或密钥。数据不会泄露到最终镜像或任何未明确挂载到它的命令中。

由于 service_discovery 没有私有依赖项,因此在开始构建 service_discovery 的镜像之前,我们将分别查看如何支持它们。

Hex 依赖项

Rebar3 将私有 Hex 依赖项的访问密钥保存在文件 ~/.config/rebar3/hex.config 中。使用实验性的 Dockerfile 语法 --mount=type=secret,可以将配置挂载到容器中,仅用于编译命令。该文件被挂载到一个单独的 tmpfs 文件系统中,并且被排除在构建缓存之外。

# syntax=docker/dockerfile:1.2
RUN --mount=type=secret,id=hex.config,target=/root/.config/rebar3/hex.config rebar3 compile

要挂载主机上的 hex.config 在运行 docker build 时,只需传递一个具有匹配 idsrc 路径到文件的机密即可。

$ docker build --secret id=hex.config,src=~/.config/rebar3/hex.config .

Git 依赖项

您可以使用上一节中的机密挂载来挂载 SSH 密钥,但 Docker 添加了一个更好的解决方案,它提供了一种专门用于处理 SSH 的挂载类型。需要 SSH 访问权限的 RUN 命令可以使用 --mount=type=ssh

# syntax=docker/dockerfile:1.2
RUN apt install --no-cache openssh-client git && \
    mkdir -p -m 0600 ~/.ssh && \
    ssh-keyscan github.com >> ~/.ssh/known_hosts && \
    git config --global url."[email protected]:".insteadOf "https://github.com/"
WORKDIR /src
COPY rebar.config rebar.lock .
RUN --mount=type=ssh rebar3 compile

首先,一个 RUN 命令安装必要的依赖项,SSH 和 git。然后,ssh-keyscan 用于下载 Github 的当前公钥并将其添加到 known_hosts 中。公钥位于 known_hosts 中意味着 SSH 不会尝试提示您是否接受主机的公钥。接下来,git 配置设置确保即使在 rebar.config 中 git url 使用 https,它也会改为使用 SSH。如果私有存储库不在 Github 上,则必须更改此 url 替换以适合相应的位置。

除了将前面的代码段添加到我们将在本章后面看到的 Dockerfile 中外,您还需要在运行时将 --ssh default 添加到构建命令中,并设置 DOCKER_BUILDKIT

$ export DOCKER_BUILDKIT=1
$ docker build --ssh default .

有关 SSH 挂载类型的更多信息和选项,请参阅 Moby 文档 - Moby 是构成 Docker 核心功能的项目的名称。

高效缓存

基本指令排序

Dockerfile 中命令的顺序对构建时间和其创建的镜像大小非常重要。Dockerfile 中的每个命令都会创建一个层,然后在将来的构建中重复使用该层,如果没有任何更改,则跳过该命令。使用 Rebar3,我们通过创建一个包含项目构建依赖项的层来利用这一点。

COPY rebar.config rebar.lock .
RUN rebar3 compile

只有当 rebar.configrebar.lock 与先前创建的层不同时,COPY 命令才会使运行 rebar3 compile(以及文件中的后续命令)的命令的缓存失效。由于没有复制项目的任何代码,并且 Rebar3 仅构建依赖项,因此这会导致一个仅包含 _build/default/lib 下构建的依赖项的层。

构建和缓存依赖项后,我们可以复制项目的其余部分并进行编译。

COPY . .
RUN rebar3 compile

由于 Dockerfile 中的操作顺序,每次运行 docker build . 仅编译项目的源代码(假设有更改),否则也会在此处使用现有的层。任何在项目发生更改时不需要重新运行的命令都需要放在两个 COPY 命令之前。例如,安装 Debian 包,RUN apt install gitWORKDIR /app/src 用于设置工作目录。

不鼓励使用 COPY . .,因为它更容易使缓存失效。如果可能,最好只复制构建所需的的文件和目录,或者使用 .dockerignore 文件过滤掉不需要的文件。.git 目录由于其大小以及其内容的更改不会影响构建工件,因此是值得忽略的一个目录。但是,在 service_discovery 中,我们依赖于 git 命令来设置发布版本和构成发布版本的应用程序。在不依赖于 Rebar3 的此功能的项目中,建议将 .git 添加到 .dockerignore 中。

实验性挂载语法

将文件复制到镜像中和缓存层不再是构建 Docker 镜像时提高效率的唯一选项。在层中缓存构建的依赖项很好,但该层还包含 Rebar3 在 ~/.cache/rebar3/hex 下创建的 Hex 包缓存。对 rebar.configrebar.lock 的任何更改都将导致所有包不仅需要重新构建,还需要从 Hex 重新获取。此外,复制整个项目的指令(使用所有源代码创建额外的层)是浪费的,因为我们只关心构建工件。

从 Docker 19.03 开始,通过用于将文件挂载到 RUN 命令上下文的实验性语法解决了这些问题。要启用实验性语法,必须设置环境变量 DOCKER_BUILDKIT 或在 /etc/docker/daemon.json 中设置 {"features":{"buildkit": true}},并将 # syntax=docker/dockerfile:1.2 用作 Dockerfile 的第一行。

# syntax=docker/dockerfile:1.2

[...]

WORKDIR /app/src

ENV REBAR_BASE_DIR /app/_build

# build and cache dependencies as their own layer
COPY rebar.config rebar.lock .
RUN --mount=id=hex-cache,type=cache,sharing=locked,target=/root/.cache/rebar3 \
    rebar3 compile

RUN --mount=target=. \
    --mount=id=hex-cache,type=cache,sharing=locked,target=/root/.cache/rebar3 \
    rebar3 compile

在此新指令集中,构建将 WORKDIR 设置为 /app/src,这随后成为后续命令的当前工作目录。并设置环境变量 REBAR_BASE_DIR/app/_build。基础目录是 Rebar3 将输出所有构建工件的位置,默认情况下是项目根目录下的 _build/ 目录,在本例中,如果没有环境变量,它将是 /app/src/_build

Rebar3 配置和锁定文件的 COPY 保持不变,但以下 RUN 已更改为包含 --mount 选项,类型为 cache。这告诉 Docker 创建一个与 Docker 层分开的缓存目录,并将其存储在主机本地。此缓存将在 docker build 的运行之间保留,因此将来本地运行 docker build,即使配置或锁定文件已更改,也将挂载此缓存,并且仅从 Hex 获取新需要的包。

接下来,与之前构建项目其余部分的指令不同,指令 COPY . . 已被删除。相反,使用类型为 bind(默认值)的挂载,并将其 target 设置为 .。与 cache 挂载相反,绑定挂载意味着 Docker 将从构建上下文挂载到容器中,从而为我们提供与 COPY . . 相同的结果,但无需从复制文件中创建层,从而使构建更快、更小。使用 COPY 命令,从主机进行了两次复制:构建上下文和构建容器中的副本。每次使用 COPY 运行 build 都需要将整个项目从构建上下文再次复制到构建容器中。

默认情况下,挂载是不可变的,这意味着如果向 /app/src 写入任何内容,构建将出错,这就是 Rebar3 基础目录配置为 /app/_build 的原因。有一个选项可以以 read-write 模式挂载,但写入不会持久化,并且它会删除不必为构建容器创建构建上下文数据副本的优化。有关挂载选项的更多信息,请参阅 Buildkit 文档中的 Dockerfile 前端实验性语法

最终结果是一个包含编译后的依赖项的层(/app/_build),以及/app/src/rebar.*(但它们对层的大小影响很小),然后是一个包含编译后的项目(/app/_build)但不包含/app/src内容的层。此外,还有一个所有已下载的hex包的缓存。

本地缓存与远程缓存

在本节中,我们使用了两种类型的缓存,并且都依赖于构建是在同一主机上完成才能访问缓存。对于在RUN期间挂载的hex-cache,它仅仅是一个本地缓存的功能,不能从注册表导出或导入。但是,Dockerfile中每个指令构建的层可以从注册表导入。

当使用持续集成或任何类型的构建服务器时,设置构建以使用远程缓存特别有用。除非只有一个节点运行构建,否则最终会浪费时间重新构建Dockerfile的每个步骤。为了解决此问题,可以使用--cache-from告诉docker build在哪里查找层,包括远程注册表中的镜像。

--cache-from有两个版本,因为较新的版本在技术上仍然是名为buildx的实验性“技术预览”的一部分,所以我们将介绍这两个版本。但是,由于buildx效率更高、更易于使用并且在我们需要的功能范围内看起来很稳定,因此它将成为service_discovery项目使用的默认值。

旧的--cache-from不是“多阶段感知”的,这意味着它要求用户手动构建和推送多阶段Dockerfile中的每个阶段。当构建基于早期阶段的阶段时,可以通过--cache-from引用其镜像,它将从注册表中拉取。

使用buildx,将构建一个缓存清单,其中包含有关多阶段构建中先前阶段的信息。参数--cache-to允许以各种方式导出此缓存。我们将使用inline选项,它将缓存清单直接写入镜像的元数据中。该镜像可以推送到注册表,然后在以后的构建中使用--cache-from引用。新缓存清单的独特之处在于,只有缓存命中的层才会被下载,在旧的表单中,当通过--cache-from引用时,会下载先前阶段的整个镜像。

缓存和安全更新

在使用层缓存时,需要牢记一个安全问题。例如,由于RUN命令仅在命令文本更改或先前层使缓存失效时才会重新运行,因此即使发布了安全修复程序,任何安装的系统软件包也将保持相同的版本。因此,最好偶尔使用--no-cache运行Docker,这在构建镜像时不会重用任何层。

多阶段构建

对于Erlang项目,我们将需要一个包含已构建版本的镜像,并且此镜像不应包含运行版本不需要的任何内容。像Rebar3、用于构建项目的Erlang/OTP版本、用于从github获取依赖项的git等工具都必须删除。与其在构建完成后删除项目,不如使用多阶段Dockerfile将最终版本(捆绑了Erlang运行时)从构建它的阶段复制到一个具有Debian基础的阶段,并且只包含运行版本所需的共享库,例如OpenSSL。

我们将逐步介绍service_discovery项目的Dockerfile中的阶段。第一个阶段名为builder

# syntax=docker/dockerfile:1.2
FROM ghcr.io/adoptingerlang/service_discovery/erlang:26.0.2 as builder

WORKDIR /app/src
ENV REBAR_BASE_DIR /app/_build

RUN rm -f /etc/apt/apt.conf.d/docker-clean

# Install git for fetching non-hex depenencies.
# Add any other Debian libraries needed to compile the project here.
RUN --mount=target=/var/lib/apt/lists,id=apt-lists,type=cache,sharing=locked \
    --mount=type=cache,id=apt,target=/var/cache/apt \
    apt update && apt install --no-install-recommends -y git

# build and cache dependencies as their own layer
COPY rebar.config rebar.lock .
RUN --mount=id=hex-cache,type=cache,target=/root/.cache/rebar3 \
    rebar3 compile

FROM builder as prod_compiled

RUN --mount=target=. \
    --mount=id=hex-cache,type=cache,target=/root/.cache/rebar3 \
    rebar3 as prod compile

builder阶段以基础镜像erlang:26.0.2开始。as builder命名阶段,以便我们可以在以后的阶段中将其用作基础镜像到FROM

旧的Docker缓存

对于使用旧的--cache-from进行远程缓存(如上一节所述),将构建builder阶段并使用标识符进行标记,以便我们根据它包含的Rebar3依赖项引用镜像。为此,我们可以在rebar.configrebar.lock上使用命令cksum。这类似于Docker在选择是否使缓存失效之前所做的操作。

$ CHKSUM=$(cat rebar.config rebar.lock | cksum | awk ‘{print $1}’)
$ docker build –target builder -t service_discovery:builder-${CHKSUM} .
$ docker push service_discovery:builder-${CHKSUM}

在构建任何使用FROM builder的阶段时,我们将包含--cache-from=service_discovery:builder-${CHKSUM}以拉取先前构建的依赖项。

开发人员通常会在同一项目的并发分支上工作,可能具有不同的依赖项,在定义用作缓存的镜像时使用当前Rebar3配置和锁定文件的校验和,允许为项目的多个依赖项集进行缓存,并在构建时使用正确的依赖项集。

下一个阶段名为releaser,使用prod_compiled镜像作为其基础

FROM prod_compiled as releaser

WORKDIR /app/src

# create the directory to unpack the release to
RUN mkdir -p /opt/rel

# build the release tarball and then unpack
# to be copied into the image built in the next stage
RUN --mount=target=. \
    --mount=id=hex-cache,type=cache,target=/root/.cache/rebar3 \
    rebar3 as prod tar && \
    tar -zxvf $REBAR_BASE_DIR/prod/rel/*/*.tar.gz -C /opt/rel

此阶段使用prod配置文件构建版本的tarball

{profiles, [{prod, [{relx, [{dev_mode, false},
                            {include_erts, true},
                            {include_src, false},
                            {debug_info, strip}]}]
            }]}.

配置文件将include_erts设置为true,这意味着tarball包含Erlang运行时,可以在未安装Erlang的目标上运行。最后,tarball被解压到/opt/rel,因此将版本从releaser阶段复制出来的阶段不需要安装tar

为什么要对版本进行tar打包?

您可能会注意到,仅创建版本的tarball是为了立即将其解压。这样做,而不是复制版本目录的内容,有两个原因。首先,它确保仅使用明确定义为包含在此版本中的内容。在Docker镜像中构建时,这不太重要,因为_build/prod/rel目录中不会有以前的版本构建,但仍然值得这样做。其次,在tar打包时对版本进行了一些更改,这些更改是使用release_handler等工具所必需的,例如,引导脚本从RelName.boot重命名为start.boot。有关更多详细信息,请参阅systools文档。

最后,可部署的镜像使用常规的操作系统镜像(debian:bullseye)作为基础,而不是先前的阶段。首先安装运行版本所需的任何共享库,然后将从releaser阶段解压的版本复制到/opt/service_discovery

FROM ghcr.io/adoptingerlang/service_discovery/debian:bullseye as runner

WORKDIR /opt/service_discovery

ENV COOKIE=service_discovery \
    # write files generated during startup to /tmp
    RELX_OUT_FILE_PATH=/tmp \
    # service_discovery specific env variables to act as defaults
    DB_HOST=127.0.0.1 \
    LOGGER_LEVEL=debug \
    SBWT=none

RUN rm -f /etc/apt/apt.conf.d/docker-clean

# openssl needed by the crypto app
RUN --mount=target=/var/lib/apt/lists,id=apt-lists,type=cache,sharing=locked \
    --mount=type=cache,id=apt,sharing=locked,target=/var/cache/apt \
    apt update && apt install --no-install-recommends -y openssl ncurses-bin

COPY --from=releaser /opt/rel .

ENTRYPOINT ["/opt/service_discovery/bin/service_discovery"]
CMD ["foreground"]

ENV命令中,我们为运行版本时使用的环境变量设置了一些有用的默认值。RELX_OUT_FILE_PATH=/tmp由版本启动脚本用作输出脚本创建的任何文件的目录。这样做是因为当运行版本时,需要从其各自的.src文件生成sys.configvm.args,并且默认情况下它们放置在与原始.src文件相同的目录中。我们不希望这些文件写入版本目录(.src文件所在的位置),因为容器文件系统不应写入是最佳实践。如果此镜像写入/tmp,则可以作为任何用户运行,但如果需要写入/opt/service_discovery下的任何位置,则必须作为root运行。因此,写入/tmp允许遵循另一个最佳实践,即不以root身份运行容器。我们可以更进一步,使运行时文件系统只读,我们将在运行容器中看到这一点。

/opt/service_discovery由root拥有,建议不要以root身份运行容器。如果设置了RELX_OUT_FILE_PATH,则将使用其位置。在这里,ENV命令用于确保在运行容器时环境变量RELX_OUT_FILE_PATH设置为/tmp

$ docker buildx build -o type=docker --target runner --tag service_discovery:$(git rev-parse HEAD) .

或者使用service_discovery中包含的用于从CircleCI构建和推送镜像的脚本

ci/build_images.sh -l

此脚本还将对镜像进行两次标记,一次使用git引用git rev-parse HEAD(如手动命令中所做的那样),另一次使用分支名称git symbolic-ref --short HEAD。分支标记用于作为构建清单缓存与--cache-from一起引用。当可用时,该脚本将使用为master分支和当前分支标记的镜像作为缓存,并且为此必须在构建命令中包含--cache-to=type=inline

使用当前分支名称和master作为检查缓存命中的镜像不如在仅包含构建依赖项阶段的镜像上使用rebar.configrebar.lock的校验和精确。没有什么可以阻止构建仍然创建和推送一个使用校验和标记的显式镜像,并将其也用作--cache-from镜像之一。但是,至少对于此项目而言,不需要处理其他镜像的便利性(因为Buildkit缓存清单跟踪所有阶段)超过了在依赖项上可能发生的缓存未命中,而使用其他方案则不会发生。

最后,需要注意的是,在CircleCI中使用脚本的方式(请参阅在CI中构建和发布镜像)与这里相比的区别在于-l选项。在CI中,我们只关心将镜像推送到远程注册表,因此可以通过不将构建的镜像加载到Docker守护进程中来节省时间。在本地构建镜像时,我们可能希望运行它,并且将在下一节运行容器中运行它,因此需要加载到Docker守护进程中。

运行容器

现在我们有了镜像,可以使用docker run启动版本以进行本地验证和测试。默认情况下,CMDforeground)将传递给版本启动脚本,通过ENTRYPOINT配置为/opt/service_discovery/bin/service_discovery。如果使用传递给docker run的最后一个参数,则可以覆盖CMD。使用console命令会导致在运行容器时出现交互式shell

$ docker run -ti service_discovery console
[...]
(service_discovery@localhost)1>

-ti选项告诉docker我们想要一个交互式shell。这对于本地测试镜像很有用,您希望在其中使用shell来检查正在运行的版本。默认情况下,由Dockerfile runner阶段中的CMD设置,将使用foreground。这里没有使用-ti,因此也可以将其删除,命令简化为

$ docker run service_discovery
Exec: /opt/service_discovery/erts-10.5/bin/erlexec -noshell -noinput +Bd -boot /opt/service_discovery/releases/8ec119fc36fa702a8c12a8c4ab0349b392d05515/start -mode embedded -boot_var ERTS_LIB_DIR /opt/service_discovery/lib -config /tmp/sys.config -args_file /tmp/vm.args -- foreground
Root: /opt/service_discovery
/opt/service_discovery

为了防止意外关闭,您将无法使用Ctrl-c停止此容器,因此要停止容器,请使用docker kill <container id>

请注意,foreground是默认值,因为这是在生产环境中应该运行的方式,尽管它将在后台运行

$ docker run -d service_discovery
3c45b7043445164d713ab9ecc03e5dbfb18a8d801e1b46e291e1167ab91e67f4

使用-d--detach的缩写,输出是容器ID。写入stdout的日志可以使用docker log <container id>查看,我们将在下一章中看到如何在Kubernetes中将日志路由到您选择的日志存储。甚至当

也可以使用docker exec附加到正在运行的节点,容器ID(在docker run -d的输出中看到或使用docker ps查找)和命令remote_console。容器是否使用consoleforeground启动都没有关系,但这当然是在您需要为尚未使用console启动的节点使用shell时最有用。因为exec不使用镜像中定义的ENTRYPOINT,所以要运行的命令必须以版本启动脚本bin/service_discovery开头

$ docker exec -ti 3c45 bin/service_discovery remote_console
[...]
(service_discovery@localhost)1>

或者,可以使用以下命令运行 Linux shell:docker exec -ti 3c45 /bin/sh,这将把你带到/opt/service_discovery目录,然后你就可以连接到remote_console或检查运行容器的其他方面。

要退出远程控制台,请不要运行q(),这将关闭 Erlang 节点和 Docker 容器。请使用Ctrl-g并输入qCtrl-g将 shell 进入称为作业控制模式的状态。要了解更多关于此 shell 模式如何使用,请参阅作业控制模式 (JCL 模式) 文档

在某些情况下,例如如果发布版无法启动,覆盖ENTRYPOINT并在尝试启动发布版的容器中获取 shell 可能很有用。

$ docker run -ti --entrypoint /bin/sh service_discovery
/opt/service_discovery #

最后,在上一节中,我们看到RELX_OUT_FILE_PATH被设置为/tmp,因此没有任何文件会尝试写入发布目录,发布目录应保持只读。Docker 有一个diff命令,可以显示镜像文件系统和当前运行容器文件系统之间的差异。

$ docker container diff 3c45
C /tmp
A /tmp/sys.config
A /tmp/vm.args

如果遇到问题或想验证你的发布版没有做不应该做的事情,这是一个用于轻松检查你的发布版正在写入磁盘的内容的有用命令。在发布版向磁盘写入大量数据的情况下,最好挂载一个并将所有写入操作指向它,但对于这两个小的配置文件来说,这不是必需的。除非,当从模板创建配置文件时使用了敏感数据,或者你想以--read-only模式运行容器。在这些情况下,建议使用tmpfs挂载。在 Linux 上,只需将--tmpfs /tmp添加到docker run命令中即可。然后,/tmp将不再是容器的可写层的一部分,而是一个独立的卷,该卷仅存在于内存中,并在容器停止时销毁。

当心僵尸进程!

从 Erlang/OTP 19.3 开始,当收到TERM信号时,Erlang 节点将通过init:stop()优雅地关闭,Docker 和 Kubernetes 使用该信号来关闭容器。

但是,在容器中仍然可能遇到僵尸进程问题。当使用docker exec运行remote_console或任何其他发布脚本命令(如ping)时,除非容器以--init参数启动(Docker 提供的,用于在入口点之前启动一个小的 init 进程),否则会留下僵尸进程。

这通常不是问题,但与原子一样,如果使用不受限制,它肯定可能成为问题。出于这个原因,除非以--init或其他一些作为 pid 0 的小型 init 进程运行,否则应避免使用ping作为健康检查,健康检查由容器运行时在容器的生命周期中定期运行。这样的长时间运行的容器最终会导致内核进程表用完插槽,并且将无法创建新的进程。

在 CI 中构建和发布镜像

由于为每个版本手动构建和发布镜像到注册表将非常繁琐,因此通常将镜像构建作为持续集成流程的一部分。通常,这仅限于在合并到主分支或创建新标签时发生,但有时也可能需要构建分支镜像以进行测试。在本节中,我们将介绍一些自动化此流程的选项,但无论你正在使用什么 CI 工具,都可以实现类似的功能。

CircleCI

测试章节(即将推出…)中,我们介绍了CircleCI用于运行测试。为了构建和发布service_discovery的 Docker 镜像,添加了一个名为docker-build-push的新作业。它使用虚拟机而不是 Docker 镜像作为执行器,并首先安装最新的 Docker 版本,在撰写本文时,默认提供的版本不支持service_discovery Dockerfile中使用的功能。

jobs:
  docker-build-and-push:
    executor: docker/machine
    steps:
      - run:
          name: Install latest Docker
          command: |
            sudo add-apt-repository "deb [arch=amd64] https://download.docker.com/linux/ubuntu $(lsb_release -cs) stable"
            sudo apt-get update

            # upgrade to latest docker
            sudo apt-get install docker-ce
            docker version

            # install buildx
            mkdir -p ~/.docker/cli-plugins
            curl https://github.com/docker/buildx/releases/download/v0.3.0/buildx-v0.3.0.linux-amd64  --output ~/.docker/cli-plugins/docker-buildx
            chmod a+x ~/.docker/cli-plugins/docker-buildx            
      - checkout
      - gcp-gcr/gcr-auth
      - run:
          name: Build and push images
          command: |
            ci/build_image.sh -p -t runner -r gcr.io/adoptingerlang            

安装最新 Docker 后,service_discovery存储库的代码会被检出,由于此操作使用 Google Cloud 作为注册表和 Kubernetes,因此它会对注册表进行身份验证。最后,调用service_discoveryci/目录中找到的一个脚本以构建和发布镜像。该脚本使用本章前面讨论的docker build命令来构建各个阶段,并使用--cache-from引用这些阶段作为每次构建的缓存。

要仅在测试通过后运行此作业,可以将其添加到 CircleCI 工作流中,并在rebar3/ct上添加requires约束。

workflows:
  build-test-maybe-publish:
    jobs:

      [...]

    - docker-build-and-push:
        requires:
        - rebar3/ct

Google Cloud Build

2018 年,Google 发布了一个镜像构建工具Kaniko,它在用户空间运行,并且不依赖于守护进程,这些功能使得在 Kubernetes 集群等环境中构建容器镜像成为可能。Kaniko 旨在作为镜像gcr.io/kaniko-project/executor运行,并且可以用作 Google Cloud Build 中的step

Kaniko 为RUN命令创建的每个层提供远程缓存。构建会在构建任何层之前检查镜像注册表中的层缓存以查找匹配项。但是,我们在service_discovery Dockerfile 中使用的 Buildkit 功能在 Kaniko 中不可用,因此在 Google Cloud Build 配置cloudbuild.yaml中使用了单独的 Dockerfile,即ci/Dockerfile.cb

steps:
- name: 'gcr.io/kaniko-project/executor:latest'
  args:
  - --target=runner
  - --dockerfile=./ci/Dockerfile.cb
  - --build-arg=BASE_IMAGE=$_BASE_IMAGE
  - --build-arg=RUNNER_IMAGE=$_RUNNER_IMAGE
  - --destination=gcr.io/$PROJECT_ID/service_discovery:$COMMIT_SHA
  - --cache=true
  - --cache-ttl=8h

substitutions:
  _BASE_IMAGE: gcr.io/$PROJECT_ID/erlang:22
  _RUNNER_IMAGE: gcr.io/$PROJECT_ID/alpine:3.10

因为 Kaniko 通过检查注册表缓存目录中的每个指令来工作,所以不需要像我们对 Docker 的--cache-from所做的那样指示它使用特定的镜像作为缓存。Docker 的构建缓存可以通过将其设置为将缓存元数据导出到注册表并为所有阶段导出层--cache-to=type=registry,mode=max来更像 Kaniko,但这在撰写本文时不受大多数注册表支持,因此未涵盖。

有关在 Google Cloud Build 中使用 Kaniko 的更多详细信息,请参阅其使用 Kaniko 缓存文档。

后续步骤

在本节中,我们为我们的服务构建了镜像,并在最后创建了一个 CI 管道,以便在对存储库进行更改时持续构建和发布这些镜像。在下一节中,我们将介绍如何从这些镜像构建到 Kubernetes 的部署。在 Kubernetes 中运行之后,下一节将介绍可观察性,例如连接到正在运行的节点、构建良好的日志、报告指标和分布式跟踪。