По мотивам моего доклада на PyCon "Контейнеризация Python без боли". На своей практике я постоянно сталкиваюсь со спорами какой базовый образ лучше использовать для проектов: alpine или debian. Аргументы есть и у той, и у другой стороны, но мне это настолько надоело, что я решил сам разобраться и наконец-то поставить точку. В конце концов "В наше время верить нельзя никому, даже себе. Но мне - можно." (с)

Интерфейс dive
Интерфейс dive

Сравниваем базовые образы alpine и debian

Перед тем, как мы перейдём к специфике запуска python-проектов под alpine, давайте посмотрим на код базовых образов и сравним что они нам предлагают.

Debian:

FROM scratch
ADD rootfs.tar.xz /
CMD ["bash"]

Alpine

FROM scratch
ADD alpine-minirootfs-3.17.0-x86_64.tar.gz /
CMD ["/bin/sh"]

Т.е. что alpine, что debian, состоят по сути из одного слоя куда распаковывается файловая система. Давайте заглянем что же там находится. Кстати, alpine-minirootfs-3.17.0-x86_64.tar.gz весит всего 3 Mb, а rootfs.tar.xz аж 31 Mb. В распакованном виде 6.7 Mb и 122 Mb соответственно. Внушительная разница, не правда ли? За счёт чего? Сравним 2 каталога:

Сравниваю каталоги как могу
Сравниваю каталоги как могу

Разница примерно везде. Но бросается в глаза размер каталога usr - аж 100 Mb! Заглянем в него (я игнорирую все каталоги меньше 100k иначе список получится очень большой):

➜  /opt/debain/usr du -h -d 2 -t 100k .
14M   ./bin
532K  ./share/info
31M   ./share/locale
168K  ./share/keyrings
128K  ./share/gcc
5,3M  ./share/man
13M   ./share/doc
264K  ./share/common-licenses
536K  ./share/perl5
344K  ./share/bash-completion
124K  ./share/lintian
5,0M  ./share/zoneinfo
56M   ./share
1,9M  ./lib/locale
1,1M  ./lib/apt
36M   ./lib/x86_64-linux-gnu
39M   ./lib
2,2M  ./sbin
111M  .

Т.е. погодите - 31 Mb под локали? 13 Mb - под документацию? там ещё gconv на 7.6 Mb? Разработчики образа debian, вы вообще там место не считаете?! Как часто программистам нужен man внутри контейнера? Лично мне примерно никогда. Аналогично в /bin и /sbin есть утилиты, которые пригождаются крайне редко (при работе контейнера): lsblk, df, debugfs, swapon/swapoff... Выглядит как мусор, но кто я такой, чтобы спорить с авторами настолько популярного базового образа. В любом случае, если основательно почистить образ, то можно ужать если не до 7 Mb, то хотя бы в 2 раза - до 50 Mb. Но всё это, как не странно, мелочи. Основное отличие alpine от debian в бинарниках. Если сравнить каталог /bin, то в debian лежат полноценные бинарники, в то время как у alpine ссылки на /bin/busybox. И тут мы переходим к вопросу "а что же такое дистрибутив alpine linux?".

Ремарка про alpine

"Small. Simple. Secure. Alpine Linux is a security-oriented, lightweight Linux distribution based on musl libc and busybox." - цитата с официального сайта, которая целиком и полностью описывает суть дистрибутива. Проект, кстати, достаточно старый - выпускается с 2005 года, и изначально делал упор на нетребовательность к ресурсам и отсекании всего лишнего. Т.е. это был обычный дистрибутив linux но без systemd (заменили на openrc), с загрузчиком extlinux, собственным пакетным менеджером apk и busybox как замена GNU coreutils. Собственно, поэтому все утилиты в /bin и ссылаются на один и тот же файл /bin/busybox.

Но самая интересная замена - GNU libc на более легковесную musl libc. К glibc были некоторые вопросы насчёт переусложнённости и расширений. Например, isalnum() мог кинуть сегфолт. Так что alpine хорошо должен подойти под всякий embedded (поправьте, если не так). Как вы понимаете, бинарно они не совместимы, и отсюда столько боли.

Сравниваем в полевых условиях

Но вернёмся к python. Спулим 2 образа, и разница уже не столь существенна, правда?

