RE:NODE

VDS13 мин чтения

Docker Compose для небольших стеков: один файл, три сервиса

Прокси, приложение и база данных из одного compose-файла: сети, тома, env-файлы, healthcheck, обновления и откаты на одном сервере.

0 прочтений

Compose - это текстовый файл, описывающий несколько контейнеров и связи между ними, и команда, которая приводит действительность в соответствие с файлом. На одном сервере это стоит больше, чем кажется. Без него стек из трёх контейнеров - это три строки docker run с двадцатью флагами на каждую, которые через шесть недель вспоминаются неверно. С ним всё сводится к docker compose up -d, конфигурация - это файл, который можно хранить в git, а пересборка стека на новой машине - это git clone и одна команда.

Compose не является планировщиком. Он не перенесёт контейнер на другой хост, не перезапустит то, что стало нездоровым, и не имеет мнения о простоях. На одном VDS ничего из этого не потеря, потому что хост один, а альтернативой был shell-скрипт. В этой статье собирается реалистичный стек - обратный прокси, приложение и база данных PostgreSQL, - а затем разбирается то, что решает, переживёт ли он год: где лежат данные, что удаляет down, как попадают секреты и что делать, когда он не поднимается.

Что такое Compose и какой файл он читает#

Compose v2 - это плагин, вызываемый как docker compose с пробелом. Старый docker-compose на Python с дефисом - отдельная, снятая с поддержки программа; если в руководстве стоит дефис, оно старше нынешних инструментов, хотя формат файла в основном тот же.

Compose ищет по порядку: compose.yaml, compose.yml, docker-compose.yaml, docker-compose.yml. Первое имя - нынешнее соглашение, а последнее - то, что есть у всех. Работает любое. Не приносит пользы строка version: "3.8" в начале старых файлов: в Compose v2 она устарела, игнорируется и вызывает предупреждение. Удалите её.

Одно понятие нужно усвоить, прежде чем файл станет понятен, - проект. Compose группирует всё, что создаёт, по имени проекта, которое по умолчанию совпадает с именем каталога. compose.yaml в /srv/notes даст контейнеры notes-app-1 и notes-db-1, сеть notes_default и том notes_dbdata. Два проекта в двух каталогах не пересекаются. Поэтому же перенос или переименование каталога может внезапно заставить Compose решить, что перед ним совершенно новый стек без томов, - зафиксируйте имя ключом name: верхнего уровня, если каталог, возможно, будет переезжать.

Один файл, три сервиса#

Вот стек, который действительно работает: Caddy спереди для TLS, приложение на Node и PostgreSQL за ними обоими.

compose.yaml
name: notesservices:  proxy:    image: caddy:2    restart: unless-stopped    ports:      - "80:80"      - "443:443"    volumes:      - ./Caddyfile:/etc/caddy/Caddyfile:ro      - caddy_data:/data      - caddy_config:/config    depends_on:      - app  app:    image: ghcr.io/example/notes:1.4.2    restart: unless-stopped    env_file:      - app.env    environment:      DATABASE_URL: postgres://notes:${DB_PASSWORD}@db:5432/notes      NODE_ENV: production    depends_on:      db:        condition: service_healthy  db:    image: postgres:17    restart: unless-stopped    environment:      POSTGRES_USER: notes      POSTGRES_PASSWORD: ${DB_PASSWORD}      POSTGRES_DB: notes    volumes:      - dbdata:/var/lib/postgresql/data    healthcheck:      test: ["CMD-SHELL", "pg_isready -U notes -d notes"]      interval: 10s      timeout: 5s      retries: 5      start_period: 30svolumes:  dbdata:  caddy_data:  caddy_config:

И конфигурация прокси, которую он подключает; она занимает три строки, потому что Caddy сам получает и продлевает сертификат, как только DNS-имя указывает на машину:

Caddyfile
notes.example.com {    encode gzip    reverse_proxy app:3000}

Обратите внимание на то, чего нет. У приложения нет записи ports:, и у базы данных тоже. Ни то, ни другое вообще недоступно снаружи машины. Публикует что-либо только прокси, и публикует те два порта, которых интернет уже ждёт.

TLS на 443http://app:3000TCP 5432Интернетпорты 80 и 443Caddyпубликует 80 и 443Приложение Nodeслушает :3000PostgreSQLтом dbdata
Одна публичная дверь перед двумя закрытыми сервисами

Сеть и почему порт публикует только прокси#

