构建代码有时就像执行脚本一样简单。但是功能齐全的构建系统需要更多的支持基础设施来同时处理多个构建请求、管理计算资源、分发工件等。

在我们讨论构建事件的上一章之后,连续构建系列的下一个迭代将介绍如何在 Docker Swarm 中启动容器以运行构建并对其进行测试。

什么是 Docker Swarm

在Swarm mode下运行 Docker 引擎时,您可以有效地创建集群。 Docker 将管理许多计算节点及其资源,并在它们之间安排在容器内运行的工作。

它在维护整体集群状态的同时处理跨节点的扩展,这样您就可以调整集群中运行的工作容器的数量、节点离线时自动故障转移等。

它还构建了必要的网络钩子,以便容器可以跨多个主机节点相互通信。它执行负载平衡、滚动更新以及您期望集群技术提供的许多其他功能。

集群设置

当计算主机构成 Docker Swarm 的一部分时,它们既可以在管理器模式下运行,也可以作为常规工作节点运行。节点能够按照管理器的指示托管容器。

一个 Swarm 可以有多个管理器,管理器本身也可以托管容器。他们的工作是跟踪集群的状态并根据需要跨节点启动容器。这允许跨集群的冗余,以便您可以松动一个或多个管理器或节点并保持基本操作运行。有关这方面的更多详细信息,请参阅官方Docker Swarm 文档。

要创建一个 swarm,您需要在您的第一个管理器(也用作您的第一个节点)上运行以下命令。

docker swarm init

前面的命令将告诉您在每个节点中运行什么以加入该集群。它通常看起来像这样:

docker swarm join --token SOME-TOKEN SOME_IP:SOME_PORT

默认情况下,docker 守护程序侦听位于/var/run/docker.sockunix套接字。这对于本地访问非常有用,但如果您需要远程访问,则必须明确启用tcp个套接字。

启用远程访问

Docker Swarm 提供了一个很好的 API 来管理服务,但是对于我们的特定用例,我们需要一个在 swarm 级别不可用的功能,并且需要单独访问节点。部分原因是因为我们将 Swarm 用于一种特殊情况,它不是为它构建的:运行一次性的短期容器 - 稍后会详细介绍。

您必须在节点守护程序上启用远程访问才能直接连接到它们。这样做似乎在 Linux 版本、发行版和 docker 配置文件的位置之间有所不同。但是,主要目标是相同的:您必须在守护程序服务执行中添加一个-H tcp://IP_ADDRESS:2375选项,其中IP_ADDRESS是它侦听的接口。

您会发现大多数示例将其设置为0.0.0.0,以便任何人都可以连接到它,但我建议将其限制为特定地址以提高安全性 - 更多内容如下。

我在使用/lib/systemd/system/docker.service中的文件来定义守护程序选项的 Ubuntu 映像上。您只需找到以ExecStart=...开头或其中包含-H的行,然后添加前面提到的额外 -H 选项。

不要忘记您必须在进行更改后重新加载守护进程配置并重新启动 docker:

sudo systemctl daemon-reload
sudo service docker restart

我见过其他发行版在/etc/default/docker下跟踪这些设置,还有另一个发行版使用/etc/systemd/system/docker.service.d/中的文件。您应该谷歌搜索docker daemon enable tcpdocker daemon enable remote api与您的操作系统风格配对以确保。

安全隐患

鉴于您可以使用 Docker 执行的操作的性质,重要的是要指出启用 TCP 套接字进行远程访问是一个非常严重的安全风险。它基本上打开了您的系统以进行远程代码执行,因为任何人都可以连接到该套接字并启动或停止容器、查看日志、修改网络资源等。

为了缓解这种情况,您需要启用证书验证以及 TCP 套接字。这使守护程序验证潜在客户端使用的 HTTPS 证书是否由预定义的证书颁发机构 (CA) 签名。

您创建证书颁发机构并签署任何客户端证书,然后将它们分发到将执行编排的计算系统 - 通常是您的构建服务。

有关如何生成证书、执行签名和启用验证选项的步骤,请参阅保护守护进程的 Docker 文档。

