Привет! Меня зовут Толя, я лидер компетенции Java в Цифровом СИБУРе. Наш прошлый материал о Docker собрал классный фидбэк, поэтому мы решили развить тему и подготовить ещё несколько статей, двигаясь от простого к сложному.
В этом материале речь пойдёт о том, что помогает избежать конфликтов зависимостей и проблем с изоляцией, возникающих при запуске нескольких приложений на одном сервере. Для решения этих задач используются технологии контейнеризации, которые позволяют создавать изолированные окружения для приложений, устраняя проблемы совместимости и упрощая процесс развёртывания. Рассмотрим, как работает контейнеризация и какие инструменты помогают сделать её максимально эффективной.
Проблемы запуска приложений в одном окружении
Во время разработки программы необходимо выполнять её в средах, где используются специфичные версии библиотек и системных компонентов.
Запуск одного приложения на физическом сервере может быть слишком затратным, поэтому зачастую на одном сервере работают несколько приложений одновременно. Это приводит к следующим проблемам.
У каждого приложения свои версии зависимостей, которые могут конфликтовать между собой. Например, для одного приложения нужна системная библиотека glibc 2.19, а для другого — glibc 2.34. При этом в операционной системе glibc обновилась только до версии 2.30. Конечно, можно жёстко завязаться на определённую версию и обозначить, что запускать сможем только на ОС, например, CentOS 6-й версии, но теряем переносимость приложения.
Необходима изоляция, чтобы одно приложение не могло повредить данные другого. К примеру, одна программа периодически удаляет временные файлы и может случайно удалить временные файлы другой. Или, если в одной из них происходит утечка памяти, это может привести к тому, что все программы на сервере перестанут работать, поскольку одно займёт всю доступную память. Также возможна ситуация, когда приложение заполнит логами весь жёсткий диск, что приведет к сбоям всех остальных.
Изоляция через chroot
Одним из первых способов решения этих проблем стал запуск приложений в chroot-окружении. В Unix-подобных системах действует принцип «всё есть файл», поэтому работающую систему можно поделить на две части: ядро ОС и корневая файловая система. Утилита chroot позволяет изменить точку монтирования корневой файловой системы, создавая новое окружение для приложений.
Как работает chroot
С помощью chroot можно сделать следующее.
Создать минимальную корневую файловую систему в отдельной директории.
Переместить туда библиотеки, необходимые приложению.
Использовать утилиту chroot для подмены корневой файловой системы и запуска приложения, которое «думает», что работает в своей системе.
Запускать приложение в изолированном окружении 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 хранит два типа данных.
Layerdb: база данных слоёв
Imagedb: база данных образов
Такая структура позволяет эффективно управлять образами.
Скачиваются только отсутствующие слои.
Слои можно переиспользовать для разных образов.
Если разместить команду, которая редко меняет данные, в 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].
Конструкция `depends_on` не учитывает задержку запуска сервисов внутри контейнеров, она ждёт, когда запустится контейнер, а не сервис в нём.
Итоги
Docker Compose — удобный инструмент для локального развёртывания сервисов, но для задач оркестрации на продакшене часто применяются более сложные инструменты, такие как Kubernetes. Он позволяют автоматически управлять, масштабировать и восстанавливать сервисы в больших системах. Про него поговорим в следующей статье.
Комментарии (2)
jingvar
26.09.2024 19:30Шо опять? Каким Макаром слои в образах относятся к управлению? А можно пример как прилепить рандомный слой к чужому образу?
Управление образами это тегирование, что позволяет легко обновить "код" сервиса, т.е. у вас есть некая система, композы и тд, можно принести новый образ и обозвать его текущим тегом. Я не говорю что так надо делать.
Хранение слоями как раз помогает приносить изменения с минимальной дельтой, но если слои не схлопнули.
xitriy87
Ну по поводу EXPOSE не совсем точно. Если указать при запуске контейнера опцию -Р , то все порты которые указаны там будут опубликованы на хосте на произвольных непривилегированных портах