Compose создаёт одну сеть на проект и подключает к ней каждый сервис. В этой сети встроенный DNS Docker разрешает имя каждого сервиса в его контейнер, так что app достаёт до базы данных по имени хоста db на порту 5432 вообще без настройки, а прокси достаёт до приложения по app:3000. Никому не нужно знать IP-адрес, тем более что адреса всё равно меняются при каждом пересоздании.

Важное следствие: порты между сервисами в одной сети Compose уже открыты. ports: нужен не для того, чтобы контейнеры разговаривали друг с другом; он пробивает дыру с хоста в контейнер. Каждая запись ports: - это новый публичный слушатель, поэтому добавляйте их только для парадной двери.

Это же решает проблему, на которой люди спотыкаются в первый раз. Опубликованный порт обходит UFW: ports: - "5432:5432" у базы данных делает PostgreSQL доступным из интернета даже при брандмауэре с запретом по умолчанию, потому что пакет пересылается в контейнер, а не доставляется хосту. Полный механизм описан в руководстве по брандмауэру UFW, а краткая версия - в статье Docker на VDS. Если порт всё же нужно опубликовать для отладки, публикуйте его на loopback - "127.0.0.1:5432:5432" - и подключайтесь через SSH-туннель.

Если вы предпочитаете nginx вместо Caddy, форма та же, только файл конфигурации длиннее, с заголовками, которые нужны проксируемому приложению, чтобы увидеть настоящего клиента. Этот блок server есть в руководстве по nginx как обратному прокси, а статья что делает обратный прокси объясняет, зачем вообще существует X-Forwarded-For.

Переменные окружения и два вида env-файлов#

Тут путаются почти все, потому что есть два механизма со схожими названиями, делающие разную работу.

Файл `.env` в каталоге проекта читает сам Compose перед запуском чего бы то ни было, и его значения подставляются вместо плейсхолдеров ${VAR} внутри compose.yaml. Он настраивает файл, а не контейнеры. Если в .env лежит DB_PASSWORD=hunter2, то ${DB_PASSWORD} в compose-файле становится этим значением.

`env_file:` у сервиса передаёт этому контейнеру файл со строками KEY=value как переменные окружения. Compose внутрь него никогда не заглядывает. Здесь место конфигурации приложения.

.env - read by Compose, substituted into compose.yaml
DB_PASSWORD=a-long-random-stringAPP_TAG=1.4.2
app.env - handed to the app container
SESSION_SECRET=another-long-random-stringSMTP_HOST=smtp.example.comLOG_LEVEL=info

Оба файла содержат секреты, поэтому обоим нужен chmod 600 и обоим место в .gitignore, а в репозитории лежит app.env.example со списком ключей без значений. Переменные, заданные в environment:, переопределяют всё из env_file: - именно этот приоритет стоит помнить, когда значение загадочным образом не совпадает с файлом.

Проверьте, что Compose на самом деле разрешил, прежде чем винить приложение:

bash
$ docker compose config$ docker compose config --services

docker compose config печатает полностью слитый и интерполированный файл. Пустое значение там, где вы ждали пароль, означает, что файл .env лежит не там, где его ищет Compose, а это почти всегда потому, что вы запустили команду не из того каталога. Более широкий вопрос о том, что вообще должно быть переменной окружения, разобран в статье переменные окружения и секреты.

Для всего, что не хочется держать в окружении процесса, Compose поддерживает секреты в файлах: блок secrets: называет файл на хосте, а сервис получает его смонтированным только для чтения по адресу /run/secrets/<name>. Многие образы принимают вариант переменной пароля с суффиксом ..._FILE именно для того, чтобы вы могли указать туда.

Тома: что должно пережить down#

Каждый сервис в примере выше одноразовый, кроме того, что лежит в именованных томах. Это сделано намеренно, и именно эту модель нужно держать в голове: контейнеры - это скот, тома - это стадо.

ПутьВидПочему
dbdata:/var/lib/postgresql/dataименованный томБаза данных. Потеряв её, вы теряете всё
caddy_data:/dataименованный томВыпущенные сертификаты и ключи аккаунта ACME
./Caddyfile:/etc/caddy/Caddyfile:robind mountВы его правите, поэтому держите в репозитории

О caddy_data легко забыть, и потерять его неприятно: без него каждое пересоздание прокси запрашивает сертификаты заново, а удостоверяющие центры ограничивают выпуск по домену в неделю. Несколько небрежных пересборок, и вы на несколько дней лишены новых сертификатов на домене, который обслуживает настоящий трафик.

Бэкапы - это дамп базы данных по расписанию, а не копия каталога данных в момент, когда в него пишут:

bash
$ docker compose exec -T db pg_dump -U notes -Fc notes > /srv/backups/notes-$(date +%F).dump$ docker compose exec -T db psql -U notes -d notes < /srv/backups/restore-test.sql