服务、任务和容器

在 swarm 模式下运行时,Docker 术语会发生一些变化。您不再只关心容器和镜像,还关心tasksservices

服务定义了构成在集群中运行的应用程序的所有部分。这些部分是任务,每个任务都是对容器的定义。

例如,如果您有运行 Flask API 的应用程序 ABC,您希望在两个节点之间进行负载平衡,您可以定义一个具有两个任务的 ABC 服务。 swarm 负责让它们在两个节点中运行(即使集群中有更多节点),并且还配置网络,以便无论您连接到哪个节点,服务都可以通过同一端口使用。

要运行的任务数量是复制策略的一部分,swarm 使用该策略来确定要在集群中继续运行多少个任务副本。您不仅可以将其设置为特定数字,还可以将其配置为全局模式,即在 swarm 的每个节点中运行任务的副本。

这些概念原则上很简单,但当您稍后尝试做更复杂的事情时可能会变得棘手。因此,我建议您查看服务文档以了解有关其工作原理的更多信息。

使用这个术语来描述我们的用例:对于每个新的构建请求,您将在 swarm 中运行一个新服务,其中包含一个任务实例和一个执行构建的容器。 swarm 调度程序将负责在任何可用的节点上进行配置。容器应该删除自己的工作完成。

替代品

如前所述,Docker Swarm 及其概念旨在维护集群内长期运行的复制服务。但是我们有一个非常特殊的情况,即每次构建都执行单副本临时服务。

我们不关心高可用性或负载平衡功能,我们想要它的容器调度功能。

虽然做起来很简单,但由于它不是为此而构建的,所以感觉就像我们在强迫事情在一起。所以另一种选择是构建我们自己的调度程序(或使用现有的调度程序)并让它在容器内执行工作。

这对于使用像 RabbitMQ 这样的工作队列来分发容器管理任务的现有任务系统(如 Celery 或Dramatiq)并不难。

沿着相同的思路,您可以为相同的目标重用分布式计算系统。为此,我已成功部署Dask。它甚至简化了一些工作流程,并启用了其他方式无法实现的其他工作流程。

我知道我以前说过很多次,但我会再说一遍。就像软件工程(和生活中)的大多数选择一样,您总是在用一组问题交换另一组问题。在这种情况下,您将工作流复杂性换成基础设施和维护复杂性,因为您现在必须保持工作线程运行并侦听队列,并随着软件的发展处理对这些线程的更新。这是通过使用 Docker Swarm 为您完全抽象的。

使用 Python 进行编排

由 Docker 人员维护的官方 Python 库是docker模块。它包装了所有主要结构,并且易于使用。我已经利用它一段时间了。

该库与 docker daemon REST API 交互。守护程序的命令接口使用资源 URL 上的 HTTP 动词来传输 JSON 数据。例如:列出容器是 GET 到/containers/json,创建卷是 POST 到/volumes/create,等等。

如果您有兴趣,请访问Docker API 参考了解更多详细信息。

APIClient 与 DockerClient

docker模块本身暴露了与守护进程的两个“层”通信。它们表现为不同的客户端类:APIClientDockerClient。前者是直接围绕接口端点的较低级别的包装器,而后者是该客户端之上的面向对象的抽象层。

为了我们今天的目的,我们将能够坚持使用DockerClient的实例来执行所有操作。进入较低级别的情况很少见,但有时是必需的。这两个接口在前面共享的链接中都有很好的记录。

创建客户端非常简单。安装pip install docker模块后,可以不带任何参数导入客户端类并实例化。默认情况下,它将连接到前面提到的 unix 套接字。

from docker import DockerClient
dock = DockerClient()

DockerClient类遵循通用的“client.resource.command”架构,使其易于使用。例如:您可以使用client.containers.list()列出容器,或者使用client.images.get('python:3-slim')查看图像详细信息。

每个资源对象都具有用于通用操作的方法,例如list()create()get(),以及特定于资源本身的方法,例如用于容器的exec_run()

.attrs属性以字典的形式提供所有属性,reload()方法获取资源的最新信息并刷新实例。

