Docker

Код, оторванный от окружения, ничего не воспроизводит: расчёт зависит ещё и от версии Python, набора библиотек, системных пакетов и десятка настроек, отсутствующих в репозитории. На этом стыке нарушается воспроизводимость чужих результатов.

«У меня на машине работает»

Типичная ситуация выглядит следующим образом. Скрипт обработки данных, полученных на эксперименте, работает на ноутбуке автора, а при отправке коллеге завершается с ImportError. При запуске на кластере обнаруживается Python 3.8 вместо 3.12, системный numpy собран без нужных флагов, а права на установку пакетов есть только у администратора, находящегося в отпуске. Через два года рецензент просит воспроизвести график, напечатанный в статье, а автор уже не помнит версий библиотек, использовавшихся в то время.

Для физики это удар по воспроизводимости. Численный результат, не поддающийся повторению, стоит немногим больше, чем результат, которого нет. Виртуальные окружения и requirements.txt решают проблему частично: они фиксируют версии установленных Python-пакетов, но не версию самого Python, не системные библиотеки (BLAS, LAPACK, HDF5, компиляторы), не переменные окружения и не операционную систему. Создание виртуальных окружений и их наполнение рассматриваются в главе «От скрипта к приложению».

Docker упаковывает приложение вместе со всем его окружением, с интерпретатором, библиотеками, системными зависимостями и настройками, в единый образ. Собранный образ запускается одинаково на ноутбуке автора, на машине коллеги и на сервере в лаборатории.

Контейнеры vs виртуальные машины

Контейнер не является виртуальной машиной. Виртуальная машина эмулирует целый компьютер, несущий внутри полноценную гостевую операционную систему со своим ядром, а гипервизор транслирует её обращения к аппаратуре. Изоляция надёжная, но дорогая: каждая ВМ занимает гигабайты и стартует десятки секунд.

Контейнер представляет собой обычный процесс операционной системы, к которому ядро Linux применило два механизма. Namespaces (пространства имён) изолируют то, что процесс видит: у контейнера свои дерево процессов, сетевые интерфейсы и файловая система, и он считает себя единственным в системе. Cgroups (control groups) ограничивают то, что процесс потребляет, то есть отведённые ему память, процессорное время и дисковый ввод-вывод. Ядро при этом остаётся общим для всех контейнеров и хоста, поэтому контейнер стартует за доли секунды, а образ занимает от десятков до сотен мегабайт вместо гигабайт, занимаемых виртуальной машиной. Платой является несколько более слабая изоляция, чем у ВМ, что существенно для запуска чужого недоверенного кода и несущественно для упаковки собственных расчётов. Пространства имён и контрольные группы рассмотрены в предыдущей главе.

На macOS и Windows нативные Linux-контейнеры невозможны, поэтому Docker Desktop поддерживает лёгкую виртуальную машину с Linux, внутри которой работают контейнеры.

Образы и контейнеры

Существуют два центральных понятия:

  • Образ (image) — неизменяемый шаблон: файловая система с установленными в неё программами плюс метаданные (какую команду запускать, какие порты открывать). В терминах программирования образ соответствует классу.
  • Контейнер (container) — запущенный экземпляр образа, процесс со своим состоянием, объект этого класса. Из одного образа можно запустить произвольное число контейнеров.

Готовые образы скачиваются из реестра, по умолчанию из Docker Hub.

# скачать официальный образ Python 3.12 (slim — урезанный, без лишнего)
docker pull python:3.12-slim

# список локально скачанных образов
docker images

Запись python:3.12-slim читается как «репозиторий:тег», где тег означает версию. Если тег не указан, подставляется latest, однако образ под этим тегом сегодня и через год окажется разным, а требуется воспроизводимость.

Запуск контейнера осуществляется следующим образом:

# запустить контейнер и выполнить в нём одну команду
docker run python:3.12-slim python -c "print('hello from container')"

# интерактивный запуск: -i — пробросить stdin, -t — выделить терминал
# получаем REPL питона внутри контейнера
docker run -it python:3.12-slim python

# а так можно попасть в шелл контейнера и осмотреться
docker run -it python:3.12-slim bash

# --rm — удалить контейнер сразу после завершения (не копить мусор)
docker run --rm -it python:3.12-slim bash

Внутри контейнера команда ls / показывает чужую файловую систему, pip install устанавливает пакет внутри контейнера, и после его удаления всё исчезает. Контейнеры эфемерны: всё ценное должно находиться либо в образе, либо в примонтированных томах (о них ниже).

Управление контейнерами:

docker ps               # запущенные контейнеры
docker ps -a            # все, включая остановленные
docker stop <id|имя>    # мягко остановить контейнер
docker rm <id|имя>      # удалить остановленный контейнер
docker rmi <образ>      # удалить образ
docker logs <id|имя>    # посмотреть, что контейнер писал в stdout/stderr

Вместо полного идентификатора можно указывать первые несколько символов или имя, выданное контейнеру самим Docker (или заданное через --name).

Проброс портов и томов

Контейнер изолирован, в том числе по сети и по файлам; изоляция нарушается в нужных местах двумя флагами.