-T важен: без него exec выделяет псевдотерминал и портит перенаправленный бинарный дамп преобразованием концов строк. Поставьте первую строку в cron или таймер systemd, копируйте результаты за пределы машины, а затем восстановите один из них где-нибудь безобидном, чтобы убедиться, что файл настоящий. Бэкап, который никто не восстанавливал, - это гипотеза; подробнее об этом в статье бэкапы и восстановление баз данных.

depends_on, healthcheck и порядок запуска#

depends_on в короткой форме управляет только порядком запуска. Он ждёт, пока контейнер будет создан и запущен, а не пока программа внутри него будет готова отвечать. Контейнеру PostgreSQL при первом запуске нужно несколько секунд на инициализацию, поэтому приложение, которое подключается при загрузке, упадёт на базе данных, которая формально уже работает.

Длинная форма решает это ожиданием healthcheck:

yaml
    depends_on:      db:        condition: service_healthy

Для этого база данных должна его определять, как в примере это делает pg_isready. Четыре параметра стоит понимать: interval - как часто выполняется проверка, timeout - сколько может длиться одна попытка, retries - сколько подряд неудач делают сервис нездоровым, а start_period - льготное окно в начале, в течение которого неудачи не считаются. Базе данных, которая при первом запуске восстанавливает большой дамп, нужен щедрый start_period, иначе её объявят нездоровой в тот момент, когда она делает ровно то, что должна.

Доступны три условия: service_started (поведение короткой формы по умолчанию), service_healthy и service_completed_successfully; последнее - способ запустить задачу миграции до старта приложения.

Даже при всём этом пишите приложение так, чтобы оно повторяло подключение к базе с небольшой задержкой. Порядок запуска решает первые тридцать секунд; перезапуск базы в три часа ночи - другой случай, а сервис, который навсегда завершается из-за одной неудачи подключения, - это сервис, которому в три часа ночи нужен человек. Про сторону схемы в той же проблеме рассказывает статья миграции без простоя.

Команды, которые вы будете набирать на самом деле#

bash
$ docker compose up -d              # создать или обновить всё, в фоне$ docker compose ps                 # что запущено и в каком состоянии$ docker compose logs -f --tail 100 app$ docker compose exec app sh        # шелл в работающем контейнере$ docker compose run --rm app npm run migrate$ docker compose restart app        # перезапуск без пересоздания$ docker compose stop               # остановить, всё сохранить$ docker compose down               # удалить контейнеры и сеть

up -d идемпотентна и является командой, которой вы пользуетесь почти для всего. Она сравнивает файл с действительностью и пересоздаёт только те сервисы, чьё описание изменилось, а значит, правка тега образа у одного сервиса и up -d затронут только его и оставят остальные работать.

Разница между exec и run ловит людей: exec заходит в уже работающий контейнер, а run запускает новый по тому же описанию. Используйте run --rm для разовых задач вроде миграции или управляющей команды, а exec - чтобы посмотреть что-то вживую.

Ещё две вещи, которые стоит знать. docker compose pull скачивает новые образы, не трогая работающий стек, так что вы можете скачать в удобный момент, а переключиться в более тихий. А docker compose --profile debug up -d запускает сервисы, помеченные ключом profiles:, - так можно держать в том же файле админский инструмент или GUI для базы данных, не запуская их постоянно.

Обновление, откат и релиз, о котором вы пожалеете#

Обновление - это две команды, а безопасным его делает тег в файле:

bash
$ docker compose pull app$ docker compose up -d app

Поскольку образы зафиксированы на 1.4.2, а не на latest, pull скачивает что-то новое только тогда, когда вы отредактировали файл, - в этом и смысл. Деплой тогда - это правка одной строки, коммит и up -d. Откат - та же правка в обратную сторону, и он работает, потому что старый образ вы ещё не удалили; docker image ls покажет его на месте. Храните хотя бы предыдущий тег, пока новый не проработает сутки.

Две вещи этот простой процесс не даёт. Есть промежуток в несколько секунд, пока старый контейнер останавливается, а новый запускается, и за это время у прокси нет того, с кем говорить, а посетители получают 502. Для домашнего стека это приемлемо; если нет, шаблоны, закрывающие этот промежуток, разобраны в статье деплой без простоя на небольшом сервере. И база данных не версионируется вместе с приложением, поэтому релиз, требующий изменения схемы, нуждается в миграции отдельным шагом, лучше такой, которая безопасна и для старого, и для нового кода.

