Docker

Вопросы по контейнеризации и Docker
Ответ
Есть!
Рассмотрим пример:
...
COPY . .
RUN pip install --no-cache-dir -r requirements.txt
...
Пусть мы ведём локальную разработку. Значит, мы часто меняем код и собираем наш образ. Зависимости меняются сильно реже кода.

При изменении кода и пересборке образа установка зависимостей запустится заново. Это увеличит время сборки и добавит дискомфорта в разработку.

Дело в том, что COPY изменит слой, следовательно, кеш следующих слоёв станет неактуальным, и их нужно будет собрать заново.

Решение — реструктурировать Dockerfile: поставить установку зависимостей перед копированием кода:
...
COPY requirements.txt requirements.txt
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
...
В таком случае изменения только в коде не повлекут за собой установку библиотек, т.к. слой с установкой docker возьмёт из кэша.
1 / 15
В типичном Dockerfile почти всегда будут присутствовать две группы команд:
Есть ли разница?
Есть ли разница, в каком порядке эти группы команд располагать друг относительно друга (выше или ниже) в Dockerfile?
Вопрос
  • Перенести все нужные файлы внутрь образа
  • Установить зависимости

Вспомните про кеширование слоёв

Нажмите на карточку, чтобы посмотреть ответ
*подсказка – Вспомните про кеширование слоёв
Ответ
Кеши
Пакетные менеджеры по умолчанию предполагают, что их вызывают обычные пользователи на обычном компьютере. Поэтому они стараются сделать так, чтобы одни и те же пакеты/библиотеки не нужно было заново скачивать, компилировать, устанавливать и т.п. Для этого они складывают всё полезное в кеши: чтобы при повторной установке `torch` пользователю не нужно было ждать, пока скачается 2 гигабайта файлов.

Для приложений с лёгкими зависимостями кеш не так заметен, но в DL может спокойно оказаться, что на 4 gb библиотек приходится 2 gb кеша.

