个人译作:使用alpine版本的Python Docker镜像会使构建速度慢50倍

李迟按:原文地址在这里 。笔者根据自己的理解进行概译,即不按字面逐句翻译。无甚目的,纯粹试一下个人英文还行不行。

当我们选择 Docker 镜像时,一般会建议使用 Alpine 版本。使用该版本可以使镜像体积更小,构建速度也会更快。如果使用 Go 镜像,这是没问题的。

但是,当使用 Python 镜像时,如果选择 Alpine 版本,会遇到下面的问题:
1、构建速度变慢。
2、镜像体积变大。
3、浪费时间。
4、有时会带来一些莫名其妙的运行时bug ( runtime bug)。

下面我们来看看为什么 Alpine 版本虽然被推荐但却不适合于 Python 应用。

为何推荐 Alpine

假设我们在构建镜像时需要安装 gcc,下面来看看 Alpine 版本镜像和 Ubuntu 18.04 版本在构建耗时和镜像体积两方面的比对。

首先,拉取两者的镜像并查看体积:

1
2
3
4
5
6
7
8
9
10
$ docker pull --quiet ubuntu:18.04
docker.io/library/ubuntu:18.04
$ docker pull --quiet alpine
docker.io/library/alpine:latest
$ docker image ls ubuntu:18.04
REPOSITORY TAG IMAGE ID SIZE
ubuntu 18.04 ccc6e87d482b 64.2MB
$ docker image ls alpine
REPOSITORY TAG IMAGE ID SIZE
alpine latest e7d92cdc71fe 5.59MB

可以看到,两个基础镜像中,Alpine 的体积要小得很多。

接着,我们在两个镜像中安装 gcc 。先在 Ubuntu 中安装:

