Привет! Меня зовут Толя, я лидер компетенции 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 хранит два типа данных.

  • 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)


  1. xitriy87
    26.09.2024 19:30
    +1

    Ну по поводу EXPOSE не совсем точно. Если указать при запуске контейнера опцию -Р , то все порты которые указаны там будут опубликованы на хосте на произвольных непривилегированных портах


  1. jingvar
    26.09.2024 19:30

    Шо опять? Каким Макаром слои в образах относятся к управлению? А можно пример как прилепить рандомный слой к чужому образу?

    Управление образами это тегирование, что позволяет легко обновить "код" сервиса, т.е. у вас есть некая система, композы и тд, можно принести новый образ и обозвать его текущим тегом. Я не говорю что так надо делать.

    Хранение слоями как раз помогает приносить изменения с минимальной дельтой, но если слои не схлопнули.