Но в Docker нам не нужно такое поведение. Docker-контейнеры по своему замыслу эфемерны и в них мы «повторно» не устанавливаем библиотеки. Поэтому кеш нам не нужен и мы можем значимо облегчить наш Docker-образ. В некоторых пакетных менеджерах есть флаги, которые его отключат:
RUN pip install --no-cache-dir -r requirements.txt
Но не во всех пакетных менеджерах есть такой флаг. В таком случае подчищаем сами:
RUN apt-get update \
 && apt-get install -y --no-install-recommends \
      <smth_libs> \
 && rm -rf /var/lib/apt/lists/*
2 / 15
Ничего не забыли?
Вопрос

Не появится ли в образе чего-то лишнего?

Нажмите на карточку, чтобы посмотреть ответ
Гарантируется, что файл requiremets.txt есть в образе, версии зафиксированы и все библиотеки установятся без конфликтов. Есть ли тогда проблемы в этой строчке?
...
RUN pip install -r requirements.txt
...
Посмотрим на строчку в Dockerfile
Ответ
Тренировка модели
docker run trainer-image:latest
1. Во-первых, наш запуск захватит собой терминал и обычно лучше запускать процесс в демоне при помощи флага -d

2. Результат тренировки — это какие-то артефакты. Например, чекпоинт модели. А Docker-контейнеры эфемерны, т.е. после конца тренировки всё, что сохранялось в контейнере, будет удалено. Поэтому нужно добавить volume, в который будет писать наш контейнер, чтобы после тренировки не потерять нужные артефакты: -v /path/to/local/dir:/dir/in/container. Если вы сохраняете все артефакты на удалённом хранилище при помощи, например, dvc, clearml, wandb и т.п. и вас не страшит эфемерность контейнера, то вы всё делаете правильно и следующий пункт как раз для вас

3. Если артефакты сохраняются при помощи систем по типу clearml или mlflow, то они обычно требуют секретов, чтобы всё заработало (пароли, access/secret-ключи для s3 и т.п.). В нашем примере всё будет работать только если эти секреты уже находятся внутри образа, а это не очень хорошо. Можно передать нужные секреты, например, через переменные окружения -e S3_ACCESS_KEY=…. Или примонтировать файлик с ними: -v ./secret_config.yml:/app/config.yml. Также можно использовать специальные сервисы, например, [vault]

4. Если мы тренируем модель на gpu, то нужно не забыть их прокинуть: --gpus '"device=0,1"' — ели хотим, чтоб было видно 0 и 1 карточки, --gpus all — чтобы было видно все. Также бывает полезно контролировать оперативную память и cpu: параметры --memory и --cpu.

5. Не выставлен --shm-size. Shm (shared memory) — общая память между процессами. Когда мы используем, например, DataLoader из PyTorch с, то данные загружаются не одним (главным) процессом, а несколькими воркерами. Чтобы данными мог воспользоваться главный процесс, воркеры записывают их не через диск/сокеты/pipe-ы — было бы слишком долго/неудобно — а сразу в общую память (shared memory). Если размер --shm-size слишком маленький (а по умолчанию, он всего 64 мб), то могут происходить разные спецэффекты: тормозить сбор батча, фризы, ошибки и т.п. Такое часто может встречаться в computer vision, потому что изображения могут много весить. Поэтому обычно этот параметр лучше сделать побольше. Например, передать --shm-size=1g.
3 / 15
docker run trainer-image:latest
Пусть у нас есть образ trainer-image:latest, в котором происходит тренировка DL-модели на pytorch. Какие проблемы может таить в себе такой запуск?
Тренировка модели
Вопрос
Нажмите на карточку, чтобы посмотреть ответ
Ответ
...
RUN apt-get update && \
    apt-get install python3 python3-pip && \
    rm -rf /var/lib/apt/lists/*
...
Сборка образа либо зависнет, либо упадёт с ошибкой. apt-get install запускается в интерактивном режиме и будет спрашивать вашего согласия на установку пакетов (введите Y/n). Сборка образа проходит в неинтерактивной среде, поэтому установить пакеты не получится. Поэтому нужно добавить флаг -y — «да, мы со всем согласны заранее, python в 300мб нас не пугает».
4 / 15
Всё ли с ней хорошо?
...
RUN apt-get update && \
    apt-get install python3 python3-pip && \
    rm -rf /var/lib/apt/lists/*
...
Посмотрим на строчку в Dockerfile
Устанавливаем
Вопрос
Нажмите на карточку, чтобы посмотреть ответ
Ответ
Как добавить модель?
1. Можно при инициализации сервиса скачивать нужный чекпоинт. Это первый вариант, который может прийти в голову, но он не очень хороший и стабильный. Приятно работать с контейнерами, которые быстро стартуют. Но если наша модель весит много, возникают проблемы с сетью и т. п., то мы оказываемся в сложном состоянии: контейнер уже запустился, но обрабатывать трафик ещё не готов, т.к. модель качается. С этим можно работать (см. probes в k8s), но неудобно и при любом перезапуске контейнера (пода) нужно заново качать и ждать.

2. Просто добавим её в docker-образ:

...
COPY model.format /models/model.format
...
Таким образом мы получим готовый образ, который при запуске сможет подхватывать нашу модель. Теперь мы не ждём скачивания модели при старте и не так боимся рестартов. Но в случае больших моделей наши образы станут слишком «толстыми», иногда это может быть проблемой. Например, если мы используем одну и ту же foundational-модель для большого количества приложений (или версий одного и того же приложения), то большое количество образов будут содержать в себе эту модель и мы будем хранить слишком много. Это можно обходить, например, тем, чтобы использовать базовый образ с этой моделью и использовать его во всех остальных, но далеко не всегда это удобно.

3. Init-контейнеры
Если модель находится где-то рядом (на той же машине), то мы всегда можем пробросить её через volume и использовать в сервисе. Но чтобы модель появилась рядом, её нужно сначала скачать. Разовьём идею из 1-го пункта тем, что «разнесём» скачивание и сам сервис на два разных контейнера: в первом мы скачиваем модель и после скачивания подкладываем её в общий volume, а во втором мы берём модель из этого общего volume и используем в сервисе.

Например, так это можно реализовать в docker-compose.
Наш Dockerfile:
# первый образ скачивает модель
FROM python:3.12 as model-downloader
ARG FILE_SERVER_URL
ENV FILE_SERVER_URL=${FILE_SERVER_URL}
WORKDIR /app
RUN pip install requests --no-cache-dir
COPY build/download_model.py model.json /app/
RUN python download_model.py
# второй образ не занимается скачиванием и ожидает, что
# готовая модель уже на месте
FROM python:3.12 as prod
WORKDIR /app
COPY requirements.txt /app/
RUN pip install --no-cache-dir -r requirements.txt
COPY main.py /app/
CMD python main.py
И сам docker-compose:
version: "3.8"
services:
  model-inference:
    build:
      context: .
      dockerfile: Dockerfile
      target: prod
    depends_on:
      # в основном образе ждём,
      # пока отработает скачивание модели
      download-model:
        condition: service_completed_successfully
    ports:
      - 8000:8000
    volumes:
      # подключились к volume с моделью на чтение
      - model:/app/ro
  download-model:
    build:
      context: .
      dockerfile: Dockerfile
      target: model-downloader
      args:
        - FILE_SERVER_URL=<model_url>
    volumes:
      # подключились к тому же volume, что и основной контейнер,
      # но с правом записывать туда
      - model:/app/rw
volumes:
  model:
Аналогичный подход можно применить и в k8s, если использовать, например, init-контейнеры.
5 / 15
Пусть мы натренировали нейросеть, сконвертировали её в нужный формат и хотим, чтобы наш сервис её использовал внутри docker. Какими способами можно этого добиться?
Как добавить модель?
Вопрос
Нажмите на карточку, чтобы посмотреть ответ
Ответ
Ищем ошибки
1. Сервисы неправильно зависят друг от друга. Первой должна «ожить» база. База не должна зависеть ни от бекенда ни от фронтенда. Бекенд в свою очередь не сможет работать без базы, поэтому сервису backend нужно поставить depends_on: db. А фронтенд не сможет жить без бекенда, поэтому нужно сделать depends_on: backend.

2. «Торчащие» порты. Мы ожидаем, что пользователи будут взаимодействовать только с фронтендом. Поэтому не нужно лишний раз открывать доступ до бекенда и базы из внешнего мира. Нужно убрать ports из backend и db.

3. Docker-контейнеры эфемерны: всё, что записывалось контейнером во время работы, будет удалено. А базы данных — это statefull приложения. Поэтому будет плохо, если перезапуск приложения будет означать очистку базы данных. Базе данных нужно добавить volume, чтобы данные не потерялись.

Исправленный вариант выглядит так:
version: "3.9"
services:
  frontend:
    build: ./frontend
    ports:
      - "80:3000"
    depends_on:
      - backend
  backend:
    build: ./backend
    depends_on:
      - db
  db:
    image: postgres:15
    volumes:
      - db_/var/lib/postgresql/data
volumes:
  db_
6 / 15
Делаем сервис, которым будут пользоваться как приложением в браузере. Посмотрите на этот docker-compose. Всё ли с ним хорошо?
version: "3.9"
services:
  frontend:
    build: ./frontend
    ports:
      - "80:3000"
  backend:
    build: ./backend
    ports:
      - "5000:5000"
    depends_on:
      - frontend
  db:
    image: postgres:15
    ports:
      - "5432:5432"
    depends_on:
      - frontend
Ищем ошибки
Вопрос

Правильно ли построено дерево зависимостей? И что будет при перезапуске docker-compose?

Нажмите на карточку, чтобы посмотреть ответ
*подсказка – Правильно ли построено дерево зависимостей? И что будет при перезапуске docker-compose?
Ответ
Образ поменьше
1. Выбрать базовый образ поменьше. Например, вместо python:3.11 (390 мб) выбрать python:3.11-slim (45 мб). Но не советуем спускаться до совсем до совсем минимальных базовых образов, если вам явно этого не нужно — может возникнуть слишком много проблем с установкой системных библиотек, которые в более тяжёлых образах присутствовали «из коробки».

2. Не тянуть лишнего. После сборки образа можете провалиться в контейнер и посмотреть, нет ли там чего-то, что занимает много места, но не используется: venv с вашего ПК, .git, локальная папка с бинарями и т.п. Лучше явно копировать в образ только то, что нужно и не забывать про .dockerignore.

3. Нам не нужны кеши. Для установки библиотек часто сохраняются кеши, которые затем не нужны для работы контейнера. Не забывайте их отключать или очищать. Например, RUN pip install --no-cache-dir -r requirements. txt для pip.
И RUN apt-get update && apt-get install -y <smth_libs> && rm -rf /var/lib/apt/lists/* для apt-get.

4. Multi-stage сборки. Можно сделать builder-образ для сборки, в котором произойдёт сборка/компиляция нашего приложения, а затем из него перетащить в образ-результат только нужные файлы. Отличный пример есть в документации docker.
7 / 15
Расскажите про подходы, которые помогают сделать docker-образ поменьше
Образ поменьше
Вопрос
Нажмите на карточку, чтобы посмотреть ответ
Ответ
Устанавливаем apt-get'ом
apt-get update обновляет индексы пакетов. Если слой с update возьмётся из кэша, а репозитории уже изменились, apt-get install может не найти пакеты или поставить не то. Правильнее объединить эти команды в одну и в конце не забыть прибраться:
RUN apt-get update \
 && apt-get install -y --no-install-recommends curl \
 && rm -rf /var/lib/apt/lists/*
8 / 15
Всё ли хорошо с этими командами?
RUN apt-get update
RUN apt-get install -y curl
Устанавливаем apt-get'ом
Вопрос

Будет ли индекс вечно свеж?

Нажмите на карточку, чтобы посмотреть ответ
*подсказка – Будет ли индекс вечно свеж?
Ответ
Build context
При docker build . docker отправляет демону весь контент текущей директории. Типичные «тяжёлые» вещи, которые случайно попадают в контекст:

  • .git/ (может быть огромным)
  • venv/
  • Датасеты, чекпоинты моделей
  • Логи, временные файлы

Не забывайте всё тяжёлое и локальное «спрятать» при помощи .dockerignore, который сработает аналогично .gitignore, но для docker.
9 / 15
Хотя весь код весит 2 MB. В чём причина?
Sending build context to Docker daemon 25GB
Сборка простого образа занимает неожиданно много времени. В логах видно:
Почему так долго?
Вопрос

Вспомните про контекст сборки

Нажмите на карточку, чтобы посмотреть ответ
*подсказка – Вспомните про контекст сборки
Ответ
Например, в GitlabCI это можно сделать при помощи rules:
build-api:
  rules:
    - changes:
      - services/api/**/*
      - shared/**/* 
  script:
    - docker build ...