为了实现我们的目标,您需要为每个构建创建一个服务,找到执行其任务的节点,对该任务中的容器进行一些更改并启动容器。

执行脚本

配置一个执行构建的 swarm 服务只是成功的一半。另一半是编写遵循我们在早期章节中定义的指令的代码,以构建、测试、记录结果和分发工件。这就是我所说的执行脚本。

我们将在以后的文章中讨论脚本本身如何工作的细节。现在只要知道它是每个构建容器在启动时运行的命令就足够了。这是相关的,因为它带来了另一个我们必须处理的问题:执行脚本是用 Python 编写的,但构建容器不需要安装 Python。

我设计并实现了需要 Python 执行的持续集成系统,以及不需要执行的那些。一个增加了使用系统的开发人员的复杂性和限制,另一个增加了系统的维护人员。

如果您只构建 Python 代码是众所周知的事实,那么这并不重要,因为正在构建的代码存储库中使用的 docker 映像已经安装了 Python。

如果不是这种情况,那么您可能会看到构建时间、复杂性大幅增加,甚至可能会限制支持的镜像,因为开发人员必须在构建过程中将 Python 安装到容器中。

这些天我的选择是打包执行脚本,使其可以在任何 docker 映像中运行。我在之前的一篇文章中记录了我的尝试,关于将 Python 模块打包为可执行文件,其中我总结使用 PyInstaller 来完成这项工作。那里包含有关如何制作包的详细信息。

构建和执行测试

有了手头的所有要素,是时候深入研究创建新服务并运行构建的代码了。正如本系列前面章节中所定义的,以下步骤假定:

  • 正在构建的存储库在其根目录中有一个带有构建指令的 YAML 文件。

  • 此配置文件包含一个image指令,用于定义要与构建一起使用的 Docker 映像。

  • 还有一个environment指令,用户可以在其中设置环境变量。

  • 处理构建事件的 webhook 函数已检索构建配置信息并将其存储在名为config的字典中,并在pr字典中提供拉取请求信息。

创建服务

在做服务的时候,需要考虑给它取什么名字,搜索swarm时如何找到。

服务名称必须是唯一的,并且为了简化基础设施维护,它们应该是描述性的。我更喜欢使用-{repo_owner}-{repo_name}-{pr_number}-{timestamp}的后缀。这些名称也有字符串大小限制,所以请注意不要太有创意。

因为您不希望相同拉取请求的重复构建,所以您还需要能够以编程方式搜索 swarm 以查找正在运行的构建。换句话说,如果我正在为给定的拉取请求执行构建,那么如果我在同一个 PR 中提交了新代码,那么允许构建完成是没有意义的。您不仅在浪费资源,而且即使构建成功完成,它也毫无用处,因为它已经过时了。

为了处理这种情况,我使用labels。一个服务可以有一个或多个标签,其中包含关于它是什么以及它在做什么的元数据。 Docker API 还提供了基于此元数据进行过滤的方法。

下面的代码利用这些函数来确定构建是否已经在运行并在创建新服务之前停止它。

import logging
import docker

...

def execute(pr, action=None, docker_node_port=None):
    ...

    # Get environment variables defined in the config
    environment = config['environment'] if 'environment' in config and isinstance(config['environment'], dict) else {}

    # Get network ports definition from the config
    ports = docker.types.EndpointSpec(ports=config['ports']) if 'ports' in config and isinstance(config['ports'], dict) else None

    environment.update({
        'FORGE_INSTALL_ID': forge.install_id,
        'FORGE_ACTION': 'execute' if action is None else action,
        'FORGE_PULL_REQUEST': pr['number'],
        'FORGE_OWNER': owner,
        'FORGE_REPO': repo,
        'FORGE_SHA': sha,
        'FORGE_STATUS_URL': pr['statuses_url'],
        'FORGE_COMMIT_COUNT': str(pr['commits'])
    })
    logging.debug(f'Container environment\n{environment}')

    # Connect to the Docker daemon
    dock = docker.DockerClient()

    # Stop any builds already running on the same pr
    for service in dock.services.list(filters={'label': [f"forge.repo={owner}/{repo}", f"forge.pull_request={pr['number']}"]}):
        logging.info(f"Found service {service.name} already running for this PR")

        # Remove the service
        service.remove()

    # Create the execution service
    service_name = f"forge-{owner}-{repo}-{pr['number']}-{datetime.now().strftime('%Y%m%dT%H%M%S')}"
    logging.info(f"Creating execution service {service_name}...")

    service = dock.services.create(
        config['image'],
        command=f"/forgexec",
        name=service_name,
        env=[f'{k}={v}' for k, v in environment.items()],
        restart_policy=docker.types.RestartPolicy('none'),
        labels={
            'forge.repo': f'{owner}/{repo}',
            'forge.pull_request': str(pr['number']),
        }
    )