Порты, -p хост:контейнер. Сервис внутри контейнера слушает свой порт, и снаружи он недоступен, пока порт не проброшен на хост.

Тома, -v путь_на_хосте:путь_в_контейнере. Каталог хоста монтируется внутрь контейнера, файлы видны с обеих сторон, а полученные результаты сохраняются после удаления контейнера.

Пример: Jupyter, запущенный в контейнере без единого pip install на собственной машине.

# jupyter/scipy-notebook — готовый образ с numpy, scipy, pandas, matplotlib
# -p 8888:8888          порт 8888 хоста -> порт 8888 контейнера
# -v ...:/home/jovyan/work   текущий каталог -> каталог work внутри контейнера
# тег фиксируем ради воспроизводимости, реестр указываем явно:
# образы jupyter в конце 2023 года переехали с Docker Hub на quay.io
docker run --rm \
    -p 8888:8888 \
    -v "$(pwd)":/home/jovyan/work \
    quay.io/jupyter/scipy-notebook:2024-05-27

В браузере открывается http://localhost:8888 по ссылке с токеном, выведенной контейнером в журнал, и работа ведётся в блокнотах, находящихся в текущем каталоге хоста. После удаления контейнера блокноты остаются, а окружение исчезает.

Dockerfile: свой образ для научного проекта

Расчётный код требует своего набора зависимостей; рецепт сборки образа описывается в файле Dockerfile. Пусть имеется проект моделирования динамики пучка.

beam-simulation/
├── Dockerfile
├── requirements.txt      # numpy, scipy, matplotlib с версиями
├── simulate.py           # основной расчёт
└── beamlib/              # свои модули

Содержимое requirements.txt:

numpy==2.1.3
scipy==1.14.1
matplotlib==3.9.2

Dockerfile:

# Базовый образ: официальный Python, slim-вариант без лишних пакетов.
# Всегда фиксируй версию — это фундамент воспроизводимости.
FROM python:3.12-slim

# Рабочий каталог внутри образа; все следующие команды выполняются в нём.
# Если каталога нет, он будет создан.
WORKDIR /app

# Сначала копируем ТОЛЬКО файл с зависимостями...
COPY requirements.txt .

# ...и устанавливаем их. --no-cache-dir не хранит кеш pip в образе,
# образ получается меньше.
RUN pip install --no-cache-dir -r requirements.txt

# Только теперь копируем весь остальной код проекта.
COPY . .

# Команда по умолчанию при запуске контейнера.
# Форма со скобками (exec form) запускает процесс напрямую, без оболочки.
CMD ["python", "simulate.py"]

Рассмотрим инструкции:

  • FROM задаёт базовый образ. Всё, что содержится в python:3.12-slim (Debian, Python, pip), достаётся готовым.
  • WORKDIR /app объявляет текущий каталог для последующих COPY, RUN, CMD.
  • COPY <откуда> <куда> копирует файлы из каталога проекта, называемого контекстом сборки, внутрь образа.
  • RUN выполняет команду во время сборки и сохраняет результат в образ. Здесь же устанавливаются системные пакеты наподобие RUN apt-get update && apt-get install -y gfortran.
  • CMD указывает, что запускать при старте контейнера. Выполняется не при сборке, а при docker run, и легко переопределяется командой docker run мой-образ bash.

Слои и кеширование: отдельное копирование requirements.txt

Каждая инструкция Dockerfile создаёт слой — снимок изменений, внесённых ею в файловую систему. Образ представляет собой стопку слоёв, и Docker их кеширует: если инструкция и её входные файлы не изменились, слой берётся из кеша, а не пересобирается. Как только один слой изменился, все последующие пересобираются.

Код (simulate.py) правится по двадцать раз в день, а requirements.txt раз в месяц. Если бы COPY . . располагалась до установки зависимостей, любая правка, сделанная в коде, инвалидировала бы слой копирования, и Docker заново выполнял бы pip install со скачиванием numpy и scipy, что означает пять минут ожидания после каждой изменённой строки. В приведённом порядке слой с зависимостями зависит только от requirements.txt: при правке кода пересобирается лишь дешёвый COPY . ., и сборка занимает секунды. Правило: редко меняющееся выше, часто меняющееся ниже.

Файл .dockerignore устроен по образцу .gitignore: в нём перечисляются .git, __pycache__, данные и прочее, что не должно попадать в образ.

Сборка и запуск

# Собрать образ из Dockerfile в текущем каталоге (точка — контекст сборки).
# -t задаёт имя и тег.
docker build -t beam-simulation:0.1 .

# Тег можно навесить и потом (это просто ярлык на тот же образ)
docker tag beam-simulation:0.1 beam-simulation:latest

# Запуск: результаты пишем в примонтированный каталог,
# иначе они умрут вместе с контейнером
docker run --rm -v "$(pwd)/results":/app/results beam-simulation:0.1

# Переопределить CMD: запустить другой скрипт из того же образа
docker run --rm beam-simulation:0.1 python -m beamlib.diagnostics

Весь расчёт сводится к одной команде, воспроизводимой на любой машине с Docker.

Реестры: Docker Hub и ghcr.io