build-worker:
  rules:
    - changes:
      - services/worker/**/*
      - shared/**/*
  script:
    - docker build ...
# ...
# ...
В таком случае, если в файлах сервиса и общих файлах ничего не было изменено, то и образ не будет пересобираться.
10 / 15
В CI мы не хотим собирать образы и пушить их в registry для каждого из сервисов при каждом коммите. Хотим собирать только для тех, где были изменения. Как такое реализовать?
# tree .
repo/
├── services/
│ ├── api/
│ ├── worker/
│ └── dl/
└── shared/
Пусть в нашем монорепозитории есть несколько сервисов:
Selective builds
Вопрос
Нажмите на карточку, чтобы посмотреть ответ
Ответ
Виртуализация vs Контейнеризация
Виртуализация
Посмотрим на картинку. Снизу — физическое железо сервера. Дальше может быть гипервизор (иногда он ставится прямо на железо, иногда поверх хостовой ОС, например Linux). Гипервизор управляет виртуальными машинами — изолированными окружениями с виртуальными CPU/памятью/дисками/сетью, в которых запускаются гостевые операционные системы. Так один физический сервер можно разделить на несколько виртуальных.
Контейнеризация
Тоже посмотрим на картинку. Снизу все привычно: наше железо и хостовая система. Дальше идет Docker Engine и это уже что-то новое. Docker Engine (дословно движок docker) — это клиент-серверное приложение, которое позволит нам запускать контейнеры и управлять ими.