➜  /opt docker images
REPOSITORY            TAG               IMAGE ID       CREATED         SIZE
python                3.10-slim         dae00c0316e5   12 hours ago    126MB
python                3.10-alpine       2527f31628e7   13 days ago     50.1MB

Давайте соберём небольшое django-приложение. Зависимости взяты как пример из моего пет-проекта:

[tool.poetry.dependencies]
python = "^3.10"
Django = "~3.2"
django-elasticsearch-dsl = "^7.2.0"
django-enumfields = "^2.1.1"
djangorestframework = "^3.12.4"
django-elasticsearch-dsl-drf = "^0.22.1"
django-filter = "^2.4.0"
django-cors-headers = "^3.7.0"
drf-nested-routers = "^0.93.4"
gunicorn = "^20.1.0"

И получилось:

➜  /opt docker images
REPOSITORY              TAG               IMAGE ID       CREATED         SIZE
django                  debian            3e9fef9d8b54   2 seconds ago   201MB
django                  alpine            2f27ca4a1588   16 seconds ago  125MB
python                  3.10-slim         dae00c0316e5   12 hours ago    126MB
python                  3.10-alpine       2527f31628e7   13 days ago     50.1MB

Разница всё та же в 70Mb - предсказуемо. И если экстраполировать на какой-нибудь реальный проект, в котором docker-образ будет весить ~600Mb, то так ли важны эти 70Mb?

Ok, с простым django-приложением более-менее всё понятно. Так что я возьму другой свой пет-проект на FastAPI со следующими зависимостями:

[tool.poetry.dependencies]
python = "^3.8"
pycairo = "^1.19.1"
fastapi = "^0.54.1"
uvicorn = "^0.11.3"
aiofiles = "^0.5.0"
SQLAlchemy = {extras = ["asyncio"], version = "^1.4.29"}
alembic = "^1.7.5"
asyncpg = "^0.25.0"
elasticsearch = {extras = ["async"], version = "^7.16.2"}

Заменю в Dockerfile python:3.8-slim на python:3.8-alpine ииии...

Просьба поставить компилятор
Просьба поставить компилятор

Ладно, поставлю gcc, хотя под debian никакой компиляции не требовалось...

И заголовочные файлы к нему
И заголовочные файлы к нему

ok, сам дурак - заголовочные файлы забыл. К слову установку я обернул в

RUN apk add g++ musl-dev --virtual dev \
  && poetry config virtualenvs.create false \
  ...
  && apk del dev

чтобы как можно честнее подсчитывать место. Но после этого меня ждало:

Непонятная страшная ошибка
Непонятная страшная ошибка

`Something went wrong bootstrapping makefile fragments` вселил в меня ужас. Ковыряться в Makefile мне совсем не улыбалось, а uvicorn больше опциональная зависимость, которую для тестов можно опустить. Так что я решил просто исключить его. Образ собрался, но осадочек-то остался... Кстати, из-за всех этих скачиваний, установок и компиляций образ собирался почти в 2 раза медленнее, чем под python:3.8-slim. Оно и логично, но ради чего? Вернёмся к этому позже. Сейчас проверим размеры собраных образов с одинаковыми зависимостями.

➜  /opt docker images
REPOSITORY                TAG               IMAGE ID       CREATED         SIZE
fastapi                   debian            b939da63315f   14 minutes ago  244MB
fastapi                   alpine            6f210c82554e   14 minutes ago  111MB

Разница больше, чем в 2 раза! Серьёзно? Чувствую в этом какой-то подвох, так что посмотрим что действительно содержится в образе (прошу прощения за стаю шакалов):

Склеиваю картинки как могу
Склеиваю картинки как могу

Основное различие в библиотеке pydantic. Разница в 2 порядка - это уже перебор, здесь точно что-то не так! Идём в каталог и видим, что в случае debian там бинарники, а под alpine - python код. Т.е. погодите - при такой установке pydantic под alpine будет работать гораздо медленнее, чем под debain. Но, собственно, почему такое отличие вообще имеет место быть? Посмотрим на его сборку и установку.

Сборка и установка python пакетов

Установка любого пакета python начинается с его сборки в wheel. Если он поставляется в исходниках (.tar.gz), то выполняется setup.py, потом будет сборка пакета, который уже в свою очередь и будет установлен. Так что хорошей практикой будет заливать в pypi не только исходники, но и собранные на CI/CD wheel-пакеты. Благо, это делается буквально в 2 строчки (на примере моей библиотеки для работы с Yandex Disk):

