使用Docker部署使用Poetry管理的Python项目的最佳实践
在为项目构建python镜像的时候,我们需要确保下载的依赖是确定的、无冲突的和可复现的。Poetry可以很好的实现这个需求。然而,如果你没有充足的经验,在Docker中使用Poetry可能导致性能不佳、构建时间过长等问题,这会影响我们的开发效率。
这篇文章的受众是那些熟悉Docker和Poerty,尤其是了解了Docker的层级缓存的工作原理,并正在寻中使用Docker构建和运行项目的最佳实践的人。
0. 项目结构
我们的Poetry示例项目名为annapurna
,项目目录中包含pyproject.toml
、poetry.lock
、你的代码和Dockerfile
。(这是Poetry管理的项目中的基本内容,如果你不了解请先学习Petry【译者】)
.
├── Dockerfile
├── README.md
├── annapurna
│ ├── __init__.py
│ └── main.py
├── poetry.lock
└── pyproject.toml
为了简单,示例中只通过poetry add fastapi
下载了 fastapi
(著名的python web 框架)和一些代码检查工具(linter)。
[tool.poetry]
name = "annapurna"
version = "1.0.0"
description = ""
authors = ["Riccardo Albertazzi <my@email.com>"]
readme = "README.md"
[tool.poetry.dependencies]
python = "^3.11"
fastapi = "^0.95.1"
[tool.poetry.group.dev.dependencies]
black = "^23.3.0"
mypy = "^1.2.0"
ruff = "^0.0.263"
[build-system]
requires = ["poetry-core"]
build-backend = "poetry.core.masonry.api"
1. 幼稚的做法
我们使用的镜像要有python环境、要下载Poetry、要把我们的代码复制进去、下载依赖和设置项目入口,最简单的做法如下:
FROM python:3.11-buster
RUN pip install poetry
COPY . .
RUN poetry install
ENTRYPOINT ["poetry", "run", "python", "-m", "annapurna.main"]
你经常在一些项目和教程中看到这样的Dockerfile,非常简单,也容易理解,通过docker build .
就能构建起来。但是随着你项目的发展,它会导致你的构建过程变得复杂和缓慢,你的镜像也会非常大(这里我最终构建的镜像高达1.1GB!)接下来我们将会看到我们如何逐步优化,充分利用缓存加速构建过程和以及减少镜像大小的方法。
2. 热身
我们先来做一些小提升作为热身。
-
固定
poetry
的版本。我建议固定poetry的版本,poetry即使是一个小版本的更新也可能包含破坏性的更改,这可能会导致版本更新后的构建失败。 -
只COPY你需要的代码和数据。避免你把本地的虚拟环境也copy进去,比如(.venv),如果文件中没有
README.md
Poetry会警告(我不是很赞成这种设计),因此我创建了一个空文件来避免这个警告。 -
使用
poetry install --without dev
可指定不安装dev
分组的依赖。在生产环境中我们不需要这些代码检查工具。(这也提示我们要分类管理依赖,把项目依赖和开发依赖分开管理,这也是我们使用poetry的原因之一,通过--without dev
可以跳过安装dev
这组依赖【译者】)
FROM python:3.11-buster
RUN pip install poetry==1.4.2
WORKDIR /app
COPY pyproject.toml poetry.lock ./
COPY annapurna ./annapurna
RUN touch README.md
RUN poetry install --without dev
ENTRYPOINT ["poetry", "run", "python", "-m", "annapurna.main"]
做完这些我们的镜像从1.1GB减小到959MB。这还远远不够,我们当前只是做了一些保守的工作。
3. 清理Poetry缓存
默认情况下,Poetry会缓存下载过的包以便于以后使用。然而在Docker中我们肯定不需要这样的缓存,我们可以以禁止这种行为。
- Poetry支持
--no-cache
选项,但是我可能不会使用它,后面会说明原因(为了利用另一种优化方式【译者】)。 - 确保清理工作是在一个
RUN
语句中完成的,如果在不同的RUN
语句中,缓存仍然会存在于之前的Docker层(包含poetry install
语句的那个层)中。(层指的是docker layer【译者】)
与此同时,我还设置了一些Poetry环境变量,进一步增强构建过程的确定性。其中最有争议的是POETRY_VIRTUALENVS_CREATE=1
。为什么要在dockers容器中创建虚环境?因为这样做可以保证我的环境尽可能的隔离,最重要的是,我的安装不会干扰到系统中的Python或者是Poetry本身(这里指的是,创建隔离环境,避免项目中依赖的库影响到系统的Python,或者是和Poetry冲突的情况出现【译者】)
FROM python:3.11-buster
RUN pip install poetry==1.4.2
ENV POETRY_NO_INTERACTION=1 \
POETRY_VIRTUALENVS_IN_PROJECT=1 \
POETRY_VIRTUALENVS_CREATE=1 \
POETRY_CACHE_DIR=/tmp/poetry_cache
WORKDIR /app
COPY pyproject.toml poetry.lock ./
COPY annapurna ./annapurna
RUN touch README.md
RUN poetry install --without dev && rm -rf $POETRY_CACHE_DIR
ENTRYPOINT ["poetry", "run", "python", "-m", "annapurna.main"]
4. 在拷贝代码之前完成依赖下载
目前为止我们已经做了很多了,但是还有一个问题,每次当我们修改我们代码的时候,我们就需要重新下载依赖。这是因为COPY
代码的操作发生在RUN poetry install
之前,但我们之前不得不这么做,因为执行poetry install
会默认install我们写代码的的项目文件夹。这会破坏Docker的层级缓存,导致每次COPY
层失效后,后面的层全部都需要重建。只是对代码做了一点点更改,需要重新下载依赖做很多无意义且耗时的操作。
解决的办法是,我们先向Poetry提供构建环境所需要的最小信息,然后再复制我们的代码。我们可以通过--no-root
来实现这一点,这个选项告诉Poetry不要将安装我们的写代码项目目录到虚拟环境中。(造成这个问题的原因是每次运行poetry install
,poetry在install完所有你需要的依赖之后,也install了你写代码的项目目录,这是它的默认行为,但是这个默认行为破坏了Docker的缓存,因此我们要阻止它,具体参考链接【译者】)
FROM python:3.11-buster
RUN pip install poetry==1.4.2
ENV POETRY_NO_INTERACTION=1 \
POETRY_VIRTUALENVS_IN_PROJECT=1 \
POETRY_VIRTUALENVS_CREATE=1 \
POETRY_CACHE_DIR=/tmp/poetry_cache
WORKDIR /app
COPY pyproject.toml poetry.lock ./
RUN touch README.md
RUN poetry install --without dev --no-root && rm -rf $POETRY_CACHE_DIR
COPY annapurna ./annapurna
RUN poetry install --without dev
ENTRYPOINT ["poetry", "run", "python", "-m", "annapurna.main"]
现在你再尝试修改项目代码并重新构建,你可以看到只有最后三层被重新构建了,速度非常快!
- 额外的
RUN poetry install --without dev
指令用于在虚拟环境中安装你的项目目录。这是个很用于的例子,如果你需要安装一些自定义脚本的话。需要不需要这一行取决于你的项目。但是无论如何,执行这一步之前你的项目依赖就已经安装好了,这一步的运行速度也会非常快。
5. 使用Docker多阶段构建
到目前为止,我们的构建速度已经非常快了,但是我们最终的镜像还是很大。我们可以尝试使用多阶段构建来解决。优化的关键在于对于特定的任务阶段选择正确的基础镜像来完成:
- Python
buster
是一个比较大的镜像,因为它包含很多开发依赖,因此我们将使用它来安装虚拟环境。 - Python
slim-buster
是一个更小的镜像,这个镜像中只有足以运行Python的最小镜像,我们将会用它来运行我们的应用。
得益于多阶段构建,我们可以把一个阶段的信息传递到另一个阶段,特别是正在构建的虚拟环境:
- Poetry不必安装在用于程序运行的阶段。事实上当虚拟环境构建完成,Poetry对于你的Python应用程序来说就编程了不必要的依赖,我们只需要操作环境变量(如VIRTUAL_ENV )就能让Python识别正确的虚拟环境。
- 为了简化,我移除了第二个安装步骤(
RUN poetry install --without dev
),我们示例项目也不需要它(不用让poetry把你写代码的项目目录安装进虚拟环境中,我们在下载完依赖之后虚拟环境算是构建好了,写代码的项目目录时被COPY
到镜像中去的【译者】)。不过,如果需要的话,仍然可以在运行镜像的时候用一条指令来添加它:RUN pip install poetry && poetry install --without dev && pip uninstall poetry
。
当Dockerfile变得复杂的时候,我建议使用Buildkit,这是嵌入Docker CLI的构建套件。它能帮助你快速和安全的构建。
DOCKER_BUILDKIT=1 docker build --target=runtime .
# The builder image, used to build the virtual environment
FROM python:3.11-buster as builder
RUN pip install poetry==1.4.2
ENV POETRY_NO_INTERACTION=1 \
POETRY_VIRTUALENVS_IN_PROJECT=1 \
POETRY_VIRTUALENVS_CREATE=1 \
POETRY_CACHE_DIR=/tmp/poetry_cache
WORKDIR /app
COPY pyproject.toml poetry.lock ./
RUN touch README.md
RUN poetry install --without dev --no-root && rm -rf $POETRY_CACHE_DIR
# The runtime image, used to just run the code provided its virtual environment
FROM python:3.11-slim-buster as runtime
ENV VIRTUAL_ENV=/app/.venv \
PATH="/app/.venv/bin:$PATH"
COPY --from=builder ${VIRTUAL_ENV} ${VIRTUAL_ENV}
COPY annapurna ./annapurna
ENTRYPOINT ["python", "-m", "annapurna.main"]
完成这些优化,我们的运行镜像缩小了整整6倍!从1.1GB到170MB。
6.Buildkit缓存挂载
我们减小了镜像大小,让只更改代码时的构建速度飞快,我们还能做什么?我们还可以在依赖改变让构建速度也很快。
这个技巧并不广为人知,它相较于之前介绍的那些功能来说比较新颖。它利用了Buildkit缓存挂载功能,Buildkit挂载并管理一个用于缓存的文件夹,有趣的时这个缓存会在多次构建之间持续存在。
通过利用此功能和Poetry的缓存相结合(现在你明白为什么我想要保留缓存了吧?),我们基本上获得了一个每次构建项目时都可以重用的依赖缓存。这样在相同的环境中我们多次构建相同的镜像,构建依赖的过程也能很快。
注意在安装完依赖之后Poetry缓存不会被清理,不然无法在构建过程中存储和重用缓存。但无所谓,Buildkit不会持久管理构建镜像中的缓存(此外这这是我们用于构建的镜像,不是我们程序真正运行的镜像)
FROM python:3.11-buster as builder
RUN pip install poetry==1.4.2
ENV POETRY_NO_INTERACTION=1 \
POETRY_VIRTUALENVS_IN_PROJECT=1 \
POETRY_VIRTUALENVS_CREATE=1 \
POETRY_CACHE_DIR=/tmp/poetry_cache
WORKDIR /app
COPY pyproject.toml poetry.lock ./
RUN touch README.md
RUN --mount=type=cache,target=$POETRY_CACHE_DIR poetry install --without dev --no-root
FROM python:3.11-slim-buster as runtime
ENV VIRTUAL_ENV=/app/.venv \
PATH="/app/.venv/bin:$PATH"
COPY --from=builder ${VIRTUAL_ENV} ${VIRTUAL_ENV}
COPY annapurna ./annapurna
ENTRYPOINT ["python", "-m", "annapurna.main"]
这种做法的缺点是什么?目前,缓存挂载对CI并不友好,因为Buildkit不允许你控制缓存的存储位置。不出意料,这是BUildkit仓库中最受关注的GitHub问题。
总结
我们学到了如何将一个简单但是糟糕的Dockefile,优化为一个高效的版本。所有的这些优化总结成下面这几条最佳实践:
- 让层尽量“小”,减少你在其中的安装和COPY。
- 利用Docker层级缓存,减少缓存失败的可能。
- 重构慢的部分(如项目依赖)应该在重构快(如项目代码)的部分之前。
- 尽量使用Dockers多阶段构造,使你用于运行的镜像尽可能精简。
注:将翻译的文章标记为原创的原因是,此网站不给翻译的文章流量,译者觉得这篇文章很有价值,因此标记为原创分享给更多人。原文地址