1
2
3
4
FROM ubuntu:18.04
RUN apt-get update && \
apt-get install --no-install-recommends -y gcc && \
apt-get clean && rm -rf /var/lib/apt/lists/*

注:Dockerfile 已经超出本文讨论范围,本文的 Dockerfile 并非最佳实践,如果涉及的话,会添加本文的复杂度。如果要在生产环境中使用 Python,可参考如下两种方法:

  1. DIY制作:详细讲解,附带例子和参考资料
  2. 使用 ASAP:最佳实践的模板

下面构建并查看耗时和镜像体积:

1
2
3
4
5
6
7
8
9
$ time docker build -t ubuntu-gcc -f Dockerfile.ubuntu --quiet .
sha256:b6a3ee33acb83148cd273b0098f4c7eed01a82f47eeb8f5bec775c26d4fe4aae

real 0m29.251s
user 0m0.032s
sys 0m0.026s
$ docker image ls ubuntu-gcc
REPOSITORY TAG IMAGE ID CREATED SIZE
ubuntu-gcc latest b6a3ee33acb8 9 seconds ago 150MB

现在看看相同操作的 Apline 的 Dockerfile:

1
2
FROM alpine
RUN apk add --update gcc

同样构建并查看耗时和镜像体积:

1
2
3
4
5
6
7
8
9
$ time docker build -t alpine-gcc -f Dockerfile.alpine --quiet .
sha256:efd626923c1478ccde67db28911ef90799710e5b8125cf4ebb2b2ca200ae1ac3

real 0m15.461s
user 0m0.026s
sys 0m0.024s
$ docker image ls alpine-gcc
REPOSITORY TAG IMAGE ID CREATED SIZE
alpine-gcc latest efd626923c14 7 seconds ago 105MB

对比两者的结构,可以看到,Alpine 版本构建速度更快,体积更小。相对于 Ubuntu的 30 秒,Alpine 只用 15 秒,体积方面,Ubuntu 为150MB,而 Alpine 只有 105MB。

构建 Python 镜像

我们需要在 Python 应用程序中使用pandasmatplotlib两个库。可以选择基于 Debian 的官方 Python 镜像(已提前拉取镜像),下面是 Dockerfile:

1
2
FROM python:3.8-slim
RUN pip install --no-cache-dir matplotlib pandas

构建镜像:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
$ docker build -f Dockerfile.slim -t python-matpan.
Sending build context to Docker daemon 3.072kB
Step 1/2 : FROM python:3.8-slim
---> 036ea1506a85
Step 2/2 : RUN pip install --no-cache-dir matplotlib pandas
---> Running in 13739b2a0917
Collecting matplotlib
Downloading matplotlib-3.1.2-cp38-cp38-manylinux1_x86_64.whl (13.1 MB)
Collecting pandas
Downloading pandas-0.25.3-cp38-cp38-manylinux1_x86_64.whl (10.4 MB)
...
Successfully built b98b5dc06690
Successfully tagged python-matpan:latest

real 0m30.297s
user 0m0.043s
sys 0m0.020s

镜像体积为 363MB。

Alpine 版本是否更好?我们试一下:

1
2
FROM python:3.8-alpine
RUN pip install --no-cache-dir matplotlib pandas

构建镜像:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
$ docker build -t python-matpan-alpine -f Dockerfile.alpine .                                 
Sending build context to Docker daemon 3.072kB
Step 1/2 : FROM python:3.8-alpine
---> a0ee0c90a0db
Step 2/2 : RUN pip install --no-cache-dir matplotlib pandas
---> Running in 6740adad3729
Collecting matplotlib
Downloading matplotlib-3.1.2.tar.gz (40.9 MB)
ERROR: Command errored out with exit status 1:
command: /usr/local/bin/python -c 'import sys, setuptools, tokenize; sys.argv[0] = '"'"'/
tmp/pip-install-a3olrixa/matplotlib/setup.py'"'"'; __file__='"'"'/tmp/pip-install-a3olrixa/matplotlib/setup.py'"'"';f=getattr(tokenize, '"'"'open'"'"', open)(__file__);code=f.read().replace('"'"'\r\n'"'"', '"'"'\n'"'"');f.close();exec(compile(code, __file__, '"'"'exec'"'"'))' egg_info --egg-base /tmp/pip-install-a3olrixa/matplotlib/pip-egg-info

...
ERROR: Command errored out with exit status 1: python setup.py egg_info Check the logs for full command output.
The command '/bin/sh -c pip install matplotlib pandas' returned a non-zero code: 1

出错了,到底发生了什么?

标准PyPI wheel 无法在 Alpine 上工作

如果观察上面用基于 Debian 镜像构建的信息,可以发现下载的文件为matplotlib-3.1.2-cp38-cp38-manylinux1_x86_64.whl。这是预编译的二进制文件。但是,在 Alpine 上,即是下载源码文件(matplotlib-3.1.2.tar.gz),因为标准的 Linux wheel 不支持 Alpine Linux。

为何?大多数 Linux 发行版本使用 GNU 版本 C 库(即 glibc),可适用于大部分 C 程序,包括 Python。但 Alpine Linux 使用的是 musl(译注:musl 是一个轻量级的C标准库),那些 wheel 使用的是 glibc,所以 Alpine Linux 不支持。
当前大部分 Python 包在 PyPI 上都有二进制 wheel,可节省安装时间。但是如果使用 Alpine Linux,则需要编译安装包的 C 代码。
这也意味着需要自行确认每个包的依赖包。在上述例子中,经搜索已经确认了依赖包,下面是修改后的 Dockerfile:

1
2
3
FROM python:3.8-alpine
RUN apk --update add gcc build-base freetype-dev libpng-dev openblas-dev
RUN pip install --no-cache-dir matplotlib pandas

接着构建,竟花了 25 分 57 秒!镜像体积为 851 MB。

下面是两个镜像的对比表:

1
2
3
Base image	        Time to build	Image size	Research required
python:3.8-slim 30 seconds 363MB No
python:3.8-alpine 1557 seconds 851MB Yes

可以看到,alipine 版本构建更慢,而且体积更大。于是笔者做了大量的研究。

能否解决这些问题?

构建时长

为了加快构建的时间,下一个稳定版本 Alpine Edge 集成了 matplotlib 和 pandas。安装系统包非常快。但是,到2020年1月为止,当前稳定的发布版本还不包括这些流行的软件包。

即使它们是能用,PyPI 上的系统包也经常滞后(更新不快),因此,Alpine 不太可能打包 PyPI 上所有的东西。实际上,笔者认识的大多数 Python 团队都不使用系统包来实现 Python 依赖性,而且使用 PyPI 或 Conda Forge。

镜像大小

有的人可能会说,可以删除原始安装好的软件包,或者不缓存软件包,或者使用多阶段构建 。有人尝试了,但产生了470MB的镜像

因此,我们可以得到一个与 slim 版本体积大致相同的镜像,但是 Alpine Linux本来的动机就是更小的镜像以及更快的构建。如果做足了工作,可能会得到一个更小的镜像,但还是要忍受 1500 秒的构建时间,而使用 python:3.8-slim 只需要30秒的构建时间。

而且,还会有其它问题。

Alpine Linux 可能引发未知的运行时bug

虽然理论上Alpine使用的muslc库与其他Linux发行版使用的glibc基本兼容,但在实践中,这种差异可能会导致问题。当问题真的发生的时候,它们将是奇怪的和出乎意料的。

一些例子:

Alpine的线程默认堆栈大小较小,这可能导致Python崩溃。

一位Alpine用户发现,由于musl与glibc分配内存的方式不同,他们的Python应用程序要慢得多。

我曾经在使用WeWork协同工作空间的WiFi时,无法在minikube(虚拟机中的Kubernetes)上运行的Alpine图像中进行DNS查找。原因是WeWork的DNS设置不好,Kubernetes和minikube做DNS的方式,musl对这个边缘案例的处理与glibc的处理相结合。musl没有错(它与RFC匹配),但我不得不浪费时间找出问题所在,然后切换到基于glibc的图像。

另一个用户发现了时间格式化和解析的问题。

这些问题中的大部分或可能全部已经解决,但毫无疑问还有更多的问题需要发现。这类随机破损只是另一个需要担心的问题。

不要使用 Alpine 版本的 Python 镜像

除非您希望构建速度大大减慢、图像更大、工作量更大,并且有可能出现模糊的bug,否则您应该避免将Alpine Linux作为基础图像。有关您应该使用什么的一些建议,请参阅我关于选择一个好的基本映像的文章。