用于构建 ConanCenter C/C++ 项目的现代 Docker 镜像:如何停止担忧并爱上旧镜像
这是关于Conan Center 的新 Docker 镜像的初始帖子的延续,它将帮助您了解 Conan Docker 工具的问题、动机和提出的解决方案。在这篇文章中,我们将继续讨论提出的实施方案,重点介绍开发过程中遇到的问题,并展示此解决方案如何改进我们使用 Conan Docker 镜像的方式。此外,这篇博文比平时稍长,包含了关于所做决策、所犯错误以及 Dockerfile 使用的技术部分的解释。我们将这篇博文分为两部分
- 第一部分:漫长的旅程、想法、动机、错误和挑战
- 第二部分:技术细节:Dockerfile 和配方背后的原理
社区的重要性
我们要感谢社区为 Conan 和 Docker 镜像提供的宝贵反馈。您的反馈让我们了解到这些 Docker 镜像不仅对 Conan Center 生成用于一般分发的官方软件包至关重要,而且对于拥有个人项目并使用这些镜像来验证和构建其 Conan 配方的个人以及通过源代码构建软件包的公司也很重要。
经过长时间的反馈和新的表述,我们认识到有必要对最初的概念进行修改。我们意识到,鉴于破坏用户之间行为的风险,我们无法修改现有镜像以实现更好的维护,因此我们从头开始,制定了一个在 Conan Center 和用户之间更平衡的版本。我们无法长时间保留旧版本以支持旧编译器,这是进步的代价。为了继续前进,我们需要删除旧版本以更好地维护新版本。
我们感谢围绕 Conan 的伟大团队,并帮助使其变得越来越完整。如果没有您的支持、反馈以及对我们持续改进 Conan 能力的信任,我们将无法取得今天的成就。
第一部分:漫长的旅程:想法、动机、错误和挑战
在本节中,我们将介绍我们如何重新审视最初的提议,发现问题,提出解决方案并开始了一个全新的想法。如果您对我们旅程的技术部分更感兴趣,请跳到第二部分。
Conan Docker 镜像:重新审视
拉取请求 #204 向我们展示了一些需要考虑的弱点
- 通过 LLVM 的 APT 仓库安装 Clang 无法保证与用于构建 GCC 的
libstdc++
版本完全兼容。 - GCC 和 Clang 的要求的 APT 软件包仍然存在,进一步增加了最终镜像的大小。
- 通过 Ubuntu 提供的软件包在出现新版本时没有可用的旧版本。这影响了可重复性要求。
- 旧编译器在进入 EOL(生命周期结束)状态后,始终是维护方面的问题。有必要更新 PPA 地址并重新构建镜像。
- 所使用的持续集成服务虽然速度相当快,但如果需要,无法自定义和优先级构建。
注意到列出的问题,我们尝试了一个激进的解决方案。此解决方案需要更多时间来实现,但在可维护性和实用性方面取得了更好的成果。
鉴于这种情况,我们决定放弃 PR #204 并从头开始,考虑上面列出的项目。
新计划:使用相同的基镜像
在开始使用适当的更正实现新的 Dockerfile 之前,我们首先需要了解项目背后的目标。我们想了解两年后对这些新镜像的期望是什么。使用当前的方法,我们已经在维护超过 40 个不同的 Dockerfile,这可能存在问题。我们还希望解决之前讨论的其中一个要点:将进行轮换以避免旧镜像的累积及其在维护方面的限制。
计划
对于当前和已经在 hub.docker 上可用的镜像,将保留这些镜像,但不再支持(除非 ConanCenter 需要,否则不会引入新版本)。您的配方和镜像将保持可用,因为立即删除它们会导致许多用户出现灾难性故障。它们最终将被删除,但将在一个遥远且尚未确定的日期。
至于新镜像,这些镜像将被采用为官方镜像并广泛推广供所有用户使用。Conan Center 中对新镜像的过渡应在发布后的几个月内进行,因为将需要重新构建 Conan Center 中已存在的软件包并替换它们以确保软件包之间的完全兼容性。至于轮换和维护,我们认为有必要随着时间的推移轮换受支持的编译器,以避免对旧镜像和社区并不总是使用的软件包造成大量的构建、工作量和维护负担。因此,将采用以下规则
- Clang 将从 10.0 支持到最新版本。
- 另一方面,GCC 广泛用于 Linux 环境,我们将从版本 5 生成到最新版本的镜像。
- 对于这两个编译器,我们将根据新版本更新所有新的编译器版本和 Conan 客户端版本。
- 放弃了多库支持,因为我们只对生成 64 位支持的软件包感兴趣。
- 添加了 Fortran 支持,从而在镜像中一起生成了
gfortran
。目前,gfortran
的 Conan 软件包完全损坏,并且具有复杂的依赖项链需要修复。
我们一直希望独立于 Linux 发行版,因此我们希望解决的问题之一是使用的编译器及其库。在最初的拉取请求中,我们从源代码构建了 GCC,而 Clang 使用预构建版本。为了解决这个问题,我们选择从源代码构建两者,以便更好地控制使用的编译器。使用这些镜像生成的软件包(ConanCenter 中的软件包)应在尽可能多的不同发行版中开箱即用。
库 libstdc++
与 GCC 项目一起分发。目的是使用库的单个最新版本。此决定将允许旧版发行版继续使用该库,同时还允许新编译器使用新功能。选择的版本是 libstdc++.so.6.0.28
,与 GCC 9 和 10 一起分发的版本,也是 Ubuntu 20.04 LTS(Focal)中的默认版本。构建 GCC 10 后,我们假设可以将此库复制到其余镜像中。那是最初的意图,但我们很快发现这是不可能的。在我们开发新配方时,GCC 11 发布了,以及一个新的 libstdc++
版本(6.0.29
)。无法将先前版本与这个新编译器一起使用。我们面临以下困境
- 使用相同的 libstdc++ 版本,除了 GCC 11 之外。
- Conan Center 变得同质化(除了 GCC 11):所有二进制文件都将使用相同的
libstdc++
版本构建和链接,这保证了所有二进制文件都可以在任何镜像中运行。 - 二进制文件很难在 Conan Center 外使用,因为它们需要官方 PPA 中尚不可用的最新版本的 libstdc++ 库。在 ConanCenter 内部构建的所有可执行文件都无法在用户的机器上运行。
- 一个可能的解决方案是在所有二进制文件中静态链接 libstdc++,但此解决方案存在一些风险。
- Conan Center 变得同质化(除了 GCC 11):所有二进制文件都将使用相同的
- 每个镜像都使用编译器提供的相应版本的 libstdc++
- 比当前方案更好,在当前方案中,它依赖于 PPA,我们无法控制它。
- 所有镜像仍然使用相同版本的 glibc,这是相较于当前方案的另一个优势。
- 我们需要注意可执行文件,因为它们将只与更高版本兼容(就像现在一样)。
- 对于用户而言,比当前方案更好。与 libstdc++ 相关的要求相同,但 glibc 版本对于所有软件包都是相同的版本。
鉴于这些条件和风险,我们选择第二种方法:**使用编译器提供的 libstdc++ 版本**。
由于 Clang 也支持 libstdc++
,因此我们选择 libstdc++.so.6.0.28
作为其默认版本。如前所述,该版本由于其历史和兼容性而具有一些优势。它也是最新 LTS Ubuntu 版本的默认版本,因此我们确实认为它应该满足大多数用例。
Ubuntu 16.04 Xenial LTS 仍然是使用的基础,它将得到支持,直到 2024 年 4 月。在此日期之后,我们将需要将镜像更新到发行版的更新版本,此外还需要重新构建所有可用的官方软件包。
总而言之:前瞻性思维是拥有更少的镜像,但拥有更好的支持,而不会出现剧烈的中断和不兼容性问题。
修订后的计划
- 使用 Ubuntu 16.04 LTS 作为基础 Docker 镜像
- 从源代码构建 Clang 和 GCC
- 使用编译器提供的 libstdc++
- 对所有新的 Docker 镜像使用 glibc 2.23
- 只要旧编译器的构建脚本与新编译器的构建脚本兼容,就会构建旧编译器的镜像。
训练巨龙:从源代码构建 Clang
我们希望只使用一个版本的 libstdc++
,因此我们找到了一种在没有直接依赖于 GCC 的情况下构建 Clang 的方法。通过使用已安装的另一个 Clang 构建 Clang,它避免了 libgcc_s
、libstdc++
并改用 libc++
、libc++-abi
、libunwind
、compiler-rt
和 ldd
。 libstdc++
将仅用于 Conan 软件包 - 而不是作为 Clang 的要求。
挑战与行动项
- LLVM 项目使用 CMake 支持,这有助于配置其构建,甚至在必要时进行自定义。
- 我们选择使用 Clang 10 作为构建器,因为它是最新的,并且仍然兼容所选的 Ubuntu 版本。编译器是预构建的,并由官方 LLVM PPA 发布。
- 从一个版本到另一个版本,选项会被添加或删除,反映了项目功能的演变和遗留功能的弃用。随着这些变化,不可避免地需要研究每个版本的 CMake 文件,以了解哪些选项在后续版本中不起作用,或者应该使用哪个选项来指定首选库。
- 与 GCC 不同,LLVM 具有大量参数,并且构建时间更长,大约 1 小时,具体取决于主机。因此,对于每次尝试,都需要长时间等待才能获得结果。
- 在 Clang 9 发布之前,当使用 Clang 时,
libc++
不会自动添加到链接中。作为解决方案,该项目支持一个配置文件,其中可以默认指定libc++
。但是,这种行为在版本 6、7 和 8 之间有所变化,需要不同的标准,并且难以对所有版本使用相同的 Docker 构建文件。 - 随着 GCC 依赖项的移除,在构建过程中需要使用
libunwind
。它已经在 LLVM 内部化,但仅用作动态库。所以一个问题出现了,如果一个项目使用带有 Clang 的镜像并安装 Conan 的 libunwind 包会发生什么?链接时出现大混乱就是答案。Clang 尝试链接由 Conan 包分发的版本,导致出现多个错误。作为解决方法,我们将原始 LLVM 的libunwind
重命名为libllvm-unwind
。
从 Clang 6 到 12,随着这些问题和限制的发现,维护变得非常困难。经过多次讨论并听取了一些 LLVM 维护人员的建议后,我们决定将 Clang 支持限制在版本 10 及以上,因为不需要应用很多修改,包括配置文件。此外,在 Linux 环境中,Clang 不是主要的编译器,因此我们认为它的使用始终与较新版本相关联。
第二部分:Dockerfile 和技术细节的幕后
在这里,我们将更加关注最终产品、Dockerfile、测试和 CI。
从蓝图到原型:编写新的 Docker 构建文件
在原型设计过程中,我们意识到可以将构建 Docker 镜像的过程分为三个阶段。
- 阶段 1:基础镜像,其中安装了所有镜像共有的软件包,例如 Python、git、svn 等,以及非 root 用户配置。
- 阶段 2:仅构建编译器的镜像。在这个容器中可以安装仅与编译器构建相关的软件包,这些软件包不会出现在最终镜像中,例如 Ninja,它用于 LLVM。
- 阶段 3:将基础镜像与生成的编译器合并到单个镜像中,而无需添加额外的软件包,但仍然可以在每个编译器版本之间重复使用。
对于基础镜像的情况,它仍然相当模块化,只需更改变量文件即可更新要安装的软件包。完整的构建文件可以从 这里 获取,但让我们看一下几个部分。
ARG DISTRO_VERSION FROM ubuntu:${DISTRO_VERSION}
ENV PYENV_ROOT=/opt/pyenv \
PATH=/opt/pyenv/shims:${PATH}
ARG CMAKE_VERSION ARG CMAKE_VERSION_FULL ARG PYTHON_VERSION ARG CONAN_VERSION
现在,发行版版本和已安装的软件包在使用的版本方面都是可配置的。以前,需要使用脚本更新所有 42 个构建文件!
RUN printf '/usr/local/lib64\n' >> /etc/ld.so.conf.d/20local-lib.conf \
&& printf '/usr/local/lib\n' > /etc/ld.so .conf.d/20local-lib.conf \ ...
为了不受调用 apt-get
的系统软件包或 Conan 软件包的影响,编译器及其工件安装在 /usr/local
中。但是,这不足以优先考虑使用的 libstdc++
顺序,为此我们需要使用本地目录更新 ldconfig
。在此之前,在以前的镜像中没有必要这样做,因为所有内容要么直接从系统中获取,要么直接安装在 /usr
中。
这些是基础镜像的主要功能,它用于所有最终镜像。
对于 Clang 的构建,我们尝试使其从版本 6.0 到 12 都可用,但我们遇到了一系列障碍和挑战,使我们改变了主意。在这里,我们将分享这段漫长的 CMake 文件和编译时间的旅程。
从源代码构建 GCC
现在让我们看一下 GCC 构建镜像,完整的构建文件可以从 这里 找到,但让我们重点介绍几点。
RUN cd gcc-${GCC_VERSION} \
&& ./configure --build=x86_64-linux-gnu --disable-bootstrap --disable-multilib ...
无论版本如何,GCC 都会继续使用相同的行进行构建。此版本中配置了一些因素。
- 已禁用引导程序以将构建时间缩短至仅 20 分钟。
- 启用了 Fortran,但它几乎不会增加构建时间和最终结果。
镜像的最后一部分使用了 Docker 的 多阶段构建 概念,这是一种避免创建单独的构建文件和镜像以利用公共部分的技术。
FROM ${DOCKER_USERNAME}/base-${DISTRO}:${DOCKER_TAG} as deploy
ARG GCC_VERSION ARG LIBSTDCPP_PATCH_VERSION
COPY --from=builder /tmp/install /tmp/install
RUN sudo rm -rf /usr/lib/gcc/x86_64-linux-gnu/* \
&& sudo cp -a /tmp/install/lib/gcc/x86_64-linux-gnu/${GCC_VERSION}
/usr/lib/gcc/x86_64-linux-gnu/ \ && sudo cp -a /tmp/install/include/* /usr/local/include/ \ &&
sudo cp -a /tmp/install/lib64/ /usr/local/ \ && sudo cp -a /tmp/install/libexec/ /usr/local/
\ && sudo cp -a /tmp/install/lib/* /usr/local/lib/ \ && sudo cp -a /tmp/install/bin/*
/usr/local/bin/ \ && sudo rm -rf /tmp/install \ && sudo update-alternatives --install
/usr/local/bin/cc cc /usr/local/bin/gcc 100 \ && sudo update-alternatives --install
/usr/local/bin/cpp cpp /usr/local/bin/g++ 100 \ && sudo update-alternatives --install
/usr/local/bin/c++ c++ /usr/local/bin/g++ 100 \ && sudo rm /etc/ld.so.cache \ && sudo
ldconfig -C /etc/ld.so.cache \ && conan config init --force
在本节中,使用的基础镜像与我们之前创建的相同,通过缓存机制可以大大减少最终镜像的构建时间。现在将从 GCC 生成的所有工件复制到它们各自的位置。最后,编译器成为镜像中的默认编译器,并列出和缓存库。最后,最棒的是,相同的构建文件可以从 GCC 5 工作到最新版本。您只需要修改一些参数即可。与当前的 Conan Docker Tools 相比,维护工作已大大简化。
Conan 遇到 Wyvern:从源代码构建 Clang C/C++ 编译器
要查看完整的构建文件,可以从 这里 获取。
让我们更进一步,详细介绍 Clang 部署步骤。
FROM ${DOCKER_USERNAME}/gcc${LIBSTDCPP_MAJOR_VERSION}-${DISTRO}:${DOCKER_TAG} as libstdcpp
FROM ${DOCKER_USERNAME}/base-${DISTRO}:${DOCKER_TAG} as deploy
ARG LIBSTDCPP_VERSION ARG LIBSTDCPP_PATCH_VERSION
ARG DOCKER_USERNAME ARG DOCKER_TAG ARG DISTRO
COPY --from=builder /tmp/install /tmp/clang COPY --from=libstdcpp /usr/local /tmp/gcc
RUN sudo mv /tmp/gcc/lib64 /usr/local/ \
&& sudo ln -s -f /usr/local/lib64/libstdc++.so.6.0.${LIBSTDCPP_PATCH_VERSION}
/usr/local/lib64/libstdc++.so.6 \ && sudo ln -s -f /usr/local/lib64/libstdc++.so.6
/usr/local/lib64/libstdc++.so \ && sudo cp -a /tmp/gcc/include/*
/usr/local/include/ \ && sudo rm -rf /usr/lib/gcc/x86_64-linux-gnu/* \ && sudo cp -a
/tmp/gcc/lib/gcc/x86_64-linux-gnu/${LIBSTDCPP_VERSION} /usr/lib/gcc/x86_64-linux-gnu/ \ &&
sudo cp -a /tmp/gcc/lib/* /usr/local/lib/ \ ...
与 GCC 所做的一样,在 Clang 中,我们也使用相同的基础镜像并将编译器生成的工件复制到 /usr/local
目录。但是,libstdc++
库是从 GCC 10 镜像中提取的。这是 Conan 支持的可能配置(compiler.libcxx
设置的可能值)的必要条件。
此外,Clang 需要一些有趣的 CMake 定义。
- LLVM_ENABLE_PROJECTS:仅启用我们想要的项目,否则我们将拥有大量的二进制文件和数小时的构建时间。
- LLVM_USE_LINKER:我们强制使用 LLVM 链接器 (lld)。它比 GNU ld 更快,并减少了总构建时间。
... && ninja unwind \ && ninja cxxabi \ && cp lib/libc++abi* /usr/lib/ \ && ninja cxx \ &&
ninja clang \
如果我们单独运行 ninja
命令,它会构建比我们配置为启用的更多的项目,因此我们逐个构建。此外,libcxx
在使用 libc++abi
构建时存在一个限制,它首先搜索系统库文件夹,而不是内部文件夹。
测试和更多测试:用于测试 Docker 镜像的 CI 管道
为了确保生成的镜像满足我们的要求,我们需要添加新的测试,除了 Conan Docker Tools 中已测试的内容之外,还需要涵盖更多内容。在此之前,使用单个脚本验证一系列构建、已安装二进制文件的版本和用户权限。新测试的内容可以从 这里 查看。
我们在测试中引入了更大的模块化,将步骤划分为单独的脚本以更好地服务于每个编译器。由于支持 Fortran,因此需要调整一个涵盖它的测试。此外,许多现在与 Conan 捆绑在一起的应用程序不再是基础镜像的一部分,这一点也已得到验证。
如果您想在本地测试生成的 Docker 镜像,可以轻松运行。
$ cd modern && pytest tests --image conanio/gcc10-ubuntu16.04 --service deploy -vv --user 0:0
CI 服务变更:从 Travis 到 Azure 和 Jenkins 的到来
从项目开始,Conan Docker Tools 就一直使用 Travis 和 Azure 等 CI 服务。但是,这并没有给我们充分的权力来优先考虑队列中的构建、自定义主机或自定义构建行以在必要时使用 Docker-in-Docker。
考虑到这一点,我们开始使用 Jenkins 来构建新的 Docker 镜像。这样做的最大优势是利用 Docker 中的缓存功能。以前,如果在其他服务上没有使用缓存,则单个作业需要长达 2 个小时。现在使用 Jenkins 和 Docker 的 --cache-from
,从基础镜像到最终镜像更新一个软件包每个作业只需要 4 分钟。使用的 Jenkinsfile 文件可以从 这里 查看。
尽管该文件乍一看很复杂,但仍然可以使用 docker-compose 从头开始构建镜像。例如,让我们使用 Clang 12。
$ cd modern
$ docker-compose build base
$ docker-compose build clang12-builder
$ docker-compose build clang12
生成的镜像将命名为 conanio/clang12-ubuntu16.04:1.39.0
,其中 1.39.0
是镜像标签和已安装的 Conan 版本。但它可以通过 .env
文件完全配置。
对于旧版镜像,它们将在需要时继续在 Azure 中构建,我们不打算将它们迁移到 Jenkins,因为这需要付出很多努力和维护成本。
如何使用新的 Docker 镜像构建 Conan 包
构建了新的镜像后,我们就可以构建 Conan 包了。让我们以 Boost 为例。
$ docker run --rm -ti -v ${HOME}/.conan/data:/home/conan/.conan/data conanio/gcc10-ubuntu16.04:1.39.0
conan@148a77cfbc33:~$ conan install boost/1.76.0@ --build
conan@148a77cfbc33:~$ exit
在这里,我们启动一个带有交互式支持的临时 Docker 容器。此外,我们将我们的 Conan 缓存数据作为卷共享。启动后,我们从源代码构建 Boost 1.76.0 及其依赖项。所有软件包都将构建并安装到共享卷中,因此我们可以在关闭容器后使用它。要完成并删除容器,我们只需要退出即可。
$ docker run -d -t -v ${HOME}/.conan/data:/home/conan/.conan/data --name conan_container conanio/gcc10
$ docker exec conan_container conan install boost/1.76.0@ --build
$ docker stop conan_container
$ docker rm conan_container
类似的执行,相同的结果。我们没有创建临时 Docker 容器,而是在后台执行它。所有容器命令都通过 conan exec
命令传递。此外,我们还需要在完成之后手动停止并删除它。
最后的感想和反馈
我们邀请所有使用 Conan Docker Tools 镜像的用户使用这种新的方案,它很快就会成为官方方案。这些新的镜像是我们漫长旅程的结果,在这个旅程中,我们从错误中吸取教训,倾听社区的声音,才取得了今天的成果。我们相信应该持续改进以保持 CDT 的发展,因此请您在 issue #205 中反馈您的意见。