Python 是一种流行的应用程序语言。在 2020 年代,作为后端服务运行的那些程序通常在容器内运行。要实现这一点,你必须构建一个容器。
通常,对于微服务架构,构建一个“根”基础镜像是有意义的,你的所有服务都基于它构建。本文主要关注这个基础镜像,因为在这里最容易犯错误。但是,我也将介绍应用程序本身,因为没有好的应用程序,好的基础也没有太大用处。
什么构成一个好的容器?
在讨论如何构建好的容器之前,你需要了解什么是好的容器。是什么区分了好容器和坏容器?你可能会转向你在容器世界中听到的一些显而易见的衡量标准:
- 快速
- 小巧
- 安全
- 可用
这相当高层次,可能过于笼统。“快速”是什么意思?在什么方面快速?“小巧”有多小? “安全”或“可靠”意味着什么?
所以更具体的东西可能更好。容器有一些特定的要求。以下是一些常见的:
- 能够保持最新
- 可重现的构建
- 生产环境中没有编译器
- 保持合理的体积
我将从“保持最新”开始。通常,首先也是最重要的是,这意味着定期安装来自上游发行版的安全更新。但是,这与下一个可重现构建的目标直接冲突。可重现构建的抽象理论指出,给定相同的源代码必须产生比特对比特完全相同的结果。这有很多优点,但实现起来并非易事。
如果稍微降低标准,相同的源代码也必须产生等效的结果。虽然这消除了一些优点,但它保持了最重要的优点。改变源代码一定的量只会导致相应的改变。这是可重现构建的主要好处。它允许推送小修复程序,并确信没有不相关的更改。这允许对小修复程序进行更少的测试,并更快地交付热补丁。
下一个标准听起来几乎微不足道:“生产环境中没有编译器。”这很容易:提前编译,并将结果存储在镜像中。之所以有这个标准,是因为如果不仔细思考和实施,很容易出错。许多容器在交付时都带有 gcc
,因为有人没有足够仔细地编写他们的 Dockerfile
。
然而,关于大小的问题,你可能会花费近乎无限的时间。对于每个字节,你都可以争论它是否值得。实际上,在进入几百兆字节以下后,这很快就会变成收益递减的游戏。数小时的工作可以用于仔细修剪几百千字节的额外空间。停止的点取决于成本结构。你是否按千兆字节付费?如果是,多少钱?有多少不同的镜像使用基础镜像?你是否有更有价值的事情可以用来打发时间?
实际上,将镜像降至几百兆字节(200 或 300)是相当简单的。通过更多的工作,可以将它们降至 200 以下。
这通常是一个好的停止点。
何时在你的容器中使用二进制包
使构建容器镜像更快更可靠的一种方法是为具有本机代码的包使用二进制 wheel。无论你是从 PyPI 获取 wheel、将 wheel 构建到内部包索引中,还是甚至将 wheel 作为多阶段容器构建的一部分构建,二进制 wheel 都是一个有用的工具。
容器用户身份
为容器添加专用用户以运行应用程序非常重要。这很重要,原因有很多,但总的主题是这是一项重要的干预措施,可以降低风险。
在大多数设置中,容器内的 root 与容器外的 root 相同。这使得 root 更有可能找到“容器逃逸”。
虽然常规用户可以找到权限提升漏洞,然后以 root 身份逃逸,但这增加了此类攻击的复杂性。通过挫败不太专注的攻击者并增加持久攻击者触发审计警报的机会,迫使攻击者使用复杂的攻击。
另一个重要原因是更平凡的:root 用户可以在容器内做任何事情。限制这些能力既是一种明智的避免错误策略,又减少了攻击面。
以 root 身份运行也是下一个好主意的必要组成部分:以最小的权限运行。最重要的是,最好尽可能避免写入权限。避免写入权限的最重要的是应用程序运行的虚拟环境。
再次避免此类写入权限通过阻止运行时代码修改来降低攻击面。
容器性能
下一个要优化的事情是性能。这里最重要的加速标准是重建时间。
现代的基于 BuildKit 的构建试图聪明地处理哪些步骤阻止哪些缓存失效。在多阶段构建中,它们还尝试并行运行那些可以证明彼此独立的步骤。
编写 Dockerfile
以利用这种技术是一项需要掌握的非凡技能,但非常值得。特别有用的是考虑哪些文件比其他文件变化更少。
一个例子技巧:首先复制 requirements.txt
并将其用作 pip install -r
的参数,然后再复制源代码并安装它。
这意味着下载和安装(有时甚至是编译)依赖项只会因 requirements.txt
文件而使缓存失效。这允许更常见的本地源代码更改用例的更快重建。
基础
要从头开始制作苹果派,首先,创造宇宙。创造宇宙是一项吃力不讨好的工作,并且可能有更有价值的方式来度过你的工作日。
所有这些都是说你可能会用 FROM <some distro>
开始你的镜像定义。但是哪个发行版?对于容器而言,比传统操作系统用途更重要的一件事是它们对大小开销更敏感。这是因为容器镜像往往与应用程序 1:1 对应。
假设一个应用程序在每个拉取请求 (PR) 上构建一个测试版本,并将其存储在注册表中一段时间,以便你可以在此 PR 上对不同环境运行测试——这会在注册表中存储许多 OS 版本。
其中一些通过容器共享基础层来缓解,但可能比实践中通常天真地假设的要少。事实证明,镜像的构建是为了接受安全和关键错误补丁,这往往会足够频繁地扰乱基础操作系统,以至于缓存虽然有帮助,但无法替代较小的尺寸。
由于应用程序是在基础镜像之上构建的,因此基础版本的小幅提升是有用的,但相对罕见。应用程序团队必须花在迁移到新基础上的任何时间,都是他们没有开发有用的面向客户功能的时间。
这意味着最好找到一个具有长期支持 (LTS) 版本的基础。拥有大约五年 LTS 的基础可以对升级进行适当的规划,而无需频繁执行。
与 LTS 一起,基础的更新策略至关重要。它是否更新一般错误?仅更新严重错误?安全修复?它是否进行向后移植或尝试升级到新的上游版本?
我发现 Alpine 对于基于 Python 的应用程序来说不是一个好的选择,因为它使用 musl
(不是 glibc
)并且它与 manylinux
不兼容。这使得许多二进制 wheel 问题变得不必要地复杂。这将来可能会随着 musllinux
的潜在支持而改变,但目前这不是最佳选择。
流行的选择包括 Debian。它具有保守的更新策略和五年 LTS。
另一个流行的选择是 Ubuntu。它具有稍微宽松的策略(例如,它仅允许出于充分理由进行向后移植)。这些策略还取决于“universe”和“multiverse”之间的细微差异,这超出了本文的范围。
容器的滚动发布怎么样?
有些发行版具有所谓的“滚动发布”。 不像定期发布那样更新所有软件包到新的上游版本,而是新的上游版本在发布和集成后就被添加进来。 这对于桌面来说效果很好,因为使用最新的版本很有趣。 甚至对于非临时服务器来说,也能很好地工作,因为能够进行长期原地升级,可以最大限度地减少需要完全重建机器的情况。
但是,对于容器来说,滚动发布不太合适。 增量更新的主要好处完全丧失,因为每个镜像都是从头开始构建的。 容器是为了整体替换而构建的。
滚动发布对容器的最大缺点是,如果没有可能获得新版本的上游软件,就无法获得安全更新。 这可能意味着需要花费高昂的成本,立即支持新版本的上游依赖项,才能推送安全修复程序。
安装 Python
既然容器中已经安装了操作系统,现在是时候展示重头戏了:Python 解释器。 运行 Python 应用程序需要解释器和标准库。 容器需要以某种方式包含它们。
一些第三方存储库正在打包 Python,以作为操作系统软件包在发行版中使用。 最著名的是 Ubuntu 的 deadsnakes
,它预编译了 Python 软件包。 这是一个受欢迎的选择。 这意味着等待正确的版本出现在存储库中,但这通常不会有太多的延迟。
另一种选择是使用 pyenv
。 如果单个开发 Python 容器镜像需要具有多个版本的 Python,这尤其有用。 您可以通过仔细复制从中构建运行时版本,并且它允许一些需要在构建时使用多个 Python 版本的工作流程。 即使不需要多个版本的 Python,pyenv
也可以是一个受欢迎的选择。 这是一个值得信赖的工具,可以在容器内构建 Python。
Python 构建
获得 pyenv
的最大好处的一种方法,而不需要容器中不太有用的一些开销(例如 shims 和切换版本的能力),是使用 python-build
。 这是 pyenv
内部构建 Python 的引擎。 直接使用它不仅可以跳过冗余,还可以更精细地配置构建细节。 这些在 pyenv
中是可能的,但需要传递给 python-build
使它们更加笨拙,尤其是在有很多的情况下。
最后,或者也许最初,可以像以前的人们那样做。 configure/make/make install
流程有效,并消除了开发人员和构建之间的任何障碍。 您可以设置和调整任何构建参数。 主要缺点是需要安全地获取源代码的 tarball 并避免供应链攻击。
RUN configure [...]
RUN make
RUN make install
在选择此项时,存在固有的权衡
- 本地构建对结果的控制程度
- 实现的难易程度
- 潜在的问题
最终,每个团队都必须自己决定哪些权衡是正确的。
通常最好构建几个版本的“基本级别” Python 容器,以便允许依赖容器在不同的时间迁移到新版本。 做到这一点至少需要两个。 虽然可能超过三个,但实际上通常没有必要。 Python 每年发布一次,因此三个版本给您两年的时间升级到新的、大部分向后兼容的 Python 版本。
如果一个团队在两年内没有空闲时间,那么问题不是 Python 版本。 实际上,这意味着选择支持两个或三个 Python 版本。
以阶段思考
容器以多个阶段构建。 默认情况下,仅输出一个阶段——最后一个阶段。 您可以通过在命令行上选择它来输出不同的阶段。
其他阶段可以通过两种不同的方式帮助该阶段进行构建。 一种方法是在新阶段的 FROM
命令中使用之前的阶段。 这与 FROM
外部镜像相同:它从之前的镜像开始,并将后续步骤作为附加层运行。
使用非输出阶段的另一种方法是从中 COPY
文件。 这类似于从 Docker 构建上下文中 COPY
,但它使用的是之前的阶段,而不是构建上下文。 COPY
的语义(就递归、文件和目录而言)保持不变。
FROM <stage>
技术允许您在 Docker 构建文件中将阶段用作“通用模块”。 如果两个镜像需要几个共同的初始步骤,您可以将这些步骤添加到内部“基础”阶段,然后两个镜像都将其用作起点。
缺点是通用模块(及其所有依赖项)必须在同一个文件中。 总的来说,尽管令人不愉快,但项目应该将其 Docker 逻辑保存在一个文件中,而不是将其拆分为多个文件。
FROM ubuntu as security-updates
RUN add-apt-repository ppa:deadsnakes/ppa
RUN apt-get update
RUN apt-get upgrade
FROM security-updates as with-38
RUN apt-get install python3.8
FROM security-updates as with-39
RUN apt-get install python3.9
阶段最重要的好处之一是它们允许分离构建和运行时依赖项。 构建时依赖项安装在一个阶段,执行构建逻辑,并将构建工件复制到下一个阶段,该阶段从一个干净的镜像开始,没有任何构建依赖项。
FROM ubuntu as builder
# install build dependencies
# build Python into /opt/myorg/python
FROM ubuntu as as runtime
COPY --from=builder \
/opt/myorg/python \
/opt/myorg/python
特别是对于运行时镜像,减少层数是有好处的。 实现这一目标的一种方法是使用几个命令和文件操作来“准备”像 /opt/myorg
这样的目录。
您可以在基础之上仅使用一个附加层来完成下一个阶段
COPY --from=prep-stage /opt/myorg/ /opt/myorg
如果您在本地构建 Python,请删除(在运行时镜像中)您不需要的大件东西——静态库、测试、各种临时构建工件等等。 通常,您可以在准备阶段执行此操作,并将最小化的 Python 构建输出复制到下一个阶段。
在应用程序中使用
有时应用程序有一些用本地代码编写的部分。 更常见的情况是,应用程序需要带有本地代码的第三方依赖项。 如果需要在本地构建这些依赖项,则应在与运行时不同的单独阶段构建它们。
一种流行的技术是构建所有依赖项,然后将它们复制到您安装在虚拟环境中的运行时镜像中。
- 使用构建器构建
- 复制到运行时
- 安装在虚拟环境中
或者,您可以通过将运行时镜像安装到虚拟环境中,然后将虚拟环境作为一个大的目录复制过来,从而使运行时镜像更小。 这确实需要精确匹配 Python 版本,因此,这取决于您如何创建基本系统。
如果需要构建 wheels,有时使它们成为自包含的是有帮助的。 为此,您需要一些依赖项。
patchelf
命令是一个用于操作可执行和可链接格式 (ELF) 文件的工具,特别是共享库。 我发现通常最好从最新的源代码编译 patchelf
,以确保您拥有所有最新的功能。
patchelf
命令提供了低级部分。 安装它并非易事,但确实需要一些包装。 使 wheels 自包含的工具是 auditwheel
。 幸运的是,一旦 patchelf
安装正确,只要您正确配置 Python 和 pip
,就可以完成 auditwheel
。 您可以使用 auditwheel
创建自包含的二进制 wheels。 这样的二进制 wheels 已将所有二进制依赖项直接修补到其中。 这要求您在运行时镜像中安装库的“运行时”版本。
这减少了层数和复杂性,但确实需要在运行时镜像和开发镜像之间具有高度的保真度。
$ auditwheel repair --platform linux_x86_64
对这种程度保真度的需求可能是一个不方便的要求。 此外,如果可以一次构建 wheels,而不是在每次 docker build
上都构建,那就太好了。 如果您有内部包索引(如 devpi
或任何商业替代方案),您可以安排此操作。
可移植 wheels
要构建可移植的二进制 wheels,请确定您需要支持的最旧的 GNU C 库 (glibc) 是什么。 在该平台上构建 wheel 后,使用带有可移植标签的 auditwheel
创建可上传的 wheel。
您只能在兼容的系统上使用此 wheel,并且您可以上传多个 wheel。
无论二进制 wheel 的最终目标是什么,您都需要以某种方式构建它。 实际的构建很简单:python -m build
。 问题是之前要做什么。 对于某些 wheels,这已经足够了。
对于其他 wheels,只需几个 apt
或 dnf
安装 -dev
库即可。 对于还有一些,构建它们需要安装 Fortran 或 Rust 工具链。
有些需要安装 Java,然后获取用 Java 编写的自定义构建工具。 不幸的是,这不是一个玩笑。
希望说明在软件包的文档中。 至少将说明编码在容器构建文件中是具体的、计算机可读的并且可重复的,无论将文档翻译成这些说明需要多长时间。
运行时镜像
现在 Python 和 PyPI 软件包都已准备就绪,您必须将它们复制到运行时镜像。 减少层数的一种方法是减少复制说明。 在开发镜像中正确准备目录比将零散的东西复制到运行时镜像更好。 仔细考虑缓存。 尽可能早地放置耗时的步骤。 尽可能晚地从上下文中复制文件。 这意味着如果只需要其中一些文件,则分别复制文件。
本地 Python 源代码变化最快。 最后复制它们。 如果做得对,瓶颈通常是最后复制到运行时镜像。 加速事情的一种方法是使开发镜像可用作本地调试的运行时镜像。
最后的想法
在为 Python 应用程序构建容器时,有很多因素需要考虑。 虽然没有客观上正确的答案,但有很多客观上错误的答案。 犯错的方式多于正确的方式,因此粗心大意地做事可能会导致遗憾。
值得考虑这些事情并制定计划。 您花费在计划和思考上的时间可以获得多次回报,因为它提供了高质量的镜像,这些镜像更易于构建、运行和审核。
容器构建文件有时是事后的想法,在“代码完成”后随意完成。 这可能会伤害您。 在实施容器构建之前花时间思考。
了解更多
我只是触及了您需要了解的内容的表面。 Itamar Turner-Trauring 撰写了一系列文章,更深入地探讨了这些问题中的许多内容。
评论已关闭。