$ pip wheel --no-deps . -w dist
Processing /opt/app/YaDiskClient
  Preparing metadata (setup.py) ... done
Building wheels for collected packages: YaDiskClient
  Building wheel for YaDiskClient (setup.py) ... done
  Created wheel for YaDiskClient: filename=YaDiskClient-0.5.1-py3-none-any.whl size=5238
Successfully built YaDiskClient

$ twine upload dist/*
Uploading distributions to https://upload.pypi.org/legacy/
Uploading YaDiskClient-0.5.1-py3-none-any.whl
100% ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 11.8/11.8 kB • 00:00 • 9.3 MB/s
View at:
https://pypi.org/project/YaDiskClient/0.5.1/

Ещё пара слов об именовании пакетов. Описан он в PEP 440 и представляет собой такую конструкцию: {dist}-{version}-{python}-{abi}-{platform}.whl (на самом деле там гораздо больше вариаций, но они нам не интересны). Что тут происходит:

  • dist - название пакета

  • version - версия пакета (обычно используется semver)

  • python - для какого python

  • abi - бинарный интерфейс (обычно abi3, повторяет python или опускается)

  • platform - платформа, под которую собраны бинарники

Т.е. один и тот же пакет poetry-1.1.14-py2.py3-none-any.whl будет использоваться и для python2, и для python3, причём для любой платформы. А вот cffi-1.15.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl будет установлен только на CPython 3.9 под Linux x86_64 с более-менее современными библиотеками. Обратим внимание на manylinux_2_17_x86_64 - это строка говорит о том, что бинарники внутри скомпилированы glibc версии 2.17 под архитектуру x86_64. Важный момент! Потому что под alpine будет ставиться другой пакет - cffi-1.15.1-cp39-cp39-musllinux_1_1_x86_64.whl. Скомпилирован он musl версии 1.1 и бинарно не совместим с manylinux. Т.е. под разные системы могут быть скачаны и распакованы разные архивы. Хорошо, если они созданы из одних исходников. Подробнее можно почитать на realpython.

Собственно, поэтому uvloop под alpine требовал компиляции - wheel под alpine просто нет на pypi. Для новых версий эту проблему починили. Т.е. теперь пакет будет скачан и распакован, компиляции не потребуется. Аналогичная проблема была и с psycopg2-binary.

Но мы возвращаемся к pydantic-1.9.1. Этот пакет собран под всё, что только можно. И при установке обычным pip выглядит нормально:

➜  ~ docker run -it python:3.9-alpine3.13 sh
/ # pip install pydantic==1.9.1
Collecting pydantic==1.9.1
  Downloading pydantic-1.9.1-cp39-cp39-musllinux_1_1_x86_64.whl (12.5 MB)
|████████████████████████████████| 12.5 MB 2.1 MB/s
Collecting typing-extensions>=3.7.4.3
  Downloading typing_extensions-4.4.0-py3-none-any.whl (26 kB)
Installing collected packages: typing-extensions, pydantic
Successfully installed pydantic-1.9.1 typing-extensions-4.4.0

/ # du -d 1 -h /usr/local/lib/python3.9/site-packages/
72.0K /usr/local/lib/python3.9/site-packages/__pycache__
49.5M /usr/local/lib/python3.9/site-packages/pydantic
...

Баги всюду

Как видно из куска файла с зависимостями, я использовал poetry. Вероятно, проблема в нём...

# poetry add pydantic@1.9.1
Updating dependencies
Resolving dependencies... (1.2s)
Writing lock file
Package operations: 2 installs, 0 updates, 0 removals
  • Installing typing-extensions (4.4.0)
  • Installing pydantic (1.9.1)

# du -d 1 -h /root/.cache/pypoetry/virtualenvs/-il7asoJj-py3.9/lib/python3.9/site-packages/
1.7M  /root/.cache/pypoetry/virtualenvs/-il7asoJj-py3.9/lib/python3.9/site-packages/pkg_resources
876.0K  /root/.cache/pypoetry/virtualenvs/-il7asoJj-py3.9/lib/python3.9/site-packages/pydantic
...

Действительно! К моменту выхода статьи проблему уже пофиксили в версии 1.2.0. Заключалась она в том, что poetry просто игнорировал пакеты с тегом musllinux_1_1_x86_64 и всегда собирал из исходников. А у pydantic в setup.py:

if not any(arg in sys.argv for arg in ['clean', 'check']) and 'SKIP_CYTHON' not in os.environ:
    try:
        from Cython.Build import cythonize
    except ImportError:
        pass
    else:
        # For cython test coverage install with `make build-trace`
        compiler_directives = {}
        if 'CYTHON_TRACE' in sys.argv:
            compiler_directives['linetrace'] = True
        # Set CFLAG to all optimizations (-O3)
        # Any additional CFLAGS will be appended. Only the last optimization flag will have effect
        os.environ['CFLAGS'] = '-O3 ' + os.environ.get('CFLAGS', '')
        ext_modules = cythonize('pydantic/*.py', exclude=['pydantic/generics.py'], …)

setup(
    …

Т.е. если не установлен Cython, то компиляции пропускается - будет работать код на python. Да-да, python-код можно компилировать в бинарник. Правда, не любой, с некоторыми ограничениями, но всё же.

Так что казалось бы популярный сетап alpine + poetry + FastAPI, а работать будет совсем по-другому. Вернее, дико тормозить. Да, именно эта проблема уже исправлена, но если вы взяли стандартный python:3.x-slim, вы бы о ней и не узнали, т.к. использовали те же самые пакеты, что и при разработке. Часто ли мы проверям docker-образ на то, что в действительности туда поставилось?

Взрываемся в препроде

Со следующей проблемой я столкнулся на препроде. Баг был в библиотеке aiohttp==3.6.2 и python==3.7. Да, давно это было, но пример показательный - поведение библиотеки под alpine и debian различалось. Один сервер конкатенировал куки не через \r\n как по стандарту, а через \n. Казалось бы мелочь, но:

➜  ~ docker run --rm --net=host tyvik/py-alpine
[]
<html><body><h1>hi!</h1></body></html>

➜  ~ docker run --rm --net=host tyvik/py-debian
['uid', 'session']
<html><body><h1>hi!</h1></body></html>

Исходники для проверки можно взять с github. Баг проявился потому что работал разный код. Под debian куки парсились с помощью конечного автомата, реализованного в бинарнике; под alpine работал фолбек-код на python, который парсил регуляркой. Случилось это потому что в pypi проник файл aiohttp-3.6.2-py3-none-any.whl, который подходит под все архитектуры и который был установлен как наиболее подходящий под alpine. Там исключительно python код. Под debian был установлен другой - aiohttp-3.6.2-cp37-cp37m-manylinux1_x86_64.whl вместе с бинарниками.

Вместо заключения

Казалось бы да, баги - с кем не бывает. Но я хочу обратить внимание, что они связаны с использованием нестандартного окружения. Не того, на котором происходит разработка. И хорошо, если это просто отнимает время на дебаг Dockerfile и установку зависимостей при сборке. Хуже, когда проблема внезапно возникает на проде, или вы вдруг узнаёте, что установилось не то, что должно было. И стоит ли сэкономленные 80Mb таких заморочек?

Alpine не плохой и не хороший. Это просто инструмент. У меня самого пара сервисов крутятся на нём, но они предельно простые. Там буквально 2 зависимости, и поэтому что-то необычное сразу бросится в глаза (например, установка из исходников). Для себя я выработал правило: в подавляющем большинстве случаев бери debian; alpine - только если действительно знаешь что делаешь.

Прошу прощения за кликбейтный заголовок. Мне хочется, чтобы к выбору базового образа подходили чуть более осознанно, учитывая как плюсы, так и минусы.

Обращение к не-питонистам

Я знаю, что фронты, гошники и др. часто используют alpine в качестве базового образа. Там это вполне уместно, т.к. слабо связано с окружающей системой. И я сам беру nginx:alpine, postgres:alpine, redis:alpine в качестве сервисов... В python-мире же очень сильно взаимодействие с бинарными файлами, которые были получены в том числе и с помощью musl. Так что приходится учитывать эту специфику.

Комментарии (17)


  1. shpaker
    27.12.2022 10:25
    +1

    Мне кажется, что заголовок слишком категоричен, хотя очевидно, что проблемы и вправду есть. Я правда тоже сталкивался с некоторыми проблемами, при использовании альпайна, но они тоже все были связаны с переходом на поетрю и пайдантик в проектах. С тех пор, когда это зависит от меня, использую двухэтапную сборку на базе slim контейнеров.


    1. TyVik Автор
      27.12.2022 10:41
      +1

      Да, сборка на базе debian более предсказуемая что ли.

      На alpine у меня крутятся совсем уж простые приложения типа notifier (данные из http перекладывает в telegram). Там кода совсем немного и будет легко отдебажить если что. Вот для него экономия в размере получилась больше, чем в 2 раза. Это, как по мне, уже существенно.


      1. Johan_Palych
        27.12.2022 18:06

        Не пробовали Configuring Docker with Btrfs ну и Use the BTRFS storage driver?

        Ну и так, например:

        find /usr/share/doc -depth -type f | xargs rm || true
        rm -rf /usr/share/man/* /usr/share/groff/* /usr/share/info/* /usr/share/lintian/* /usr/share/linda/* /var/cache/man/*


        1. TyVik Автор
          27.12.2022 18:12

          Нет, с btrfs не пробовал экспериментировать, так что спасибо за наводку.


        1. dolfinus
          28.12.2022 02:23
          +1

          А чем здесь поможет btrfs?

          В целом, можно взять образ на основе Debian slim, применить скрипт выше, и с помощью multistage скопировать все файлы в новый stage на базе scratch. По сути это будет тоже самое, что слить все правки в один слой. А поверх него уже собирать образ с приложением.


          1. Johan_Palych
            28.12.2022 12:43
            +1

            Используется сжатие на уровне файловой системы btrfs с zstd компрессией.(Use the BTRFS storage driver)

            sudo apt install btrfs-progs btrfs-compsiz

            Скрин для примера


          1. Johan_Palych
            28.12.2022 13:22

            Для экономии места можно схлопнуть все промежуточные слои в один финальный слой так: sudo docker build --squash -t
            Параметр squash (Squash an image’s layers (--squash) experimental).


            1. dolfinus
              28.12.2022 13:33
              +1

              Не, squash всех слоев в образе с приложением полностью ломает кэширование.

              А squash только одного слоя (который к тому же можно сделать отдельным образом) все ещё даёт возможность использовать кэш, правда только при сборке на том же хосте, где собиралась предыдущая версия образа


  1. not-allowed-here
    27.12.2022 11:41

    а можно сложный вопрос - Работает ли в докере компрессия памяти? те имея 100500 запущенных "контейнеров" на сервере жмется ли память для них? дедупликация?


    1. dolfinus
      27.12.2022 11:45

      Речь об оперативной памяти?


      1. not-allowed-here
        27.12.2022 13:39

        да, именно про нее - тк кпд использования при существенных количествах мелких контейнеров становится критичным


        1. dolfinus
          27.12.2022 16:53
          +3

          Я просто не совсем понимаю, о какой компрессии и дедупликации идёт речь. zram? KSM+copy-on-write?

          Контейнеры - это обычные процессы, запущенные в своих изолированных namespace (pid, network, fs, etc). Все, что работает для процессов на хосте, работает и для процессов в контейнерах.


    1. TyVik Автор
      27.12.2022 11:45
      +2

      Насколько я понимаю, контейнер - такой же процесс ОС, как и любой другой, но с выделенной cgroups (namespaces и layerfs). Т.е. если в целом компрессия памяти на хостовой ОС включена, то она будет работать и для контейнера.


  1. slisli
    27.12.2022 18:11

    У альпайна еще были проблемы с резолвингом ДНС. Незнаю как сейчас правда.


  1. Roman_Cherkasov
    28.12.2022 08:14

    Не понимаю зачем вообще использовать Alpine образы. В debian есть куча мусора, но кому до неё есть дело, если кроме места на диске, она ни как негативно не влияет на производительность и скорость разработки? 100мб лишнего занятого пространства, как мне кажется наименьшая из проблем, даже если образов десятки


  1. nkokarovtsev
    28.12.2022 09:10
    -1

    Не знаю как это применить, но оч интересно!


  1. B7W
    29.12.2022 23:36

    Есть причина не использовать. И она весьма серьёзная. В alpine используется musl вместо glibc, что может приводить к случайным падением сервисов на python/jvm/и тд под нагрузкой.

    При этом совершенно не понятны плюсы, кроме размера одного базового образа.