Над Docker Engine нарисованы контейнеры. Разберемся, что такое контейнер и чем он отличается от виртуальных машин.

Контейнер — это изолированный процесс, в котором запускается приложение со всеми нужными зависимостями. Контейнеры легковеснее виртуальных машин, потому что используют ядро хостовой ОС (не поднимают отдельную гостевую ОС), поэтому обычно запускаются заметно быстрее.
⚠️ Note
Но из-за зависимости от хостовой ОС если вы подготовите контейнер под одну ОС, то не факт, что он заработает на другой. Например, контейнер, который работает на ПК с Ubuntu не факт, что заработает на Windows. Но чаще всего это не проблема,
потому что почти все сидят на Unix-подобных ОС и контейнеры между ними почти всегда переносятся без проблем.
⚠️ Note
Сейчас, когда говорят о контейнерах, то почти всегда имеют в виду Docker-контейнеры. Существуют и другие, но де факто Docker-контейнеры — стандарт в индустрии. Ради интереса можно зайти на вики и посмотреть, какие бывают.
Основное отличие
Основное отличие в том, что в виртуализации мы должны поддерживать ресурсами целую ОС, а то и несколько. При контейнеризации всё делается на нашей хостовой ОС, за счет этого идёт экономия ресурсов и растёт скорость разворачивания, но страдает изоляция.

