构建系统由用于从源代码过渡到运行应用程序的工具和流程组成。这种过渡还涉及将代码的受众从软件开发人员更改为最终用户,无论最终用户是运维同事还是部署系统。
在使用容器创建了一些构建系统之后,我认为我有一种体面且可重复的方法,值得分享。这些构建系统用于为嵌入式硬件生成可加载的软件镜像和编译机器学习算法,但该方法足够抽象,可以用于任何基于容器的构建系统。
这种方法是关于以易于使用和维护的方式创建或组织构建系统。它与容器化任何特定软件编译器或工具所需的技巧无关。它适用于软件开发人员构建软件以将可维护的镜像移交给其他技术用户(无论是系统管理员、DevOps 工程师还是其他职位)的常见用例。构建系统从最终用户那里抽象出来,以便他们可以专注于软件。
为什么要容器化构建系统?
创建可重复的、基于容器的构建系统可以为软件团队带来许多好处
- 专注: 我想专注于编写我的应用程序。当我调用工具进行“构建”时,我希望工具集交付一个随时可用的二进制文件。我不想花时间排除构建系统故障。事实上,我宁愿不知道或不关心构建系统。
- 相同的构建行为: 无论用例如何,我都希望确保整个团队使用相同版本的工具集,并在构建时获得相同的结果。否则,我将不断处理“在我的电脑上可以工作,但在你的电脑上却不行”的情况。在团队项目中,使用相同的工具集版本并为给定的输入源文件集获得相同的输出至关重要。
- 易于设置和未来迁移: 即使向每个人提供了详细的工具集安装说明,也可能有人会弄错。或者,由于每个人自定义 Linux 环境的方式不同,可能会出现问题。当团队中使用不同的 Linux 发行版(或其他操作系统)时,情况可能会进一步恶化。当需要迁移到工具集的下一个版本时,问题可能会变得更加糟糕。使用容器和本文中的指南将使迁移到更新版本变得更加容易。
在我的项目中使用容器化构建系统在我的经验中确实非常有价值,因为它缓解了上述问题。我倾向于使用 Docker 作为我的容器工具,但由于安装和网络配置对于每个环境都是唯一的,因此仍然可能存在问题,尤其是在涉及一些复杂代理设置的企业环境中。但至少现在我可以处理的构建系统问题更少了。
浏览容器化构建系统
我创建了一个 教程仓库,您可以稍后克隆并检查,或者按照本文进行操作。我将浏览仓库中的所有文件。构建系统是故意简单的(它运行 gcc),以使重点放在构建系统架构上。
构建系统要求
我认为构建系统中两个关键的理想方面是
- 标准构建调用: 我希望能够通过指向某个工作目录(路径为 /path/to/workdir)来构建代码。我希望像这样调用构建:
./build.sh /path/to/workdir
为了保持示例架构的简单性(为了便于解释),我将假设输出也生成在 /path/to/workdir 中的某个位置。(否则,它会增加暴露给容器的卷数量,这并不困难,但解释起来更麻烦。)
- 通过 shell 自定义构建调用: 有时,需要以不可预见的方式使用工具集。除了用于调用工具集的标准 build.sh 之外,如果需要,还可以将其中一些作为选项添加到 build.sh 中。但我始终希望能够进入 shell,在那里我可以直接调用工具集命令。在这个简单的示例中,假设有时我想尝试不同的 gcc 优化选项以查看效果。为了实现这一点,我希望调用
./shell.sh /path/to/workdir
这应该让我进入容器内的 Bash shell,并可以访问工具集和我的 workdir,这样我就可以随意使用工具集进行实验。
构建系统架构
为了符合上述基本要求,以下是我构建构建系统的方式