请注意,在创建服务之前,我们不仅要获取构建配置中定义的环境变量,还要添加描述我们正在执行的操作的附加内容。它们将有关存储库的相关信息和正在构建的拉取请求传递到执行脚本中。

如您所见,我们使用.services.list()来获取当前在 swarm 中运行的服务列表,这些服务使用我们之前描述的标签进行过滤。如果服务存在,调用service.remove()也将摆脱它的容器。

创建服务是对.services.create()的调用,我们在其中传递:

  • 服务所基于的容器镜像 - 在我们的构建配置中定义。

  • 启动时执行的命令,这是我们的执行脚本的名称 - 在本例中为forgexec

  • 服务名称。

  • 环境变量被定义为格式为NAME=VALUE的字符串列表,因此我们使用列表推导将它们从环境字典中转换。

  • 重启策略是 Docker 用来定义在主机重启时如何处理容器的术语。在我们的例子中,我们不希望它们自动上线,所以我们将其设置为none

  • 带有我们之前描述的元数据的标签。

获取任务信息

一旦 swarm 创建了服务,它需要几秒钟才能初始化其任务并提供运行它的节点和容器。因此,我们通过检查分配给服务的任务总数等到它可用:

    # Wait for service, task and container to initialize
    while 1:
        if len(service.tasks()) > 0:
            task = service.tasks()[0]
            if 'ContainerStatus' in task['Status'] and 'ContainerID' in task['Status']['ContainerStatus']:
                break
        time.sleep(1)

构建服务中只有一个任务,所以我们总是可以假设它是列表中的第一个。每个task都是一个字典,其中包含描述运行它的容器的属性。

这里有两种延迟:一种是在分配任务之前,另一种是在容器为任务启动之前。因此,有必要等到任务详细信息中的容器信息可用后再继续。

将执行脚本复制到容器中

如前所述,您需要将我们打包的执行脚本复制到每个构建容器中才能启动 - 相当于docker cp

将数据复制到容器中需要对文件进行 tar'd 和压缩,因此在事件服务器脚本的最开始,我们使用tarfilePython 模块创建一个tar.gz文件:

if __name__ == '__main__':
    # Setup the execution script tarfile that copies into containers
    with tarfile.open('forgexec.tar.gz', 'w:gz') as tar:
        tar.add('forgexec.dist/forgexec', 'forgexec')

这意味着我们有一个forgexec.tar.gz文件可以使用 docker 模块提供的container.put_archive()函数进行传输。每次 webhook 事件服务器启动并覆盖任何现有文件时都执行此操作,以确保您没有使用过时的代码。

将文件传输到容器中需要我们直接连接到 swarm 节点。 docker 服务级别没有接口可以帮助我们做到这一点。这就是为什么我们必须更早地启用远程访问。

首先,我们从任务中获取有关 docker 节点的信息,然后我们创建一个新的DockerClient实例来连接到该节点:

    node = dock.nodes.get(task['NodeID'])
    nodeclient = docker.DockerClient(f"{node.attrs['Description']['Hostname']}:{docker_node_port}")

这一次,实例化使用节点正在侦听的 tcp 端口(在集群设置期间配置)和节点的主机名。根据您的网络和 DNS 设置,您可能希望使用socket模块来帮助进行域名解析。像socket.gethostbyname(node.attrs['Description']['Hostname'])这样的东西可能就足够了。

