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
- Docker CLI
- Виртуализация и контейнеризация
- Проникновение в Docker с примерами
- Оптимизация образов Docker
- Документация на docker network
- Docker: гибкая сеть без NAT на все случаи жизни
- Docker Compose
- Docker Compose CLI
Задание. Упаковать бэкенд и фронтенд в образы и связать их через
docker compose: «Деплой стартапа „Котики в мир“».