Основы Docker: контейнеризация, Dockerfile и Docker Compose. Часть 2
Привет! Меня зовут Толя, я лидер компетенции Java в Цифровом СИБУРе. Наш прошлый материал о Docker собрал классный фидбэк, поэтому мы решили развить тему и подготовить ещё несколько статей, двигаясь от простого к сложному.
В этом материале речь пойдёт о том, что помогает избежать конфликтов зависимостей и проблем с изоляцией, возникающих при запуске нескольких приложений на одном сервере. Для решения этих задач используются технологии контейнеризации, которые позволяют создавать изолированные окружения для приложений, устраняя проблемы совместимости и упрощая процесс развёртывания. Рассмотрим, как работает контейнеризация и какие инструменты помогают сделать её максимально эффективной.
Проблемы запуска приложений в одном окружении
Во время разработки программы необходимо выполнять её в средах, где используются специфичные версии библиотек и системных компонентов.
Запуск одного приложения на физическом сервере может быть слишком затратным, поэтому зачастую на одном сервере работают несколько приложений одновременно. Это приводит к следующим проблемам.
У каждого приложения свои версии зависимостей, которые могут конфликтовать между собой. Например, для одного приложения нужна системная библиотека glibc 2.19, а для другого — glibc 2.34. При этом в операционной системе glibc обновилась только до версии 2.30. Конечно, можно жёстко завязаться на определённую версию и обозначить, что запускать сможем только на ОС, например, CentOS 6-й версии, но теряем переносимость приложения.
Необходима изоляция, чтобы одно приложение не могло повредить данные другого. К примеру, одна программа периодически удаляет временные файлы и может случайно удалить временные файлы другой. Или, если в одной из них происходит утечка памяти, это может привести к тому, что все программы на сервере перестанут работать, поскольку одно займёт всю доступную память. Также возможна ситуация, когда приложение заполнит логами весь жёсткий диск, что приведет к сбоям всех остальных.
Изоляция через chroot
Одним из первых способов решения этих проблем стал запуск приложений в chroot-окружении. В Unix-подобных системах действует принцип «всё есть файл», поэтому работающую систему можно поделить на две части: ядро ОС и корневая файловая система. Утилита chroot позволяет изменить точку монтирования корневой файловой системы, создавая новое окружение для приложений.
Как работает chroot
С помощью chroot можно сделать следующее.
1. Создать минимальную корневую файловую систему в отдельной директории.
2. Переместить туда библиотеки, необходимые приложению.
3. Использовать утилиту chroot для подмены корневой файловой системы и запуска приложения, которое «думает», что работает в своей системе.
4. Запускать приложение в изолированном окружении chroot.
Преимущества использования chroot
В результате применения chroot.
Приложение работает в изолированной среде.
Конфликтов между библиотеками разных приложений нет, так как они хранятся в разных директориях.
Приложение не знает, что оно работает в общей системе, и «думает», что запущено в отдельной операционной системе.
Одно приложение не может получить доступ к файлам другого.
Кроме того, директорию с окружением можно архивировать. Если такой архив передать другим разработчикам или системам, им не нужно будет разбираться во всех тонкостях настройки приложения. Это также обеспечивает идентичное окружение в тестовой и производственной средах, исключая ошибки, связанные с различиями в конфигурациях, и облегчает автоматизацию настройки окружения.
Проблемы и ограничения chroot
Но не всё так просто, и, помимо нерешённых проблем, возникают новые.
Программа по-прежнему видит процессы других приложений на сервере.
Нет управления ресурсами, и утечки памяти одной программы могут привести к сбою всех остальных на сервере.
Архивы окружений могут занимать много места, и не всегда удобно пересылать их, если изменения касаются всего лишь нескольких килобайт данных.
Развитие технологий изоляции: Jail и LXC
В ответ на описанные выше проблемы в операционной системе FreeBSD появилась система виртуализации Jail, которая использует системный вызов chroot для изоляции приложений. А в ОС Linux были созданы подсистемы namespaces и cgroups.
Namespaces: обеспечивают изоляцию процессов, файловых систем, сетевых интерфейсов и других ресурсов.
Cgroups: позволяют управлять квотами на CPU, память и сеть, делая так, что процессы не могут использовать больше выделенных им ресурсов.
На базе этих двух подсистем была создана LXC (Linux Containers), которая позволила запускать контейнеры в изолированных окружениях. LXC до сих пор используется наряду с системой Jail. Компания Ubuntu разработала инструмент LXD для управления контейнерами LXC.
Появление Docker
Компания Docker тоже решила создать собственное решение для контейнеризации. Первоначально Docker был построен на LXC, но в последующих версиях разработчики отказались от LXC и начали напрямую использовать namespaces и cgroups. Docker добавил несколько полезных инструментов, которые сделали его популярным.
Унифицированный способ сборки образов через Dockerfile.
Единое хранилище образов — hub.docker.com.
Git-подобный синтаксис для работы с образами и контейнерами.
Использование слоёв (layers) для эффективного хранения образов.
Dockerfile: инструкции для создания образа
Dockerfile — это файл с инструкциями, которые описывают, как собрать образ. Помимо комментариев, он содержит команды для сборки образа. Для идентификации образов используются хэш-идентификаторы (sha256) или теги — символические имена, которые можно присваивать одному и тому же образу.
К наиболее часто используемым командам Dockerfile относятся:
FROM: указывает базовый образ, на основе которого будет собран новый образ. Очень часто берут уже подготовленный кем-то шаблон с набором общих библиотек и инструментов, добавляя только специфичные команды. Однако существует специальная конструкция FROM SCRATCH, где SCRATCH — это заглушка, то есть шаблон, в котором нет никаких данных. Таким образом, сборку базового образа можно представить следующим образом: берётся SCRATCH-образ, в который копируются файлы базового образа (подготовленные, например, утилитой debootstrap в ОС семейства Debian или yum в RedHat-подобных системах);
WORKDIR: задаёт рабочую директорию внутри образа. По умолчанию равно »/».
RUN: выполняет команды внутри контейнера. Если в командах используются относительные пути, то текущая директория определяется командой WORKDIR;
CMD / ENTRYPOINT: задаёт команду, которая будет выполнена при запуске контейнера. CMD обычно используется, чтобы можно было переопределить параметры или команду запуска. На собеседованиях часто спрашивают, в чём различие между этими двумя командами (я сам иногда задаю этот вопрос). Конечно, можно углубиться в обсуждение двух режимов запуска — через exec или shell. Но если упростить, команда, указанная в ENTRYPOINT, всегда имеет приоритет перед CMD, и переопределить её несколько сложнее, чем CMD. Что выбрать при создании образов? Каждый сам решает, какой способ лучше соответствует поставленной задаче. Относительные пути в CMD / ENTRYPOINT задаются относительно WORKDIR;
ADD / COPY: эти инструкции копируют файлы снаружи внутрь образа. В отличие от COPY, инструкция ADD позволяет не только копировать файлы в образ, но и загружать их из сети;
ENV / ARG: задают переменные окружения для сборки или запуска контейнера. ARG существует только в процессе сборки образа, а ENV — и во время работы контейнера;
LABEL / MAINTAINER / EXPOSE: указывают информацию об образе, его создателе и портах, которые использует контейнер. Важно понимать, что EXPOSE не пробрасывает порт при старте контейнера, это всего лишь отметка о том, что для работы приложения требуется этот порт.
Каждая команда Dockerfile создаёт новый слой образа. Образ — это указатель на определённый набор слоёв. Docker хранит два типа данных.
Такая структура позволяет эффективно управлять образами.
Скачиваются только отсутствующие слои.
Слои можно переиспользовать для разных образов.
Если разместить команду, которая редко меняет данные, в Dockerfile выше, можно ускорить сборку образа, так как команда будет выполняться редко и docker будет использовать чаще уже закешированный готовый слой.
Docker предоставляет команды для работы с образами:
`docker images` — просмотр локально сохранённых образов;
`docker rmi` — удаление образов;
`docker inspect` — просмотр манифеста образа;
`docker save` / `docker load` — экспорт и импорт образов в виде tar-архивов;
`docker build` — сборка образа по Dockerfile;
`docker tag` — присвоение образу символического имени;
`docker pull` — загрузка образа из удалённого репозитория;
`docker push` — загрузка локального образа в удалённый репозиторий.
Контейнеры Docker
Контейнер — это запущенный образ, в котором выполняется процесс. Когда запускается контейнер, Docker делает следующее:
1. «Распаковывает» и объединяет слои образа в отдельную директорию (используя файловую систему overlayFS);
2. Выполняет команду `chroot` для изоляции процесса в этой директории;
3. Ограничивает процесс с помощью namespaces и cgroups.
Для работы с контейнерами используются команды:
`docker run` — создать и запустить контейнер;
`docker start` / `docker stop` — запустить или остановить контейнер;
`docker ps` — показать список контейнеров;
`docker exec` — выполнить команду внутри контейнера;
`docker rm` — удалить контейнер;
`docker logs` — просмотреть логи контейнера;
`docker stats` — аналог `htop` для контейнеров;
`docker cp` — скопировать файлы внутрь или из контейнера.
При использовании команды docker run часто указывают следующие опции:
»-v» — пробросить директорию внутрь контейнера. Поскольку контейнер эфемерен и может быть остановлен в любой момент, проброс директории обеспечивает сохранность файлов;
»-p» — пробросить порт контейнера;
»-e» — задать переменную окружения.
Для флагов »-v» и »-p» аргумент имеет вид «снаружи»: «внутри». Например, аргумент »-v /opt/svc/data:/app/data» пробрасывает директорию /opt/svc/data на внешней ОС в директорию /app/data внутри контейнера. С портами аналогично: флаг »-p 8080:8090» означает, что порт 8090 внутри контейнера будет пробрасываться на порт 8080 снаружи.
Docker Compose: работа с несколькими контейнерами
Docker Compose — это утилита, упрощающая работу с множеством контейнеров. Приложение может состоять из нескольких контейнеров, таких как база данных, кеш, Nginx в качестве балансировщика нагрузки и сервис, выполняющий бизнес-логику. Для запуска такого приложения нужно выполнить несколько команд docker run, каждая из которых включает десятки аргументов, несколько портов, а также требует проброса нескольких директорий. В итоге запуск превращается в набор длинных команд, с которыми неудобно работать. Для решения этой проблемы была создана утилита Docker Compose. Вместо использования длинных команд docker run все параметры запуска выносятся в файл docker-compose.yaml.
Пример структуры файла `docker-compose.yaml`:
```yaml
services:
my-db:
container_name: db
image: my-repo.domain.com/repo/db:2.4.0
environment:
ARG1: VAL1
volumes:
- /opt/data:/app/data
ports:
- "5432:5432"
my-service:
container_name: service
image: my-repo.domain.com/repo/svc:1.2.0
environment:
DB_URL: db
ports:
- "8080:8080"
- "9090:8090"
```
Для упрощения управления версиями и настройками можно использовать переменные. Переменные задаются в файле `.env`, а в `docker-compose.yaml` они указываются через `${VAR_NAME}`.
Docker Compose предоставляет следующие команды:
`docker compose up` / `docker compose up -d` — запустить или обновить все сервисы, флаг »-d» указывает, что запуск надо выполнять в фоне;
`docker compose ps` — показать список запущенных контейнеров;
`docker compose logs` — посмотреть логи сервисов;
`docker compose down` — остановить и удалить контейнеры;
`docker compose rm` — удалить контейнер.
Ограничения Docker Compose
Несмотря на удобство Docker Compose, у него есть ограничения.
Он не поддерживает балансировку нагрузки и не умеет запускать контейнеры на разных серверах.
Проверка состояния (health checks) ограничена, и для автоматического восстановления требуется использовать сторонние инструменты, например, [docker-autoheal](https://github.com/willfarrell/docker-autoheal).
Конструкция `depends_on` не учитывает задержку запуска сервисов внутри контейнеров, она ждёт, когда запустится контейнер, а не сервис в нём.
Итоги
Docker Compose — удобный инструмент для локального развёртывания сервисов, но для задач оркестрации на продакшене часто применяются более сложные инструменты, такие как Kubernetes. Он позволяют автоматически управлять, масштабировать и восстанавливать сервисы в больших системах. Про него поговорим в следующей статье.