使用Docker部署由Poetry管理的Python项目的最佳实践

使用Docker部署使用Poetry管理的Python项目的最佳实践

在为项目构建python镜像的时候,我们需要确保下载的依赖是确定的、无冲突的和可复现的。Poetry可以很好的实现这个需求。然而,如果你没有充足的经验,在Docker中使用Poetry可能导致性能不佳、构建时间过长等问题,这会影响我们的开发效率。

这篇文章的受众是那些熟悉Docker和Poerty,尤其是了解了Docker的层级缓存的工作原理,并正在寻中使用Docker构建和运行项目的最佳实践的人。

0. 项目结构

我们的Poetry示例项目名为annapurna,项目目录中包含pyproject.tomlpoetry.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.mdPoetry会警告(我不是很赞成这种设计),因此我创建了一个空文件来避免这个警告。

  • 使用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多阶段构造,使你用于运行的镜像尽可能精简。

注:将翻译的文章标记为原创的原因是,此网站不给翻译的文章流量,译者觉得这篇文章很有价值,因此标记为原创分享给更多人。原文地址

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值