То есть перед тем, как запустить приложение на виртуальной машине, вам сначала нужно её поставить. Грубо говоря, «установить винду». Это не быстро, да и сама винда весит не пару килобайт и на её сервинг нужно тратить мощности процессора.

При запуске же контейнеров никакой ОС дополнительно устанавливать не надо. Всё быстро запустится на ОС вашего сервера почти без дополнительных накладных расходов.
⚠️ Note
Контейнеры клёво, когда:
1. Хотим быстро что-то развернуть и без накладных расходов
2. И вас не гложет, что у вас всё зависит от хостовой ОС
11 / 15
Расскажите «на пальцах», чем виртуализация отличается от контейнеризации?
Виртуализация vs Контейнеризация
Вопрос
Нажмите на карточку, чтобы посмотреть ответ
Ответ
Как идея?
Зачастую это будет плохой идеей. Правило «один Docker-контейнер — один процесс» не закон, но на практике почти всегда полезно им пользоваться.

Почему один процесс обычно лучше:
  • Масштабирование: захочется увеличить только количество воркеров, обрабатывающих очередь — придётся масштабировать вместе со всем остальным (например, c Web UI), даже если масштабирование остального не нужно
  • Надёжность: если упал один из процессов, то docker либо посчитает упавшим «всё сразу» (если повезло и упал главный процесс), либо ваше приложение будет продолжать жить с одним «сломанным» процессом: т.к. с точки зрения docker если главный процесс (PID 1) жив, то всё в порядке. Отсюда может возникнуть ситуация «приложение продолжает жить со сломанной частью»
  • Сигналы (SIGTERM, SIGKILL) доставляются только главному процессу с PID 1, дочерние процессы могут не завершиться корректно
  • Наблюдаемость: логи, метрики, healthcheck'и — всё проще, когда «живой/мёртвый» только один процесс, а не пачка
  • Обновления: чтобы обновить одно приложение, нужно пересобирать и перезапускать весь контейнер
12 / 15
Хорошая ли идея засовывать внутрь docker-контейнера сразу несколько работающих приложений (процессов)?
Как идея?
Вопрос

*Подсказка – Что можно сказать про масштабирование?