此时我们可以直接访问容器,将文件拷贝进去并启动它:

    # Get container object
    container = nodeclient.containers.get(task['Status']['ContainerStatus']['ContainerID'])

    # Copy the file
    with open('forgexec.tar.gz', 'rb') as f:
        container.put_archive(path='/', data=f.read())

    # Start the container
    container.start()

放在一起

这是我们新的execute()函数与webhook 事件处理章节中的代码合并:

def execute(pr, action=None, docker_node_port=None):
    """Kick off .forge.yml test actions inside a docker container"""

    logging.info(f"Attempting to run {'' if action is None else action} tests for PR #{pr['number']}")

    owner = pr['head']['repo']['owner']['login']
    repo = pr['head']['repo']['name']
    sha = pr['head']['sha']

    # Select the forge for this user
    forge = forges[owner]

    # Get build info
    config = get_build_config(owner, repo, sha)

    if config is None or config.get('image') is None or config.get('execute') is None:
        logging.info('Unable to find or parse the .forge.yml configuration')
        return

    # Get environment variables defined in the config
    environment = config['environment'] if 'environment' in config and isinstance(config['environment'], dict) else {}

    # Get network ports definition from the config
    ports = docker.types.EndpointSpec(ports=config['ports']) if 'ports' in config and isinstance(config['ports'], dict) else None

    environment.update({
        'FORGE_INSTALL_ID': forge.install_id,
        'FORGE_ACTION': 'execute' if action is None else action,
        'FORGE_PULL_REQUEST': pr['number'],
        'FORGE_OWNER': owner,
        'FORGE_REPO': repo,
        'FORGE_SHA': sha,
        'FORGE_STATUS_URL': pr['statuses_url'],
        'FORGE_COMMIT_COUNT': str(pr['commits'])
    })
    logging.debug(f'Container environment\n{environment}')

    # Connect to the Docker daemon
    dock = docker.DockerClient(docker_host)

    # Stop any builds already running on the same pr
    for service in dock.services.list(filters={'label': [f"forge.repo={owner}/{repo}", f"forge.pull_request={pr['number']}"]}):
        logging.info(f"Found service {service.name} already running for this PR")

        # Remove the service
        service.remove()

    # Create the execution service
    service_name = f"forge-{owner}-{repo}-{pr['number']}-{datetime.now().strftime('%Y%m%dT%H%M%S')}"
    logging.info(f"Creating execution service {service_name}...")

    service = dock.services.create(
        config['image'],
        command=f"/forgexec",
        name=service_name,
        env=[f'{k}={v}' for k, v in environment.items()],
        restart_policy=docker.types.RestartPolicy('none'),
        # mounts=[],
        labels={
            'forge.repo': f'{owner}/{repo}',
            'forge.pull_request': str(pr['number']),
        }
    )

    # Wait for service, task and container to initialize
    while 1:
        if len(service.tasks()) > 0:
            task = service.tasks()[0]
            if 'ContainerStatus' in task['Status'] and 'ContainerID' in task['Status']['ContainerStatus']:
                break
        time.sleep(1)

    node = dock.nodes.get(task['NodeID'])
    nodeclient = docker.DockerClient(f"{node.attrs['Description']['Hostname']}:{docker_node_port}")
    container = nodeclient.containers.get(task['Status']['ContainerStatus']['ContainerID'])

    with open('forgexec.tar.gz', 'rb') as f:
        container.put_archive(path='/', data=f.read())

    container.start()

接下来是什么?

本文为您提供了使用 Docker Swarm 配置在集群内构建代码的计算所需的详细信息。除了前面关于处理存储库事件和定义执行构建所需的指令的章节之外,您已经准备好进入下一个涵盖执行脚本本身的部分。您需要为管道中的不同阶段配置构建环境、运行命令和执行测试。


了解更多!

订阅tryexceptpass.org 邮件列表以获取有关 Python、Docker、开源以及我们在企业软件工程方面的经验的更多内容。

Logo

权威|前沿|技术|干货|国内首个API全生命周期开发者社区

更多推荐