Передать образ коллеге можно файлом (docker save и docker load), однако стандартным путём является реестр (registry), устроенный как git-хостинг для образов. По умолчанию используется публичный Docker Hub, откуда был скачан python:3.12-slim. Второй популярный вариант, GitHub Container Registry (ghcr.io), удобен тем, что образы располагаются рядом с кодом проекта на GitHub и собираются автоматически через GitHub Actions.

Имя образа, отправляемого в реестр, должно включать аккаунт владельца, после чего выполняется docker push.

# тегируем образ под свой аккаунт на Docker Hub
docker tag beam-simulation:0.1 fuodorov/beam-simulation:0.1

docker login                            # авторизация (однократно)
docker push fuodorov/beam-simulation:0.1

# коллега на любой машине делает:
docker pull fuodorov/beam-simulation:0.1

# для ghcr.io всё то же, только префикс реестра в имени:
docker tag beam-simulation:0.1 ghcr.io/fuodorov/beam-simulation:0.1
docker push ghcr.io/fuodorov/beam-simulation:0.1

Docker Compose: несколько контейнеров

Система редко состоит из одного контейнера: типичный сценарий — приложение, записывающее результаты в базу данных PostgreSQL. Запускать оба контейнера вручную, настраивать сеть между ними, помнить порядок запуска и десяток флагов утомительно. Docker Compose описывает всю связку декларативно, в одном файле compose.yaml:

services:
  app:
    build: .                    # собрать образ из Dockerfile в текущем каталоге
    volumes:
      - ./results:/app/results  # результаты — на хост
    environment:
      DB_HOST: db               # имя сервиса работает как сетевое имя хоста
      DB_USER: physicist
      DB_PASSWORD: secret
    depends_on:
      - db                      # сначала стартует база, потом приложение

  db:
    image: postgres:16          # готовый образ с Docker Hub
    environment:
      POSTGRES_USER: physicist
      POSTGRES_PASSWORD: secret
      POSTGRES_DB: measurements
    volumes:
      - pgdata:/var/lib/postgresql/data   # именованный том: данные переживут контейнер

volumes:
  pgdata:

Compose автоматически создаёт общую сеть, в которой контейнеры видят друг друга по именам сервисов, объявленным в файле, и приложение подключается к базе по адресу db:5432, без IP. Основные команды:

docker compose build      # только собрать образы, не запуская
docker compose up -d      # собрать и запустить всё; -d — в фоне
docker compose logs -f    # смотреть логи всех сервисов
docker compose ps         # какие сервисы подняты и какие порты опубликованы
docker compose down       # остановить и удалить контейнеры (тома останутся)

Одного файла в репозитории достаточно, чтобы любой член группы поднял всю инфраструктуру проекта одной командой.

Видимость портов снаружи и внутри

В примере выше наружу не опубликовано ничего: приложение и база взаимодействуют только между собой, по сети, созданной Compose. Чтобы открыть сервис пользователю или соседней машине, существуют два разных ключа.

services:
  nginx:
    image: nginx:1.27
    ports:
      - "80:80"     # опубликовать наружу: порт 80 хоста -> порт 80 контейнера
  backend:
    build: ./backend
    expose:
      - "8000"      # видно только соседям по сети Compose, с хоста не достучаться

ports публикует порт на хост, и по нему в контейнер может обратиться любой, у кого есть доступ к машине. expose не публикует ничего, а лишь помечает порт, на котором сервис слушает, для соседей по той же сети. Обычная схема рабочего стенда: снаружи опубликован единственный порт, а за ним стоит nginx, раздающий статику фронтенда и перенаправляющий запросы к API на бэкенд по имени сервиса. Такой nginx называется обратным прокси, и в его конфигурации достаточно одной секции:

server {
    listen 80;
    location / {
        root /usr/share/nginx/html;     # собранный фронтенд
    }
    location /api/ {
        proxy_pass http://backend:8000/;   # backend — имя сервиса из compose.yaml
    }
}

Порт, не опубликованный наружу, нельзя атаковать с хоста и из сети: база данных и бэкенд остаются недосягаемыми, даже если в них обнаружится уязвимость. Проверкой служит команда docker compose ps: публикация должна присутствовать ровно у одного сервиса, а curl на порт бэкенда с хоста отвечать не должен.

Контейнеры в науке

Контейнер является ответом рецензенту, требующему воспроизвести результаты. Всё чаще к статье прикладывают не только код, но и собранный образ или хотя бы Dockerfile, помещённый в репозиторий. Одной команды docker run достаточно, чтобы читатель получил те же графики из тех же данных, с точностью до версий всех задействованных библиотек. На суперкомпьютерах Docker обычно запрещён из-за демона, работающего с правами root. Там используется Apptainer (бывший Singularity), контейнерная система, спроектированная для HPC, которая работает без root-прав, совместима с MPI и GPU и позволяет напрямую конвертировать Docker-образы. Поэтому образ, собранный для ноутбука, почти без изменений запускается и на кластере.

Полезные ссылки

Задание. Упаковать бэкенд и фронтенд в образы и связать их через docker compose: «Деплой стартапа „Котики в мир“».