Нажмите на карточку, чтобы посмотреть ответ
*подсказка – Вспомните про контекст сборки
Ответ
Restart policies
- no (по умолчанию): не перезапускать никогда
- always: перезапускать всегда, даже если контейнер остановлен вручную. После перезагрузки docker daemon тоже его перезапустит
- unless-stopped: как always, но если контейнер был остановлен вручную, то до перезагрузки daemon'а, не запустится автоматически
- on-failure[max-retries]: перезапускать только при ненулевом exit code. Можно ограничить число попыток
13 / 15
Чем отличаются эти политики перезапуска?
docker run --restart=no ...
docker run --restart=always ...
docker run --restart=unless-stopped ...
docker run --restart=on-failure:5 ...
Restart policies
Вопрос
Нажмите на карточку, чтобы посмотреть ответ
Ответ
Multi-stage
Multi-stage сборка позволяет использовать несколько команд FROM в одном Dockerfile. Каждый FROM начинает новый этап (stage), и в финальный образ можно скопировать только нужные артефакты из предыдущих этапов.

Для сборки приложения часто нужны компиляторы, SDK, dev-зависимости, исходники. Но для запуска нужен только готовый бинарник/артефакт. Multi-stage позволяет не тащить всё «сборочное» в продакшн-образ.

Пример из документации docker:
# сборка
FROM golang:1.24 AS build
WORKDIR /src
COPY <<EOF /src/main.go
package main
import "fmt"
func main() {
fmt.Println("hello, world")
}
EOF
RUN go build -o /bin/hello ./main.go
# в финальный образ кладём только нужный бинарник. В нём не будет компиляторов
# и т.п. и он будет весить меньше
FROM scratch
COPY --from=build /bin/hello /bin/hello
CMD ["/bin/hello"]
Multi-stage сборка особенно «полюбилась» в компилируемых языках, потому что там можно скомпилировать один бинарный файл и использовать в финальном образе только его. Но в python ей тоже можно найти применение. Например, если у вас есть dev-зависимости для разработки, которые не нужны в production-образе. Чтобы не плодить два докерфайла под два окружения, можно обойтись одним, используя multi-stage:
FROM python:3.12 as prod
WORKDIR /app
RUN apt update \
&& apt install -y curl \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY main.py .
CMD python main.py
FROM prod as dev
COPY requirements.dev.txt .
# дополнительно ставим зависимости, которые нужны только для разработки
RUN pip install --no-cache-dir -r requirements.dev.txt
14 / 15
Расскажите про multi-stage сборку и напишите пример Dockerfile с ней.
Multi-stage
Вопрос

*Подсказка – Что можно сказать про масштабирование?

Нажмите на карточку, чтобы посмотреть ответ
*подсказка – Что можно сказать про масштабирование?
Ответ
Устанавливаем зависимости
Сейчас если изменились light-requirements.txt, то это инвалидирует слой COPY … и все слои ниже него. А значит, при изменении «лёгких» зависимостей, мы будем также вынуждены переустановить и «тяжёлые».

Чтобы при изменении лёгких зависимостей нужно было переустанавливать только их и не ждать установки тяжёлых, нужно переписать наш Dockerfile следующим образом:
...
 
COPY heavy-requirements.txt .
RUN pip install --no-cache-dir -r heavy-requirements.txt
COPY light-requirements.txt .
RUN pip install --no-cache-dir -r light-requirements.txt
...
Теперь слой с тяжёлыми зависимостями стоит выше слоя с лёгкими и не будет инвалидироваться при изменении light-requirements.txt.
15 / 15
*подсказка – Хотим ли мы переустанавливать «тяжёлые» зависимости, если изменились «лёгкие»?
У приложения есть два вида зависимостей: лёгкие, которые к тому же часто меняются. И тяжёлые — pytorch и т. п., которые меняются редко. Всё ли хорошо мы делаем в этом Dockerfile?
...
COPY light-requirements.txt heavy-requirements.txt ./
RUN pip install --no-cache-dir -r light-requirements.txt -r heavy-requirements.txt
...
Устанавливаем зависимости
Вопрос

Хотим ли мы переустанавливать «тяжёлые» зависимости, если изменились «лёгкие»?

Нажмите на карточку, чтобы посмотреть ответ

DLOps

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