在底部,workdir 代表任何需要由软件开发人员最终用户构建的软件源代码。通常,此 workdir 将是一个源代码仓库。最终用户可以在调用构建之前以他们想要的任何方式操作此源代码仓库。例如,如果他们使用 git 进行版本控制,他们可以 git checkout 他们正在处理的功能分支,并添加或修改文件。这使构建系统独立于 workdir。
顶部的三个块共同代表容器化构建系统。顶部最左侧(黄色)的块代表最终用户将用于与构建系统交互的脚本(build.sh 和 shell.sh)。
中间(红色块)是 Dockerfile 和关联的脚本 build_docker_image.sh。开发运维人员(在本例中是我)通常会执行此脚本并生成容器镜像。(事实上,我会执行很多很多次,直到我把一切都弄对,但那是另一个故事。)然后我会将镜像分发给最终用户,例如通过容器可信注册表。最终用户将需要此镜像。此外,他们将克隆构建系统仓库(即,与 教程仓库 等效的仓库)。
右侧的 run_build.sh 脚本在最终用户调用 build.sh 或 shell.sh 时在容器内执行。接下来我将详细解释这些脚本。这里的关键是最终用户不需要了解红色或蓝色块,也不需要了解容器的工作原理才能使用任何这些。
构建系统详细信息
教程仓库的文件结构映射到此架构。我已经将此原型结构用于相对复杂的构建系统,因此它的简单性绝不是任何限制。下面,我列出了仓库中相关文件的树状结构。dockerize-tutorial 文件夹可以用任何其他与构建系统对应的名称替换。在此文件夹中,我使用一个参数(即 workdir 的路径)调用 build.sh 或 shell.sh。
dockerize-tutorial/
├── build.sh
├── shell.sh
└── swbuilder
├── build_docker_image.sh
├── install_swbuilder.dockerfile
└── scripts
└── run_build.sh
请注意,我特意排除了上面的 example_workdir,您将在教程仓库中找到它。实际源代码通常会驻留在单独的仓库中,而不是构建工具仓库的一部分;我将其包含在此仓库中,因此我不必在教程中处理两个仓库。
如果您只对概念感兴趣,则无需进行本教程,因为我将解释所有文件。但是,如果您想继续操作(并已安装 Docker),请首先使用以下命令构建容器镜像 swbuilder:v1
cd dockerize-tutorial/swbuilder/
./build_docker_image.sh
docker image ls # resulting image will be swbuilder:v1
然后像这样调用 build.sh
cd dockerize-tutorial
./build.sh ~/repos/dockerize-tutorial/example_workdir
build.sh 的代码如下。此脚本从容器镜像 swbuilder:v1 实例化一个容器。它执行两个卷映射:一个从 example_workdir 文件夹到容器内路径 /workdir 的卷,第二个从容器外部的 dockerize-tutorial/swbuilder/scripts 到容器内部的 /scripts。
docker container run \
--volume $(pwd)/swbuilder/scripts:/scripts \
--volume $1:/workdir \
--user $(id -u ${USER}):$(id -g ${USER}) \
--rm -it --name build_swbuilder swbuilder:v1 \
build
此外,build.sh 还调用容器以您的用户名(和组,教程假设两者相同)运行,以便您在访问生成的构建输出时不会遇到文件权限问题。
请注意,shell.sh 是相同的,除了两件事:build.sh 创建一个名为 build_swbuilder 的容器,而 shell.sh 创建一个名为 shell_swbuilder 的容器。这样做是为了避免在另一个脚本正在运行时调用任一脚本时发生冲突。
两个脚本之间的另一个主要区别是最后一个参数:build.sh 传入参数 build,而 shell.sh 传入参数 shell。如果您查看用于创建容器镜像的 Dockerfile,最后一行包含以下 ENTRYPOINT。这意味着上面的 docker container run 调用将导致执行 run_build.sh 脚本,其中 build 或 shell 作为唯一输入参数。
# run bash script and process the input command
ENTRYPOINT [ "/bin/bash", "/scripts/run_build.sh"]
run_build.sh 使用此输入参数来启动 Bash shell 或调用 gcc 来执行简单的 helloworld.c 项目的构建。真正的构建系统通常会调用 Makefile 而不是直接运行 gcc。
cd /workdir
if [ $1 = "shell" ]; then
echo "Starting Bash Shell"
/bin/bash
elif [ $1 = "build" ]; then
echo "Performing SW Build"
gcc helloworld.c -o helloworld -Wall
fi
如果您的用例需要,您当然可以传递多个参数。对于我处理过的构建系统,构建通常是针对具有特定 make 调用的给定项目。如果构建系统的构建调用很复杂,您可以让 run_build.sh 调用最终用户必须编写的 workdir 内的特定脚本。
关于 scripts 文件夹的说明
您可能想知道为什么 scripts 文件夹位于树状结构的深处而不是仓库的顶层。任何一种方法都可以工作,但我不想鼓励最终用户四处翻找并更改那里的内容。将其放置得更深是一种使其更难以四处翻找的方法。此外,我可以添加一个 .dockerignore 文件来忽略 scripts 文件夹,因为它不需要成为容器上下文的一部分。但由于它很小,所以我没有费心。
简单而灵活
虽然这种方法很简单,但我已将其用于几个相当不同的构建系统,并发现它非常灵活。相对稳定的方面(例如,一年只更改几次的给定工具集)固定在容器镜像内部。更流畅的方面作为脚本保留在容器镜像外部。这使我可以轻松地通过更新脚本并将更改推送到构建系统仓库来修改工具集的调用方式。用户需要做的就是将更改拉取到他们的本地构建系统仓库,这通常非常快(不像更新 Docker 镜像)。该结构本身适用于拥有任意数量的卷和脚本,同时将复杂性从最终用户那里抽象出来。
7 条评论