Прим. Wunder Fund: в этой длииинной статье вы найдете ряд полезных советов по работе с Docker, как общего характера, так и Python-специфичных. Хоть мы и давно используем Docker в работе, про некоторые советы мы подумали "а что, так можно было?". Советуем вначале пролистать статью, и отметить штуки, которые покажутся актуальными для вашей текущей ситуации.
Файлы Dockerfile
1. Используйте многоэтапные сборки
Воспользуйтесь многоэтапными сборками для того, чтобы создавать более компактные и лучше защищённые образы Docker.
Многоэтапные сборки Docker позволяют разделять выполнение действий, описываемых в файлах Dockerfile, на несколько этапов. Например, можно выделить этап компиляции и сборки приложения, а тем, что получится после прохождения этого этапа, можно воспользоваться на следующих этапах. Так как для создания образа используется лишь один, финальный этап, зависимости и инструменты, связанные с подготовкой приложения к работе, в итоговый образ не входят, что позволяет выйти на компактный, модульный образ, готовый к использованию в продакшне.
Вот пример файла Dockerfile из сферы веб-разработки:
# Этап, на котором выполняются подготовительные действия
FROM python:3.9-slim as builder
WORKDIR /app
ENV PYTHONDONTWRITEBYTECODE 1
ENV PYTHONUNBUFFERED 1
RUN apt-get update && \
apt-get install -y --no-install-recommends gcc
COPY requirements.txt .
RUN pip wheel --no-cache-dir --no-deps --wheel-dir /app/wheels -r requirements.txt
# Финальный этап
FROM python:3.9-slim
WORKDIR /app
COPY --from=builder /app/wheels /wheels
COPY --from=builder /app/requirements.txt .
RUN pip install --no-cache /wheels/*
В этом примере для установки определённых Python-пакетов требуется компилятор GCC. Поэтому мы описываем в файле этап, нужный лишь во время сборки проекта. Так как финальный образ, который будет использоваться для запуска проекта, не включает в себя GCC, этот образ получается компактнее и безопаснее, чем он был бы, если бы в его состав входил этот компилятор.
Сравним размеры образов:
REPOSITORY TAG IMAGE ID CREATED SIZE
docker-single latest 8d6b6a4d7fb6 16 seconds ago 259MB
docker-multi latest 813c2fa9b114 3 minutes ago 156MB
Вот пример Dockerfile из сферы Data Science:
# Этап, на котором выполняются подготовительные действия
FROM python:3.9 as builder
RUN pip wheel --no-cache-dir --no-deps --wheel-dir /wheels jupyter pandas
# Финальный этап
FROM python:3.9-slim
WORKDIR /notebooks
COPY --from=builder /wheels /wheels
RUN pip install --no-cache /wheels/*
Сравним размеры образов:
REPOSITORY TAG IMAGE ID CREATED SIZE
ds-multi latest b4195deac742 2 minutes ago 357MB
ds-single latest 7c23c43aeda6 6 minutes ago 969MB
В итоге можно отметить, что применение многоэтапных сборок позволяет выходить на более компактные продакшн-образы, что способствует экономии времени и денег. Это, кроме того, позволяет упростить продакшн-контейнеры. Благодаря тому, что образы имеют меньшие размеры, чем при использовании обычных сборок, и тому, что устроены они проще, поверхность атаки таких образов, в потенциале, меньше, чем у обычных образов.
2. Располагайте команды в файлах Dockerfile в надлежащем порядке
Обращайте особое внимание на порядок расположения команд в файлах Dockerfile для того чтобы пользоваться возможностями Docker по кешированию слоёв.
Docker выполняет кеширование каждого шага (или слоя) при обработке файлов Dockerfile для того чтобы ускорить выполнение следующих шагов. Когда содержимое слоя меняется, кеш подвергается инвалидации, причём речь идёт не только о кеше изменённого слоя, но и о кешах всех следующих за ним слоёв.
Вот пример:
FROM python:3.9-slim
WORKDIR /app
COPY sample.py .
COPY requirements.txt .
RUN pip install -r /requirements.txt
В этом Dockerfile мы скопировали код приложения до установки зависимостей с использованием файла requirements.txt
. Теперь каждый раз, когда мы будем менять sample.py
, будет осуществляться и переустановка пакетов-зависимостей. Это весьма неэффективный подход, особенно при использовании контейнера Docker для организации среды разработки. В результате важно, чтобы работа с файлами, которые часто меняются, была бы описана в слоях, которые описаны ближе к концу Dockerfile.
Выполнение нежелательных операций по инвалидации кеша можно предотвратить, воспользовавшись файлом .dockerignore
. Это позволяет исключить попадание ненужных файлов в контекст сборки и в финальный образ Docker. Мы ещё затронем эту тему.
Итак, для улучшения ситуации с кешированием, в вышеприведённом Dockerfile стоит перенести команду COPY sample.py .
в конец файла:
FROM python:3.9-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install -r /requirements.txt
COPY sample.py .
Обратите внимание на следующее:
Составляя файлы Dockerfile, всегда старайтесь размещать слои, которые, вероятнее всего, будут меняться как можно ближе к концу файла.
Комбинируйте команды
RUN apt-get update
иRUN apt-get install
. (Это, кроме того, положительно сказывается на размерах файлов. Мы ещё об этом поговорим.)Если вам нужно отключить кеширование для конкретной Docker-сборки — воспользуйтесь опцией
--no-cache=True
.
3. Стремитесь к использованию как можно более компактных базовых образов Docker
Маленькие образы Docker отличаются более высоким уровнем модульности и безопасности, чем более крупные образы.
Чем меньше образ — тем быстрее осуществляется его сборка, загрузка в репозиторий и скачивание из репозитория. Компактные образы, кроме того, обычно безопаснее больших образов, так как в их состав входят только библиотеки и системные зависимости, которые необходимы для запуска приложения.
Каким именно базовым образом Docker стоит пользоваться?
На этот вопрос, к сожалению, нет однозначного ответа. Всё зависит от конкретной ситуации.
Вот сравнение размеров различных базовых Docker-образов для Python:
REPOSITORY TAG IMAGE ID CREATED SIZE
python 3.9.6-alpine3.14 f773016f760e 3 days ago 45.1MB
python 3.9.6-slim 907fc13ca8e7 3 days ago 115MB
python 3.9.6-slim-buster 907fc13ca8e7 3 days ago 115MB
python 3.9.6 cba42c28d9b8 3 days ago 886MB
python 3.9.6-buster cba42c28d9b8 3 days ago 886MB
Хотя образ 3.9.6-alpine3.14, основанный на Alpine Linux, является самым компактным в этом списке, его применение часто приводит к увеличению времени сборки образа. Это происходит в том случае, если не удаётся найти нужные предварительно скомпилированные исполняемые файлы, которые будут в нём работать. В результате при выборе такого образа необходимые бинарники может понадобиться собирать самостоятельно, а это способно привести к увеличению размеров образа (что зависит от необходимых зависимостей системного уровня) и к увеличению времени сборки (из-за необходимости компиляции чего-либо из исходного кода).
Если вас интересуют подробности о том, почему в Python-проектах лучше не использовать базовые образы, основанные на Alpine Linux — взгляните на этот и этот материалы.
В результате, можно сказать, что подбор базового образа — это вопрос баланса. Если вы не можете решить, какой именно образ выбрать для разработки приложения, остановитесь на *-slim-разновидности интересующего вас образа. Особенно — если речь идёт об образе, используемом в процессе разработки. Процесс разработки упрощает отсутствие необходимости в постоянном обновлении Dockerfile для установки нужных зависимостей системного уровня при добавлении в проект новых Python-пакетов. А когда речь идёт о подготовке приложения и файлов Dockerfile к прощакшн-использованию, об оптимизации размеров и уменьшении поверхности атаки готового решения, вероятно, есть смысл рассмотреть возможность использования Alpine Linux-образа в роли базы для финального образа, полученного на выходе многоэтапной сборки.
Кроме того, не забывайте регулярно обновлять используемые вами базовые образы. Это способствует улучшению уровня безопасности и производительности готового решения. Когда выходит новая версия базового образа (например, в проекте используется 3.9.6-slim, а вышла 3.9.7-slim), стоит скачать новый образ и обновить работающие контейнеры, что позволит оснастить их самыми свежими патчами безопасности.
4. Минимизируйте количество слоёв образов
Комбинировать, всегда, когда это возможно, команды RUN
, COPY
и ADD
— это отличная идея. Дело в том, что использование этих команд приводит к созданию слоёв. Каждый слой увеличивает размер образа, так как слои кешируются. В результате по мере того, как растёт количество слоёв, входящих в образ, растёт и размер образа.
Исследовать этот вопрос для конкретного образа можно с помощью команды docker history
:
$ docker images
REPOSITORY TAG IMAGE ID CREATED SIZE
dockerfile latest 180f98132d02 51 seconds ago 259MB
$ docker history 180f98132d02
IMAGE CREATED CREATED BY SIZE COMMENT
180f98132d02 58 seconds ago COPY . . # buildkit 6.71kB buildkit.dockerfile.v0
<missing> 58 seconds ago RUN /bin/sh -c pip install -r requirements.t… 35.5MB buildkit.dockerfile.v0
<missing> About a minute ago COPY requirements.txt . # buildkit 58B buildkit.dockerfile.v0
<missing> About a minute ago WORKDIR /app
...
Обратите внимание на размеры. Только использование команд RUN
, COPY
и ADD
приводит к увеличению размеров образа. Уменьшить размер образа можно, комбинируя эти команды всегда, когда это возможно. Вот пара команд:
RUN apt-get update
RUN apt-get install -y netcat
Их можно скомбинировать в одну:
RUN apt-get update && apt-get install -y netcat
В результате вместо двух слоёв будет создан один слой, что приведёт к уменьшению размеров финального образа.
Хотя сокращение числа слоёв, входящих в образ — это, в целом, хорошо, важно понимать, что не стоит видеть своей главной целью именно это, что главное тут — побочный эффект, выражающийся в уменьшении размеров образа и в сокращении времени сборки образа. Другими словами, рекомендуется уделять больше внимания трём предыдущим советам (многоэтапным сборкам, порядку команд в Dockerfile, подбору компактных базовых образов), а не пытаться оптимизировать абсолютно все команды.
Размышляя о слоях, из которых состоит образ, учитывайте следующее:
Использование команд
RUN
,COPY
иADD
приводит к созданию слоёв.Каждый слой содержит сведения о его отличиях от предыдущего слоя.
Слои увеличивают размеры финального образа.
Вот несколько дополнительных советов, касающихся работы со слоями:
Комбинируйте взаимосвязанные команды.
Удаляйте ненужные файлы, находясь в том же слое, созданном командой
RUN
, в котором они созданы.Минимизируйте число вызовов
apt-get upgrade
, так как эта команда обновляет все пакеты, которые можно обновить, до их последних версий.При использовании многоэтапных сборок не уделяйте слишком много времени тонкой оптимизации команд этапов, на которых выполняются подготовительные действия.
И, наконец, чтобы улучшить читабельность кода команд, списки аргументов, разбитые на несколько строк, имеет смысл располагать в алфавитно-цифровом порядке:
RUN apt-get update && apt-get install -y \
git \
gcc \
matplotlib \
pillow \
&& rm -rf /var/lib/apt/lists/*
5. Используйте непривилегированные контейнеры
По умолчанию Docker выполняет процессы в контейнере с root-правами. Но это порочная практика, так как процесс с root-правами, выполняющийся внутри контейнера, выполняется с root-правами и на Docker-хосте. Из этого следует, что, если злоумышленник получит доступ к контейнеру, то у него будет возможность воспользоваться всеми root-привилегиями. В результате он сможет атаковать и Docker-хост. Например, речь идёт о следующих атаках:
Копирование секретной информации из файловой системы хоста в контейнер.
Выполнение удалённых команд.
Для того чтобы не допустить подобного — обеспечьте выполнение контейнеризованных процессов с правами пользователей, не являющихся root-пользователями:
RUN addgroup --system app && adduser --system --group app
USER app
Можно пойти и ещё дальше, отключив пользователю shell-доступ и отказавшись от создания его домашней директории:
RUN addgroup --gid 1001 --system app && \
adduser --no-create-home --shell /bin/false --disabled-password --uid 1001 --system --group app
USER app
Проверим эту конфигурацию:
$ docker run -i sample id
uid=1001(app) gid=1001(app) groups=1001(app)
Здесь контейнеризованное приложение запускается с правами пользователя, не являющегося root-пользователем. Но, принимая во внимание вышесказанное, учитывайте то, что демон Docker и сам контейнер при этом запускаются с root-привилегиями. Для того чтобы наладить запуск демона и контейнеров без root-привилегий — обратитесь к этому разделу документации Docker.
6. Если это возможно — используйте COPY вместо ADD
Если вы уверены в том, что вам не нужны дополнительные возможности, которые даёт команда ADD
— используйте команду COPY
.
В чём разница между COPY
и ADD
? Обе команды позволяют копировать в Docker-образы файлы из различных мест:
ADD <src> <dest>
COPY <src> <dest>
Хотя, похоже, что эти команды предназначены для решения одной и той же задачи, команда ADD
отличается некоторыми дополнительными возможностями:
Команда
COPY
используется для копирования с Docker-хоста в образ локальных файлов и директорий.Команду
ADD
можно использовать и для решения той же задачи, и для загрузки в образ файлов из внешних источников. Кроме того, если речь идёт об исходных файлах (<src>
в вышеприведённом описании команд), которые представляют собой архивы (хранящиеся в форматах tar, gzip, bzip2 и так далее), командаADD
автоматически распакует такие файлы в целевую директорию.
# Копирование локальных файлов с хоста в целевую директорию
COPY /source/path /destination/path
ADD /source/path /destination/path
# Загрузка внешнего файла и копирование его в целевую директорию
ADD http://external.file/url /destination/path
# Копирование и распаковка локального сжатого файла
ADD source.file.tar.gz /destination/path
7. Кешируйте Python-пакеты на Docker-хосте
Когда изменяется файл requirements.txt
— для установки новых пакетов нужно пересобрать образ. Данные предыдущих шагов будут кешированы, речь об этом шла в разделе «Минимизируйте количество слоёв». Загрузка всех пакетов при повторной сборке образа может привести к необходимости передачи больших объёмов данных по сети и может занять много времени. При каждой пересборке образа на загрузку обычных пакетов, используемых в разных образах, уходит примерно одно и то же время.
Избежать этого можно, указав локальную директорию, расположенную на хост-машине, в качестве директории, в которой хранится pip-кеш. После того как пакеты загружаются, они сохраняются в локальном кеше, что может ускорить операции повторной сборки образов, в которых используются эти пакеты.
Место для кеширования пакетов можно указать в виде опции при запуске Docker (-v $HOME/.cache/pip-docker/:/root/.cache/pip
), назначение соответствующей директории директорией, где хранится кеш, можно описать и в файле настройки Docker Compose.
В коде, показанном выше, приведён лишь пример директории с кешем. Вы же можете выбрать ту директорию, которая вам нужна. Проверьте, чтобы в качестве директории, где хранится кеш, была бы назначена правильная директория, а не та, в которой хранятся пакеты, имеющие отношение к конкретному проекту (та, где находятся пакеты, используемые при сборке).
Перемещение кеша из Docker-образа на хост может помочь в деле уменьшения размеров финального образа.
Если вы применяете Docker BuildKit, можно для управления кешем воспользоваться ключом --mount=type=cache
:
# syntax = docker/dockerfile:1.2
...
COPY requirements.txt .
RUN --mount=type=cache,target=/root/.cache/pip \
pip install -r requirements.txt
...
8. Запускайте в одном контейнере лишь один процесс
Почему рекомендуется запускать в одном контейнере лишь один процесс?
Представим, что стек вашего приложения состоит из двух веб-серверов и одного сервера базы данных. Хотя и можно, без особых сложностей, запустить все эти сервисы в одном контейнере, каждый из них следует запускать в отдельном контейнере, что облегчает повторное использование и масштабирование каждого отдельного сервиса. Можно отметить следующие сильные стороны такого решения:
Масштабируемость: если каждый сервис работает в собственном контейнере — можно горизонтально масштабировать веб-серверы в том случае, если возникнет необходимость в обработке большего, чем обычно, объёма трафика.
Возможность повторного использования кода: может быть, у вас есть другой сервис, нуждающийся в контейнеризованном сервере базы данных. Если у вас есть такой сервер, оформленный в виде отдельного контейнера, этот контейнер можно легко использовать повторно, не сталкиваясь при этом с дополнительной нагрузкой в виде двух ненужных веб-серверов, находящихся в том же контейнере.
Логирование: если в одном контейнере работают разные сервисы — это значительно усложняет задачу логирования. Подробнее об этом мы поговорим ниже.
Переносимость и предсказуемость: гораздо легче устанавливать патчи безопасности или заниматься отладкой в том случае, если речь идёт об одном процессе, а не о нескольких.
Всегда, когда это возможно, используйте exec-формы инструкций CMD и ENTRYPOINT
Инструкции CMD
и ENTRYPOINT
в файлах Dockerfile можно оформлять с использованием массивов (exec-форма) или с использованием строк (shell-форма):
# массив (exec)
CMD ["gunicorn", "-w", "4", "-k", "uvicorn.workers.UvicornWorker", "main:app"]
# строка (shell)
CMD "gunicorn -w 4 -k uvicorn.workers.UvicornWorker main:app"
Оба способа оформления этих инструкций корректны и позволяют добиться практически одного и того же результата, но, всегда, когда это возможно, следует использовать их exec-форму. Вот что об этом сказано в документации Docker:
Постарайтесь использовать в файлах Dockerfile exec-форму инструкций
CMD
иENTRYPOINT
.Например, пользуйтесь конструкцией вида
[«program», «arg1», «arg2»]
, а не вида«program arg1 arg2»
. Использование строковой формы инструкций приводит к тому, что Docker запускает процесс с использованием bash, что ведёт к неправильной обработке сигналов. Compose всегда использует JSON-форму, поэтому не беспокойтесь, если переопределитеCMD
илиENTRYPOINT
в Compose-файле.
Итак, из-за того, что большинство оболочек не передают сигналы дочерним процессам, при использовании shell-формы команд сочетание клавиш CTRL+C
(которое генерирует SIGTERM
) может не остановить дочерний процесс.
Вот пример:
FROM ubuntu:18.04
# Плохо: shell-формат
ENTRYPOINT top -d
# Хорошо: exec-формат
ENTRYPOINT ["top", "-d"]
Попробуйте оба варианта. Обратите внимание на то, что при использовании shell-формата сочетание клавиш CTRL+C
не «убивает» процесс. Вместо этого можно увидеть ^C^C^C^C^C^C^C^C^C^C^C
.
Ещё один недостаток shell-формата заключается в том, что при его использовании мы имеем дело с PID командной оболочки, а не самого процесса.
# exec-формат
root@18d8fd3fd4d2:/app# ps ax
PID TTY STAT TIME COMMAND
1 ? Ss 0:00 python manage.py runserver 0.0.0.0:8000
7 ? Sl 0:02 /usr/local/bin/python manage.py runserver 0.0.0.0:8000
25 pts/0 Ss 0:00 bash
356 pts/0 R+ 0:00 ps ax
# shell-формат
root@ede24a5ef536:/app# ps ax
PID TTY STAT TIME COMMAND
1 ? Ss 0:00 /bin/sh -c python manage.py runserver 0.0.0.0:8000
8 ? S 0:00 python manage.py runserver 0.0.0.0:8000
9 ? Sl 0:01 /usr/local/bin/python manage.py runserver 0.0.0.0:8000
13 pts/0 Ss 0:00 bash
342 pts/0 R+ 0:00 ps ax
Разберитесь с различиями инструкций ENTRYPOINT и CMD
Что использовать для запуска контейнеризованных процессов? ENTRYPOINT
или CMD
?
Существует два способа запуска команд в контейнере:
CMD ["gunicorn", "config.wsgi", "-b", "0.0.0.0:8000"]
# и
ENTRYPOINT ["gunicorn", "config.wsgi", "-b", "0.0.0.0:8000"]
Обе, в общем-то, решают одну и ту же задачу: запускают приложение config.wsgi
с помощью сервера Gunicorn и привязывают его к 0.0.0.0:8000
.
Инструкцию CMD
легко переопределить. Если для запуска контейнера воспользоваться командой вида docker run <image_name> uvicorn config.asgi
, то аргументы вышеприведённой инструкции CMD
будут заменены новыми аргументами (например — uvicorn config.asgi
). А вот для переопределения ENTRYPOINT
придётся приложить некоторые усилия, которые заключаются в использовании опции --entrypoint
:
docker run --entrypoint uvicorn config.asgi <image_name>
В данном случае совершенно очевидно то, что мы переопределяем ENTRYPOINT
. Поэтому рекомендуется пользоваться ENTRYPOINT
, а не CMD
для того, чтобы не допустить случайного переопределения некоей инструкции.
Эти инструкции, кроме того, можно использовать и совместно.
Например:
ENTRYPOINT ["gunicorn", "config.wsgi", "-w"]
CMD ["4"]
Когда они, как здесь, используются совместно, команда, которая выполняется при запуске контейнера, выглядит так:
gunicorn config.wsgi -w 4
Как уже было сказано, инструкцию CMD
очень легко переопределить. В результате CMD
можно использовать для передачи аргументов инструкции ENTRYPOINT
. То есть — в данном случае предельно просто настроить число рабочих процессов:
docker run <image_name> 6
Благодаря использованию такой команды контейнер будет запущен с шестью, а не с четырьмя рабочими процессами.
11. Пользуйтесь инструкцией HEALTHCHECK
Используйте инструкцию HEALTHCHECK
для того, чтобы проверить то, что процесс, выполняемый в контейнере, не просто функционирует, а ещё и находится в «здоровом» состоянии.
Docker обладает API для проверки состояния процессов, выполняемых в контейнере. Этот API даёт о процессе больше сведений, чем лишь данные о том «работает» этот процесс или нет. Дело в том, что если процесс «работает» — это может означать и то, что он «находится в нормальном работоспособном состоянии», и то, что он «всё ещё запускается», и даже то, что он «ведёт себя неправильно и застрял в бесконечном цикле». Работать с этим API можно, используя инструкцию HEALTHCHECK.
Например, если вы поддерживаете веб-приложение, вы можете воспользоваться следующей командой для того, чтобы узнать о том, работоспособна ли конечная точка /
, и о том, может ли она обрабатывать запросы, обеспечивающие функционирование приложения:
HEALTHCHECK CMD curl --fail http://localhost:8000 || exit 1
Если выполнить команду docker ps
— можно видеть результаты проверки HEALTHCHECK
:
Вот — пример «здорового» контейнера:
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
09c2eb4970d4 healthcheck "python manage.py ru…" 10 seconds ago Up 8 seconds (health: starting) 0.0.0.0:8000->8000/tcp, :::8000->8000/tcp xenodochial_clarke
Вот — пример контейнера, с которым что-то не так:
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
09c2eb4970d4 healthcheck "python manage.py ru…" About a minute ago Up About a minute (unhealthy) 0.0.0.0:8000->8000/tcp, :::8000->8000/tcp xenodochial_clarke
Можно пойти ещё дальше и подготовить специальную конечную точку, предназначенную исключительно для проверки «здоровья» сервисов, а после этого настроить HEALTHCHECK
на проверку возвращаемых этой конечной точкой данных. Например, если конечная точка возвращает JSON-ответ {«ping»: «pong»}
, можно предложить HEALTHCHECK
проверить на правильность тело ответа.
Вот как просмотреть результаты HEALTHCHECK-проверки с использованием docker inspect
:
❯ docker inspect --format "{{json .State.Health }}" ab94f2ac7889
{
"Status": "healthy",
"FailingStreak": 0,
"Log": [
{
"Start": "2021-09-28T15:22:57.5764644Z",
"End": "2021-09-28T15:22:57.7825527Z",
"ExitCode": 0,
"Output": "..."
Здесь, для краткости, приведена лишь часть выходных данных, так как в их полном виде приведён большой объём HTML-кода.
Проверку можно включить и в файле настройки Docker Compose:
version: "3.8"
services:
web:
build: .
ports:
- '8000:8000'
healthcheck:
test: curl --fail http://localhost:8000 || exit 1
interval: 10s
timeout: 10s
start_period: 10s
retries: 3
Здесь доступны следующие опции:
test
: команда для выполнения проверки.interval
: интервал тестирования (то есть, например, проводить тесты через каждые10
секунд).timeout
: максимальное время ожидания ответа.start_period
: момент начала проверок. Эту опцию можно использовать в том случае, если перед тем, как контейнер будет готов к работе, нужно выполнить какие-то дополнительные операции, вроде миграции контейнера.retries
: максимальное число попыток выполнения теста, после которого тест может быть сочтён непройденным.
Если вы используете инструменты оркестрации контейнеров, отличные от Docker Swarm, то есть — Kubernetes или AWS ECS — весьма вероятно то, что эти инструменты имеют собственные внутренние системы для проведения проверок работоспособности процессов. Прежде чем пользоваться инструкциями HEALTHCHECK
в своих проектах — обратитесь к документации используемых вами инструментов.
Образы
1. Ответственно подходите к версионированию Docker-образов
Всегда, когда это возможно, постарайтесь не пользоваться тегом latest
.
Если вы полагаетесь на тег latest
(это, на самом деле, не совсем «тег», так как он применяется по умолчанию в том случае, когда образ явным образом не тегирован), вы, опираясь на тег образа, не сможете понять, какая именно версия вашего кода работает в контейнере. Это усложняет процедуры проведения отката к предыдущим версиям ПО и открывает дорогу проблемам, связанным со случайной или злонамеренной перезаписью важных данных. Теги, как и инфраструктура, и развёртывания, должны быть иммутабельными.
Вне зависимости от того, как вы организуете работу со своими внутренними образами, категорически не рекомендуется использовать тег latest
при описании базовых образов. Это ведёт к возможности непреднамеренного развёртывания в продакшне новой версии базового образа, содержащей изменения, несовместимые с программами, использующими этот образ.
При оформлении внутренних образов стоит использовать описательные теги, что упростит задачу выяснения того, какая именно версия кода работает в конкретном контейнере, облегчит выполнение откатов и позволит избежать коллизий имён тегов.
Например, в состав тега могут входить следующие дескрипторы:
Отметки времени.
Идентификаторы Docker-образов.
Хеши Git-коммитов.
Семантические версии.
О других подходах к составлению тегов вы можете узнать, обратившись к этому ответу на Stack Overflow, посвящённому грамотному версионированию Docker-образов.
Вот пример:
docker build -t web-prod-a072c4e5d94b5a769225f621f08af3d4bf820a07-0.1.4 .
Здесь для формирования тега использованы следующие данные:
Имя проекта:
web
.Имя окружения:
prod
.Хеш Git-коммита:
a072c4e5d94b5a769225f621f08af3d4bf820a07
.Семантическая версия:
0.1.4
.
Очень важно выбрать схему тегирования образов и чётко её придерживаться. Так как хеши коммитов облегчают задачу перехода от образа к коду, на котором он основан, настоятельно рекомендуется разработать схему тегирования, включающую в себя, кроме прочего, и эти хеши.
2. Не храните в образах секретные данные
Секретные данные — это информация, которая ни в коем случае не должна попасть в чужие руки. Например это пароли, учётные данные для подключения к базам данных, SSH-ключи, токены, TLS-сертификаты. Подобные данные не должны включаться в состав образов в незашифрованном виде. Дело в том, что пользователи, не имеющие права работать с подобными данными, но получившие доступ к образам, могут извлечь из них эти данные, всего лишь исследовав слои образов.
Не добавляйте секретные данные в файлы Dockerfile в виде обычного текста, особенно — если вы отправляете образы в общедоступные репозитории вроде Docker Hub:
FROM python:3.9-slim
ENV DATABASE_PASSWORD "SuperSecretSauce"
Вместо этого такие данные нужно внедрять в контейнеры следующим образом:
Посредством переменных окружения (во время выполнения контейнера).
Посредством аргументов, передаваемых Docker при сборке образов.
С использованием инструментов, доступных в рамках сред оркестрации контейнеров вроде Docker Swarm (с помощью Docker Secrets) или Kubernetes (с помощью Kubernetes Secrets).
Кроме того, можно предотвратить утечку секретных данных путём добавления описаний файлов и папок, в которых обычно хранятся такие данные, в файл .dockerignore
:
**/.env
**/.aws
**/.ssh
И наконец — чётко описывайте файлы, копируемые в образ, не прибегайте к рекурсивному копированию всех файлов:
# Плохо
COPY . .
# Хорошо
copy ./app.py .
Чёткое описание файлов, включаемых в образ, кроме того, позволяет ограничить отключение кеширования.
Переменные окружения
Секретные данные можно передать в контейнер посредством переменных окружения, но эти данные будут видны всем дочерним процессам, связанным контейнерам, средствам логирования данных; посмотреть их можно будет и с помощью docker inspect
. Такие данные, кроме того, сложно обновлять:
$ docker run --detach --env "DATABASE_PASSWORD=SuperSecretSauce" python:3.9-slim
d92cf5cf870eb0fdbf03c666e7fcf18f9664314b79ad58bc7618ea3445e39239
$ docker inspect --format='{{range .Config.Env}}{{println .}}{{end}}' d92cf5cf870eb0fdbf03c666e7fcf18f9664314b79ad58bc7618ea3445e39239
DATABASE_PASSWORD=SuperSecretSauce
PATH=/usr/local/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
LANG=C.UTF-8
GPG_KEY=E3FF2839C048B25C084DEBE9B26995E310250568
PYTHON_VERSION=3.9.7
PYTHON_PIP_VERSION=21.2.4
PYTHON_SETUPTOOLS_VERSION=57.5.0
PYTHON_GET_PIP_URL=https://github.com/pypa/get-pip/raw/c20b0cfd643cd4a19246ccf204e2997af70f6b21/public/get-pip.py
PYTHON_GET_PIP_SHA256=fa6f3fb93cce234cd4e8dd2beb54a51ab9c247653b52855a48dd44e6b21ff28b
Это — самый простой и прямолинейный подход к управлению секретными данными в Docker. Хотя этот подход и нельзя назвать самым безопасным, он, всё же, позволяет скрыть секретную информацию от любопытных глаз за тонким защитным слоем и, как говорится, помогает честным людям оставаться честными.
Передача секретных данных в контейнер с использованием общего тома безопаснее, но такие данные должны быть зашифрованы с помощью Vault или AWS Key Management Service (KMS), так как они сохраняются на диске.
Аргументы, передаваемые Docker при сборке образов
Передать в образ секретные данные можно во время его сборки, воспользовавшись соответствующими аргументами, но эти данные будут доступны, посредством команды docker history
, тем, у кого есть доступ к образу.
Вот пример:
FROM python:3.9-slim
ARG DATABASE_PASSWORD
Сборка образа:
$ docker build --build-arg "DATABASE_PASSWORD=SuperSecretSauce" .
Если секретные данные нужны лишь в течение некоторого времени, при сборке образа (например SSH-ключи для клонирования приватного репозитория или для загрузки приватного пакета), стоит использовать многоэтапную сборку, так как этапы, на которых выполняются подготовительные действия, не оставляют записей в истории сборки:
# Этап, на котором выполняются подготовительные действия
FROM python:3.9-slim as builder
# секретные данные
ARG SSH_PRIVATE_KEY
# установка git
RUN apt-get update && \
apt-get install -y --no-install-recommends git
# использование ssh-ключа для клонирования репозитория
RUN mkdir -p /root/.ssh/ && \
echo "${PRIVATE_SSH_KEY}" > /root/.ssh/id_rsa
RUN touch /root/.ssh/known_hosts &&
ssh-keyscan bitbucket.org >> /root/.ssh/known_hosts
RUN git clone git@github.com:testdrivenio/not-real.git
# Финальный этап
FROM python:3.9-slim
WORKDIR /app
# копирование репозитория из временного образа
COPY --from=builder /your-repo /app/your-repo
# используем репозиторий для решения неких задач!
При многоэтапной сборке в истории сохраняются лишь сведения о финальном образе. Помните о том, что этот подход можно использовать и при работе с секретными данными, которые постоянно нужны приложению, вроде учётных сведений для доступа к базе данных.
Ещё, пользуясь командой docker build
, можно прибегнуть к новой опции --secret
, которая позволяет передавать в образы Docker секретные данные, которые не сохраняются в образах.
# "docker_is_awesome" > secrets.txt
FROM alpine
# показывает секретные данные из стандартного места для хранения таких данных:
RUN --mount=type=secret,id=mysecret cat /run/secrets/mysecret
Благодаря этому секретные данные будут взяты из файла secrets.txt
.
Сборка образа:
docker build --no-cache --progress=plain --secret id=mysecret,src=secrets.txt .
# вывод
...
#4 [1/2] FROM docker.io/library/alpine
#4 sha256:665ba8b2cdc0cb0200e2a42a6b3c0f8f684089f4cd1b81494fbb9805879120f7
#4 CACHED
#5 [2/2] RUN --mount=type=secret,id=mysecret cat /run/secrets/mysecret
#5 sha256:75601a522ebe80ada66dedd9dd86772ca932d30d7e1b11bba94c04aa55c237de
#5 0.635 docker_is_awesome#5 DONE 0.7s
#6 exporting to image
И, наконец, проверим историю для того, чтобы узнать о том, не произошла ли утечка секретных данных:
❯ docker history 49574a19241c
IMAGE CREATED CREATED BY SIZE COMMENT
49574a19241c 5 minutes ago CMD ["/bin/sh"] 0B buildkit.dockerfile.v0
<missing> 5 minutes ago RUN /bin/sh -c cat /run/secrets/mysecret # b… 0B buildkit.dockerfile.v0
<missing> 4 weeks ago /bin/sh -c #(nop) CMD ["/bin/sh"] 0B
<missing> 4 weeks ago /bin/sh -c #(nop) ADD file:aad4290d27580cc1a… 5.6MB
Подробнее о секретных данных, передаваемых в образ в процессе сборки, можно почитать здесь.
Использование инструмента docker secret
Если вы используете Docker Swarm, это значит, что управлять секретными данными вы можете с помощью инструмента docker secret.
Например, инициализируем Docker Swarm:
$ docker swarm init
Создадим, воспользовавшись командой docker secret
, секретные данные, которые планируется использовать в Docker:
$ echo "supersecretpassword" | docker secret create postgres_password -
qdqmbpizeef0lfhyttxqfbty0
$ docker secret ls
ID NAME DRIVER CREATED UPDATED
qdqmbpizeef0lfhyttxqfbty0 postgres_password 4 seconds ago 4 seconds ago
Когда контейнеру дают доступ к вышеописанным секретным данным — он смонтирует /run/secrets/postgres_password
. В этом файле будет содержаться, в виде обычного текста, соответствующий пароль.
Примеряете другие инструменты для оркестрации контейнеров? Вот несколько полезных ссылок на материалы, имеющие отношение к управлению секретными данными с использованием этих инструментов:
3. Используйте файл .dockerignore
Мы уже несколько раз упоминали файл .dockerignore. Этот файл используется для указания файлов и папок, которые не нужно включать в исходный контекст сборки, отправляемый демону Docker, который потом будет заниматься сборкой образа. Другими словами — можно использовать этот файл для описания необходимого вам контекста сборки образов.
При сборке Docker-образа весь контекст Docker, то есть — корневая директория проекта, отправляется демону Docker до выполнения инструкций COPY
или ADD
. Это может оказаться крайне ресурсоёмкой операцией, особенно если в проекте имеется множество зависимостей, если в нём есть большие файлы с данными или артефакты сборки. В дополнение этому инструмент командной строки Docker и демон Docker могут размещаться на разных компьютерах. В результате если демон работает на удалённой машине, следует ещё внимательнее следить за размерами контекста сборки.
Что стоит описать в файле .dockerignore
?
Временные файлы и папки.
Файлы журналов сборки.
Файлы с локальными секретными данными.
Локальные файлы, используемые в ходе разработки проекта, вроде
docker-compose.yml
.Папки, используемые системами контроля версий, вроде
.git
,.hg
и.svn
.
Вот пример:
**/.git
**/.gitignore
**/.vscode
**/coverage
**/.env
**/.aws
**/.ssh
Dockerfile
README.md
docker-compose.yml
**/.DS_Store
**/venv
**/env
В результате оказывается, что правильно структурированный файл .dockerignore
может помочь в решении следующих задач:
Уменьшение размеров Docker-образа.
Ускорение процесса сборки.
Предотвращение ненужных операций по инвалидации кеша.
Предотвращение утечки секретных данных.
4. Производите линтинг файлов Dockerfile и сканирование образов
Линтинг — это процесс проверки исходного кода на предмет программных и стилистических ошибок, на предмет использования в нём неудачных методик разработки, применение которых способно привести к каким-то проблемам. Статические файлы, как и файлы с кодом, могут подвергнуты линтингу. В частности, в случае с файлами Dockerfile, линтеры могут помочь проверить то, легко ли будет поддерживать эти файлы, то, что в них не используются устаревшие синтаксические конструкции, и то, что их авторы придерживаются лучших практических приёмов их разработки. Линтинг файлов Dockerfile должен быть стандартным шагом используемых вами CI-конвейеров.
Самым популярным линтером файлов Dockerfile можно назвать Hadolint:
$ hadolint Dockerfile
Dockerfile:1 DL3006 warning: Always tag the version of an image explicitly
Dockerfile:7 DL3042 warning: Avoid the use of cache directory with pip. Use `pip install --no-cache-dir <package>`
Dockerfile:9 DL3059 info: Multiple consecutive `RUN` instructions. Consider consolidation.
Dockerfile:17 DL3025 warning: Use arguments JSON notation for CMD and ENTRYPOINT arguments
Посмотреть на него в деле можно здесь. Он существует и в виде расширения для VS Code.
Линтинг файлов Dockerfile можно совместить со сканированием контейнеров на предмет уязвимостей.
Вот некоторые инструменты для решения этой задачи:
Snyk — это инструмент, ориентированный на поиск уязвимостей локальных образов Docker, входящий в состав стандартных средств командной строки Docker. Для его использования нужно прибегнуть к команде
docker scan
.Trivy можно использовать для сканирования образов контейнеров, файловых систем, Git-репозиториев и других конфигурационных файлов.
Clair — это опенсорсный проект, используемый для статического анализа контейнеров приложений на уязвимости.
Anchore — это ещё один опенсорсный проект, в рамках которого реализован централизованный сервис для исследования, анализа и сертификации образов контейнеров.
В результате можно порекомендовать выполнять линтинг файлов Dockerfile и сканирование образов на уязвимости для выявления потенциальных проблем, выражающихся в отклонениях от общепризнанных рекомендаций.
5. Подписывайте и верифицируйте образы
Как узнать, что образ, используемый для запуска продакшн-кода, не взломали?
Взлом образа может быть осуществлён дистанционно, с использованием атаки посредника (Man in the middle, MITM), подмена образа может быть осуществлена после компрометации репозитория.
Система Docker Content Trust (DCT) позволяет подписывать и верифицировать образы Docker, загружаемые из удалённых реестров.
Для того чтобы проверять целостность и подлинность образов, нужно установить следующую переменную окружения:
DOCKER_CONTENT_TRUST=1
После этого, если попытаться скачать неподписанный образ, будет выдана следующая ошибка:
Error: remote trust data does not exist for docker.io/namespace/unsigned-image:
notary.docker.io does not have trust data for docker.io/namespace/unsigned-image
Подробности о подписывании образов можно найти в документации по Docker Content Trust. При загрузке образов с Docker Hub постарайтесь использовать либо официальные образы, либо верифицированные образы из надёжных источников. Большим командам стоит рассмотреть возможность использования собственного внутреннего приватного реестра контейнеров.
Дополнительные рекомендации
При необходимости используйте в контейнерах виртуальные окружения Python
Стоит ли использовать виртуальное окружение внутри контейнера?
В большинстве случае виртуальные окружения в контейнерах не нужны — по крайней мере, до тех пор, пока вы придерживаетесь рекомендации относительно запуска в одном контейнере лишь одного процесса. Так как сам контейнер предоставляет нам механизм изоляции своего содержимого от окружающей среды, в контейнере можно устанавливать пакеты, доступные во всей системе. И, тем не менее, может возникнуть необходимость в использовании виртуального окружения, а не сборки wheel-файлов, в многоэтапных сборках.
Вот — пример, в котором используются wheel-файлы:
# Этап, на котором выполняются подготовительные действия
FROM python:3.9-slim as builder
WORKDIR /app
ENV PYTHONDONTWRITEBYTECODE 1
ENV PYTHONUNBUFFERED 1
RUN apt-get update && \
apt-get install -y --no-install-recommends gcc
COPY requirements.txt .
RUN pip wheel --no-cache-dir --no-deps --wheel-dir /app/wheels -r requirements.txt
# Финальный этап
FROM python:3.9-slim
WORKDIR /app
COPY --from=builder /app/wheels /wheels
COPY --from=builder /app/requirements.txt .
RUN pip install --no-cache /wheels/*
А вот — пример, в котором используется виртуальное окружение:
# Этап, на котором выполняются подготовительные действия
FROM python:3.9-slim as builder
WORKDIR /app
ENV PYTHONDONTWRITEBYTECODE 1
ENV PYTHONUNBUFFERED 1
RUN apt-get update && \
apt-get install -y --no-install-recommends gcc
RUN python -m venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"
COPY requirements.txt .
RUN pip install -r requirements.txt
# Финальный этап
FROM python:3.9-slim
COPY --from=builder /opt/venv /opt/venv
WORKDIR /app
ENV PATH="/opt/venv/bin:$PATH"
Устанавливайте ограничения на использование процессора и памяти
Ограничение использования памяти Docker-контейнерами — это хорошая идея, особенно в том случае, если на одном компьютере запускают несколько контейнеров. Это может не дать одному из контейнеров занять всю доступную память и тем самым навредить остальным.
Легче всего ограничить использование ресурсов памяти и процессора можно с помощью опций --memory
и --cpu
команды docker run
:
$ docker run --cpus=2 -m 512m nginx
Эта команда ограничивает контейнер 2 процессорными ядрами и 512 Мб основной памяти.
Сделать то же самое можно и в файле настройки Docker Compose:
version: "3.9"
services:
redis:
image: redis:alpine
deploy:
resources:
limits:
cpus: 2
memory: 512M
reservations:
cpus: 1
memory: 256M
Обратите внимание на поле reservations
. Оно используется для установки мягкого ограничения, которому отдаётся приоритет в том случае, когда на хост-системе наблюдается недостаток ресурсов памяти или процессора.
Вот и вот — пара полезных документов, касающихся управления системными ресурсами, доступными Docker-контейнерам.
Выполняйте логирование в stdout или stderr
Приложения, выполняющиеся в Docker-контейнерах, должны писать логи в стандартный поток вывода (stdout) или стандартный поток ошибок (stderr), а не в файл.
Если они так и поступают, это значит, что можно настроить демона Docker на отправку лог-сообщений в централизованную систему работы с журналами (вроде CloudWatch Logs или Papertrail).
Вот и вот — полезные материалы по теме.
При применении Gunicorn Heartbeat используйте директории, физически расположенные в памяти
Gunicorn использует систему контроля работоспособности форков рабочих процессов (Gunicorn Heartbeat), основанную на файлах.
В большинстве случаев такие файлы располагаются в директории /tmp
, которая часто, благодаря использованию tmpfs, физически находится в оперативной памяти. Так как Docker, по умолчанию, не использует tmpfs, такие файлы могут оказаться и в файловой системе, в основе которой лежит жёсткий диск. Это может привести к проблемам, вроде периодических «подвисаний», так как Gunicorn Heartbeat использует метод os.fchmod
, который способен заблокировать рабочий процесс в том случае, если директория, на самом деле, находится в файловой системе.
К счастью, эту проблему легко исправить: нужно изменить директорию, используемую Gunicorn Heartbeat на директорию, находящуюся в файловой системе, расположенной в памяти. Сделать это можно с помощью флага --worker-tmp-dir
:
gunicorn --worker-tmp-dir /dev/shm config.wsgi -b 0.0.0.0:8000
Итоги
В этой статье приведены советы, которые направлены на то, чтобы сделать файлы Dockerfile и создаваемые с их помощью Docker-образы аккуратнее, компактнее и безопаснее. Вот и вот — пара дополнительных материалов, в которых вы можете найти ещё некоторые советы по работе с Docker.
О, а приходите к нам работать? ????
Мы в wunderfund.io занимаемся высокочастотной алготорговлей с 2014 года. Высокочастотная торговля — это непрерывное соревнование лучших программистов и математиков всего мира. Присоединившись к нам, вы станете частью этой увлекательной схватки.
Мы предлагаем интересные и сложные задачи по анализу данных и low latency разработке для увлеченных исследователей и программистов. Гибкий график и никакой бюрократии, решения быстро принимаются и воплощаются в жизнь.
Сейчас мы ищем плюсовиков, питонистов, дата-инженеров и мл-рисерчеров.
Присоединяйтесь к нашей команде.
Комментарии (8)
casnerano
08.11.2021 20:58+9Спасибо, обратили внимание на достаточно важные моменты.
Единственное, заголовок "Практические рекомендации по работе с Docker для Python-разработчиков" можно заменить, например на "Практические рекомендации по работе с Docker на примере с Python".
Т.е. основной текст статьи несет в себе информацию по Docker полезную не только python-разработчикам.
mgyk
08.11.2021 21:42+4Ещё удобная штука чтобы смотреть сколько и какой слой занимает. Для больших образов не всегда понятно где именно проблема появляется. Можно включать в CI
https://github.com/wagoodman/dive
dolfinus
08.11.2021 22:37+3Спасибо за статью, много полезных рекомендаций.
Однако с одним утверждением я не согласен:
При необходимости используйте в контейнерах виртуальные окружения Python
С одной стороны да, размер образа будет меньше. С другой, внутри контейнера, который и так даёт изоляцию окружения от хоста, зачем-то создаётся виртуальное окружение, которое изолирует зависимости от системного Python.
Плюс, нужно будет собирать stage на одном и том же базовом образе, поскольку по-умолчанию внутри venv пути к интерпретатору python на самом деле являются симлинками на системный python, а в разных дистрибутивах он может быть расположен по разным путям (где-то /usr/bin, где-то /usr/local/bin). А если использовать опцию
--copy
, то размер образа увеличивается, поскольку в venv создаётся копия файлов интерпретатора.Плюс новички, увидев что внутри контейнера создаётся venv, могут по незнанию написать в своем Dockerfile что-нибудь вроде
COPY venv/ /app/venv/
. В таком варианте уже не приходится говорить ни о какой воспроизводимости сборки, как и о ее независимость от хоста, на котором она выполняется.Так что этот подход я бы не стал рекомендовать.
Renatk
08.11.2021 22:47+2Если будут силы и идеи на новую статью, было бы интересно прочитать про ускорение Python с помощью различных PyPy, Cython, Nuitka и т.д. в разрезе сборки Docker в Ci/Cd и с результатами, насколько быстрее получились приложения, а так же что удалось/не удалось.
stepalxser
11.11.2021 15:57ENV PYTHONDONTWRITEBYTECODE 1 ENV PYTHONUNBUFFERED 1
В примере с многоэтапной сборкой передается в образе билдера, переменные как-нибудь передаются в финальный образ?
Ryav
02.12.2021 21:55А можно подробнее по секретным данным? Предположим, что у меня есть
secret.py
, где указаны ключики, а запускается контейнер на чуждой мне VPS. Сейчас я просто собираю контейнер сCOPY secret.py /workdir
, а после сношуsecret.py
с хоста, но по сути внутри контейнера он доступен. Есть другое решение?
kiloper
спасибо