Чтобы стек поднялся после перезагрузки, обычно достаточно restart: unless-stopped у каждого сервиса, поскольку демон Docker стартует при загрузке и тянет их за собой. Если вы хотите привязать стек к порядку запуска самой машины или чтобы он дожидался смонтированной файловой системы, оберните docker compose up -d в небольшой unit systemd: файл есть в статье сервисы systemd для ваших приложений.

Когда стек не поднимается#

`service "app" depends on undefined service db`. Опечатка или ошибка в отступах. YAML чувствителен к пробелам, соглашение - два пробела, а табуляция где угодно в файле - синтаксическая ошибка. docker compose config ловит и то, и другое до того, как вы что-либо запустите.

Port is already allocated. Порт хоста занят другим процессом или другим проектом Compose. Назовёт его sudo ss -lntp. Два проекта не могут одновременно публиковать порт 443; для этого и нужен прокси.

Приложение не может разрешить `db`. Оба сервиса должны быть в одном проекте и в одной сети. Если вы разнесли стек по двум compose-файлам, получится две сети: объявите внешнюю сеть в обоих или держите всё в одном файле.

База данных каждый раз стартует с нуля. Том подключён не там, где его ждёт образ, поэтому данные уходят в записываемый слой контейнера и погибают вместе с ним. docker compose exec db ls -la /var/lib/postgresql/data должна показать файлы, а docker volume ls - том с префиксом проекта.

Смена `POSTGRES_PASSWORD` ничего не изменила. Официальные образы баз данных используют эти переменные только при инициализации пустого каталога данных. На существующем томе пароль остаётся таким, каким был в первый день, а меняют его через ALTER USER внутри базы данных.

Всё здорово, а сайт возвращает 502. Прокси обращается не на тот порт или не по тому имени. Проверьте, что приложение действительно слушает 0.0.0.0 внутри контейнера, а не 127.0.0.1, потому что loopback контейнера не является общим с loopback прокси.

Кончилось место на диске. Старые образы и кэш сборки. docker system df покажет, куда оно ушло; docker image prune -a и docker builder prune безопасно освобождают его. Не используйте флаг --volumes, если не уверены на сто процентов.

Такой стек комфортно помещается в небольшой VDS: прокси почти ничего не нужно, PostgreSQL вполне доволен гигабайтом для небольшого сайта, если его настроить, а приложение - какое есть. Сторона настроек - в статье настройка PostgreSQL для небольших серверов. То, что VDS даёт и чего не даст управляемый тариф, - именно это: ядро, демон Docker и root, так что стек из вашего файла - это стек, который работает.

FAQ#

Не избыточен ли Compose для одного контейнера?

Немного, но всё равно окупается. Файл с одним сервисом фиксирует флаги, которые иначе пришлось бы набирать снова, сводит обновления к двум командам и ничего не стоит во время работы. Как только вы добавляете базу данных, это становится очевидным способом.

Использовать build: или готовый образ?

Если можете, собирайте в другом месте и тяните образ с тегом. Сборка на сервере привязывает деплой к процессору и диску машины, заполняет кэш сборки, а неудавшаяся сборка оставляет вас с ничем не работающим. Сборка на месте приемлема для небольшого проекта, если вы храните предыдущий образ.

Удаляет ли docker compose down мою базу данных?

Сама по себе - нет. Она удаляет контейнеры и сети, а именованные тома оставляет. docker compose down -v удаляет тома, и вот это действительно уничтожает базу данных без запроса подтверждения.

Как запустить несколько стеков на одном сервере?

По каталогу на каждый, по одному compose.yaml на каждый, и только один из них публикует порты 80 и 443. Вынесите общий прокси в отдельный проект во внешней сети, к которой присоединяются остальные, или дайте каждому приложению свой внутренний порт и маршрутизируйте по имени хоста в единственном прокси.

Можно ли использовать Compose на управляемом тарифе хостинга?

Нет. Compose управляет демоном Docker, а управляемый игровой или прикладной тариф сам является контейнером без такового. Это одна из самых ясных причин перейти на VDS, где демон и учётная запись root принадлежат вам.

Что заменит Compose, когда этот стек вырастет?

Обычно ничего, и надолго. Один сервер с горсткой сервисов - ровно тот размер, для которого создан Compose. Когда вам по-настоящему нужно больше одной машины, следующий шаг - планировщик, а следующий за ним - команда, которая его сопровождает; и то, и другое - большие расходы, которые легко взять на себя слишком рано.


Комментарии

Полностью анонимно: без аккаунта, без почты, без cookie. Мы храним имя, которое вы ввели, текст и время - больше ничего. Количество ссылок ограничено, разметка не отображается.

0/2000