gitaspen docs

Запуск приложения на сервере: файл compose, сети, проверки

Как довести сервер от состояния «Docker установлен, код и значения на месте» до состояния «приложение работает, шлюз отвечает на 127.0.0.1:8000, наружу не смотрит ни одна часть». Это то исходное состояние, которое требуют HTTPS для домена и релиз и выкат: оба начинают с работающего шлюза на loopback.

22 минуты

Как довести сервер от состояния «Docker установлен, код и значения на месте» до состояния «приложение работает, шлюз отвечает на 127.0.0.1:8000, наружу не смотрит ни одна часть». Это то исходное состояние, которое требуют HTTPS для домена и релиз и выкат: оба начинают с работающего шлюза на loopback.

Раскладку слоёв — какой вход к чему ведёт и что публикуется — задаёт сетевой контур. Здесь она записывается в файл compose и проверяется командами.

Что нужно до начала:

УсловиеПроверкаОжидается
Docker и Compose работаютdocker compose versionDocker Compose version v2.…
ограничен размер журналовdocker info --format '{{.LoggingDriver}}'json-file
на сервере лежит каталог продукта с описаниями образовls /opt/appкаталоги частей и файл compose.yml
значения заполнены и закрытыstat -c '%a %U %n' /opt/app/app/.secrets/.env600 <учётная запись выката>
порт публикации свободенsudo ss -ltn | grep :8000пустой вывод
команды выполняются без sudodocker psтаблица контейнеров без ошибки прав

Непустой вывод пятой проверки означает, что порт занят другим процессом или прежним запуском: до его освобождения docker compose up завершится ошибкой привязки.

Место в цепочке

Откуда пришлиЭтот документКуда ведёт
Docker на сервере — движок и правило публикации портов; секреты — заполненный .secrets/.env рядом с каждой частьюсостав частей, сети, порядок старта, ограничения, запуск и обновлениеHTTPS для домена — вход и TLS; затем релиз и выкат

Что предыдущее звено обязано обеспечить: работающий docker compose, ротацию журналов и правило публикации — порт публикуется с явным адресом 127.0.0.1. Правило принципиально: Docker добавляет свои правила в iptables раньше правил межсетевого экрана, поэтому публикация без адреса открывает порт в интернет, даже если ufw его запрещает (разбор — в документе о Docker, шаг 4).

Что этот документ оставляет следующему: шлюз приложения отвечает на 127.0.0.1:8000, ни одна другая часть порт на хосте не занимает, у каждой части есть служебная ручка готовности. На этом стоит следующий шаг: nginx хоста проксирует ровно на этот адрес, и его проверка (curl -I http://127.0.0.1:8000) без работающего шлюза не проходит.


Состав: что поднимается и что смотрит наружу

Приложение на сервере — это несколько частей в одном файле compose. Наружу из них не смотрит ни одна: единственная опубликованная часть — шлюз, и опубликован он на loopback.

Код
nginx хоста                          порт 443, TLS — вне файла compose
  │  proxy_pass → 127.0.0.1:8000
  ▼
gateway     ports: "127.0.0.1:8000:80"      единственная публикация
  │  сеть app_net
  ▼
app         expose: "8000"                  публикации нет
  │  сеть data
  ▼
db          публикации нет, данные в томе
ЧастьРольПубликацияОписание роли
gatewayмаршруты, CORS, лимиты, своя ручка /health127.0.0.1:8000:80BMGP
appпредметная логиканет, только exposeBMBP
dbхранилищенетбаза данных
брокер событий (если есть)доставка сообщений между частяминетобмен сообщениями
nginx перед частью (если часть на своём сервере)вход со стороны частной сетинетсетевой контур

Отдельный nginx перед app в этой раскладке не нужен: у части один вход — шлюз, и он же и есть её nginx. Правило «сколько входов, столько и nginx» из сетевого контура добавляет второй nginx тогда, когда появляется второй вход — обращение из частной сети с другого сервера (раздел «Когда часть живёт на своём сервере»).

Один файл compose или несколько

Размещение частейКак описываетсяЧем обеспечен порядок старта
все части на одной машинеодин файл compose.yml на продуктdepends_on с условием внутри файла
части на разных машинахсвой файл compose рядом с каждой частьюничем: depends_on действует только внутри одного файла

Дальше разбирается первый случай — он же и требуется для настройки домена. Во втором случае порядок старта заменяется повторными попытками подключения в коде: часть, стартовавшая раньше соседа, обязана дождаться его, а не завершиться.


Шаг 1. Файл compose: части, сети, публикация

Файл кладётся в корень каталога продукта на сервере: /opt/app/compose.yml. Рядом — каталоги частей с описаниями образов и .secrets/ у каждой:

Код
/opt/app/
├── compose.yml
├── .env                     # параметры запуска: метки образов, номер порта публикации
├── gateway/
│   ├── container            # описание образа
│   ├── conf.d/              # маршруты шлюза
│   └── .secrets/
└── app/
    ├── container
    └── .secrets/.env
yaml
# /opt/app/compose.yml
name: app-stack

networks:
  edge:                       # шлюз ↔ хост: единственная сеть с выходом наружу
  app_net:
    internal: true            # шлюз ↔ приложение
  data:
    internal: true            # приложение ↔ база

volumes:
  db_data:

services:
  gateway:
    build: { context: ./gateway, dockerfile: container }
    image: gateway:${GATEWAY_VERSION:-local}
    restart: unless-stopped
    ports:
      - "127.0.0.1:8000:80"   # доступен только процессам этой машины
    environment:
      APP_UPSTREAM: http://app:8000
    depends_on: [app]
    networks: [edge, app_net]

  app:
    build: { context: ./app, dockerfile: container }
    image: app:${APP_VERSION:-local}
    restart: unless-stopped
    expose: ["8000"]          # порт объявлен; на хосте не публикуется
    env_file:
      - ./app/.secrets/.env
    volumes:
      - ./app/.secrets:/app/.secrets:ro
    depends_on: [db]
    networks: [app_net, data]

  db:
    image: postgres:17-alpine
    restart: unless-stopped
    env_file:
      - ./db/.secrets/.env    # имя базы, пользователь, пароль
    volumes:
      - db_data:/var/lib/postgresql/data
    networks: [data]

restart: unless-stopped поднимает части после перезагрузки сервера; без него запущенным остаётся только сам Docker (см. документ о Docker, шаг 5).

ports и expose: почему часть не публикует порт вовсе

Разница директив разобрана в документе о Docker (шаг 4). Здесь важны два следствия.

Публикация обходит межсетевой экран. Опубликованный без адреса порт доступен из интернета независимо от правил ufw. Поэтому публикация — не «удобство для отладки», а создание входа, у которого нет ни TLS, ни маршрутов, ни проверок шлюза.

expose ничего не открывает и ничего не ограничивает. Соседи по общей сети видят любой порт контейнера и без этой строки; она нужна как объявление, чем именно часть отвечает. Недоступность части снаружи обеспечивают две другие вещи: отсутствие ports и раскладка сетей.

Внутренние сети и internal: true

Сеть с internal: true не имеет выхода наружу: контейнеры в ней не обращаются в интернет, и опубликовать в такой сети порт нельзя. Отсюда раскладка: часть с публикацией (gateway) состоит в обычной сети edge, остальные связи — во внутренних.

Сети данных и входа разделены намеренно. Части соединены только там, где им нужно разговаривать:

  • gateway не состоит в сети data и не имеет маршрута к базе. Часть, до которой можно дотянуться с хоста, не может обратиться к хранилищу даже при ошибке в её конфигурации;
  • db состоит только в data: обратиться к ней может лишь app;
  • одна общая сеть на все части даёт обратное — доступность порта базы любому контейнеру продукта.

Ограничение действует в обе стороны: часть, которой нужно обращаться к внешней системе (платёжный провайдер, почтовый сервер), из сетей internal: true её не увидит — такой части нужна не-internal сеть.

Переменные и значения

Секретные значения в файл compose не пишутся: они лежат в .secrets/.env рядом с частью и подключаются через env_file и том только на чтение. Хранение, права, доставка на сервер и замена — в документе о секретах.

Файлов с расширением .env в схеме два, и это разные механизмы:

ФайлКто читаетЧто там держат
<часть>/.secrets/.envконтейнер, через env_fileзначения для приложения, в том числе секретные
/opt/app/.env рядом с compose.ymlсам Compose, для подстановки ${…}параметры запуска: метки образов, номер порта публикации

Второй файл секретов не содержит: подставленные значения печатает docker compose config.

Проверка:
cd /opt/app
docker compose config >/dev/null && echo OK      # синтаксис и подстановки
docker compose up -d
docker compose ps --format 'table {{.Service}}\t{{.Status}}\t{{.Ports}}'

Ожидается — порты указаны только у gateway и только с адресом 127.0.0.1:

Код
SERVICE   STATUS          PORTS
app       Up 2 minutes
db        Up 2 minutes
gateway   Up 2 minutes    127.0.0.1:8000->80/tcp
bash
sudo ss -ltnp | grep -E ':(8000|5432)'

Ожидается: одна строка с 127.0.0.1:8000. Строка с 0.0.0.0:8000 или порт базы в выводе означают публикацию, которой быть не должно.


Шаг 2. Проверки готовности и порядок старта

Порядок принципиален: проверки заводятся до ограничений, иначе неясно, чем вызван отказ — кодом или пределом.

depends_on в коротком виде (шаг 1) ждёт только создания контейнера соседа. Контейнер базы считается запущенным на первой секунде, а соединения она принимает позже — за это время app успевает попытаться подключиться и завершиться. С restart: unless-stopped он перезапускается по кругу, и это выглядит как «сервис не поднялся», хотя причина во времени старта.

Условие снимает неопределённость: Compose держит зависимую часть, пока условие не выполнено.

УсловиеКогда снимаетсяТребует от зависимости
service_startedконтейнер создан и запущен (значение по умолчанию)ничего
service_healthyпроверка готовности прошла успешноблок healthcheck
service_completed_successfullyконтейнер завершился с кодом 0завершающаяся задача (например, миграции)

Блоки добавляются к описаниям частей из шага 1:

yaml
  gateway:
    depends_on:
      app:
        condition: service_healthy
    healthcheck:
      test: ["CMD", "wget", "-qO-", "http://127.0.0.1/health"]
      interval: 10s
      timeout: 3s
      retries: 6
      start_period: 5s

  app:
    depends_on:
      db:
        condition: service_healthy
    healthcheck:
      test: ["CMD", "wget", "-qO-", "http://127.0.0.1:8000/health/ready"]
      interval: 10s
      timeout: 3s
      retries: 6
      start_period: 20s       # запас на миграции при первом старте

  db:
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U \"$$POSTGRES_USER\" -d \"$$POSTGRES_DB\""]
      interval: 10s
      timeout: 3s
      retries: 10
      start_period: 10s

Что означают параметры:

  • test — команда, выполняемая внутри контейнера. Инструмент должен быть в образе: в alpine-образах есть wget, curl есть не везде; для образа без обоих проверка пишется на интерпретаторе языка приложения. Двойной $$ в CMD-SHELL оставляет $ для оболочки контейнера: одиночный $ Compose счёл бы своей подстановкой;
  • interval, timeout, retries — как часто, сколько ждать ответа, сколько неудач подряд до состояния unhealthy;
  • start_period — окно после старта, в котором неудачные попытки не считаются. Без него часть с долгим первым запуском (миграции, прогрев кеша) объявляется неисправной раньше, чем успевает подняться.

Адрес в проверке — 127.0.0.1 внутри контейнера, и это корректно: команда выполняется в том же сетевом пространстве. Но слушать процесс обязан 0.0.0.0 внутри контейнера, иначе соседи по сети до него не достучатся, а проверка при этом будет проходить. Правило «привязываться к loopback» относится к процессам на хосте, а не внутри контейнера: там границу задают сети и отсутствие публикации.

Ручек две, и они отвечают разное: «жив» — процесс отвечает; «готов» — зависимости доступны, можно давать трафик (разделение описано в релизе и выкате). Ручка шлюза отвечает сама за себя, не опрашивая бэки (BMGP): иначе отказ одной части делает неисправным весь контур.

Неуспешная проверка сама по себе контейнер не перезапускает: Compose использует её при старте (depends_on) и показывает состояние в ps.

Проверка — поднять с нуля и убедиться, что порядок соблюдён:

bash
docker compose down
docker compose up -d --wait --wait-timeout 120 && echo READY
docker compose ps --format 'table {{.Service}}\t{{.Status}}'

Ожидается: READY и состояние Up … (healthy) у всех трёх частей. Ключ --wait возвращает ненулевой код, если какая-то часть не стала готовой за отведённое время, — по нему выкат отличает «поднялось» от «запустилось».

Код
SERVICE   STATUS
app       Up 40 seconds (healthy)
db        Up 55 seconds (healthy)
gateway   Up 30 seconds (healthy)

Порядок запуска виден по времени в колонке STATUS: db старше app, app старше gateway.


Шаг 3. Ограничения и права контейнера

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

yaml
  app:
    read_only: true                  # корневая ФС контейнера только на чтение
    tmpfs:
      - /tmp:rw,nosuid,nodev,noexec,size=16m
    security_opt:
      - no-new-privileges:true
    cap_drop: [ALL]
    user: "10001:10001"              # процесс не от root
    pids_limit: 128
    mem_limit: 512m
    cpus: 1.0
НастройкаЧто даёт
mem_limitпри превышении ядро убивает процесс в контейнере, а не начинает вытеснять память всей машины; отказ остаётся в одной части
cpusдоля процессорного времени: часть не занимает все ядра и не тормозит соседей
pids_limitпредел числа процессов и потоков; ограничивает разрастание при ошибке — цикл порождения процессов упирается в предел, а не в машину
read_onlyкорневая ФС только на чтение: записанный файл не переживёт перезапуск, а попытка записи видна сразу как отказ
tmpfsкаталоги, куда процесс обязан писать (кеш, /tmp, /var/run), — в памяти, с ограничением объёма; noexec запрещает исполнение из них, nosuid — повышение прав через setuid-файл
cap_drop: [ALL]снимает привилегии ядра: смена владельца файлов, изменение сетевых настроек, монтирование становятся недоступны
no-new-privilegesпроцесс не может повысить права через setuid-программу, оставшуюся в образе
userпроцесс работает не от root: файл вне тома он не перезапишет

Значения подбираются по фактическому потреблению, а не «на глаз»: docker stats --no-stream показывает память и процессор под нагрузкой. Предел ставится с запасом над наблюдаемым пиком — слишком тесный mem_limit даёт отказ, неотличимый по симптомам от утечки памяти.

Два ограничения этой раскладки:

  • порт ниже 1024 после cap_drop: [ALL] занять нельзя. Шлюз слушает 80 внутри контейнера, поэтому ему добавляется cap_add: [NET_BIND_SERVICE]. Второй вариант — слушать порт выше 1024 внутри контейнера: публикация всё равно назначает свой номер;
  • read_only подходит не всем образам. Образ базы пишет за пределами тома данных, поэтому у db корневая ФС остаётся записываемой, а ограничения сводятся к пределам ресурсов.

Проверка — часть работает с ограничениями, и они действительно применены:

bash
docker compose up -d
docker compose ps --format 'table {{.Service}}\t{{.Status}}'    # ожидается: healthy у всех

docker inspect "$(docker compose ps -q app)" \
  --format '{{.HostConfig.Memory}} {{.HostConfig.PidsLimit}} {{.HostConfig.ReadonlyRootfs}}'
# ожидается: 536870912 128 true

docker compose exec app sh -c 'touch /var/lib/probe 2>&1 || true'
# ожидается: Read-only file system

Последняя команда подтверждает, что read_only не обошли монтированием: если файл создался, каталог записываем и ограничение на него не действует.


Шаг 4. Сквозная проверка контура

Проверка идёт снизу вверх: первый слой, который не отвечает, и есть место отказа (разбор по слоям — в сетевом контуре).

bash
# 1. приложение отвечает соседям по внутренней сети
docker compose exec gateway wget -qO- http://app:8000/health/ready

# 2. шлюз отвечает на своей ручке
docker compose exec gateway wget -qO- http://127.0.0.1/health

# 3. шлюз доступен с хоста
curl -sf http://127.0.0.1:8000/health && echo OK

Ожидается: ответы служебных ручек на всех трёх и OK на третьей.

Если в образе шлюза нет ни wget, ни curl, первый запрос выполняется разовым контейнером в той же сети:

bash
docker network ls --filter name=app_net --format '{{.Name}}'    # ожидается: app-stack_app_net
docker run --rm --network app-stack_app_net curlimages/curl -sf http://app:8000/health/ready

Имя сети складывается из имени проекта (ключ name: в файле compose) и имени сети.

Проверка снаружи — с другой машины:
curl -m 5 -I http://example.com:8000     # ожидается таймаут или отказ соединения

Ответ на этот запрос означает, что шлюз опубликован на всех интерфейсах и доступен в обход TLS. Исправьте публикацию (шаг 1) и повторите: настраивать домен до этого бессмысленно.

На этом состояние, требуемое документом о HTTPS, достигнуто.


Повседневные операции

bash
cd /opt/app

docker compose up -d                        # поднять; пересоздаются только изменившиеся части
docker compose ps -a                        # состояние, включая завершившиеся, с кодом выхода
docker compose logs -f --tail=100 app       # журнал одной части
docker compose logs --since 10m             # журнал всех частей за период

docker compose stop                         # остановить; контейнеры и тома остаются
docker compose start                        # поднять остановленные
docker compose restart app                  # перезапуск процесса без пересоздания контейнера

Обновление одной части. Части обновляются по отдельности; остальные не перезапускаются:

bash
docker compose build app                    # если образ собирается здесь
docker compose pull app                     # если образ берётся из реестра
docker compose up -d --wait app

Метка образа задаётся переменной (app:${APP_VERSION:-local}), а значение хранится в /opt/app/.env рядом с файлом compose. Значение, переданное в командной строке разово, при следующем docker compose up -d не применяется — вернётся то, что записано в файле.

Чего не делает restart. Он перезапускает существующий контейнер: ни новый образ, ни изменившийся .secrets/.env при этом не перечитываются. Пересоздание — это up -d, а при неизменном описании — up -d --force-recreate <часть>.

Где искать причину, если часть не поднялась:

Что смотретьКомандаЧто видно
код выходаdocker compose ps -aExited (1) — процесс завершился сам; Exited (137) — убит по пределу памяти
вывод процессаdocker compose logs --no-log-prefix appсообщение об ошибке при старте
попытки проверки готовностиdocker inspect "$(docker compose ps -q app)" --format '{{json .State.Health}}'вывод и код возврата последних проверок
убит ли по памятиdocker inspect "$(docker compose ps -q app)" --format '{{.State.OOMKilled}}'true — предел mem_limit мал или в части утечка
итоговое описаниеdocker compose configкак Compose прочитал файл после подстановок

Пустой журнал при незапустившейся части означает, что процесс не начал работу: ошибка в команде запуска, отсутствующий файл, неверные права. Такие сообщения ищутся в docker compose ps -a и в docker inspect, а не в журнале приложения.


Когда часть живёт на своём сервере

Раскладка не меняется, меняется способ описания: у части появляется свой файл compose и свой nginx на вход со стороны частной сети. Шлюз обращается к ней по адресу из переменной окружения, а не по имени контейнера. Готовый пример такого файла — в сетевом контуре, раздел о частной сети.

Что при этом меняется в эксплуатации:

  • depends_on больше не обеспечивает порядок: части поднимаются независимо, и каждая обязана переживать недоступность соседа;
  • проверка «изнутри сети» выполняется с сервера шлюза, а не через docker compose exec;
  • сам сервис по-прежнему не публикует порт: вход к нему — только через свой nginx.

Типичные отказы

ПризнакПричинаЧто делать
часть в состоянии unhealthy, хотя процесс работаеткоманда проверки не годится: нет инструмента в образе, не тот порт или путьdocker inspect … '{{json .State.Health}}' — там вывод последних попыток
часть «поднялась», сосед получает Connection refusedпроцесс слушает 127.0.0.1 внутри контейнераслушать 0.0.0.0 внутри контейнера; loopback — правило для процессов на хосте
Name or service not known при обращении по имени частичасти в разных сетях composeдобавить общую сеть обеим
nginx не стартует: host not found in upstreamимя соседа резолвится при старте, а сосед ещё не созданdepends_on с условием готовности
bind: address already in use при upпорт хоста занят другим процессом или прежним запускомsudo ss -ltnp | grep :8000, освободить порт или сменить номер публикации
Permission denied при записи в каталог томакаталог создан от root, процесс работает под user:создать каталог заранее нужному владельцу (sudo install -d -o 10001 -g 10001 <путь>) либо перейти на именованный том
Read-only file system при записи внутри контейнераread_only: true без tmpfs для этого каталогадобавить каталог в tmpfs или вынести в том
часть перезапускается по кругустартовала раньше зависимости и завершиласьdepends_on с condition: service_healthy у зависимости
контейнер завершился с кодом 137превышен предел памятиdocker inspect … '{{.State.OOMKilled}}'; поднять mem_limit после замера docker stats
образ обновился, а работает прежняя версияметка не изменилась, контейнер не пересозданdocker compose pull <часть> && docker compose up -d <часть>; ставить метку версии, а не latest
после правки .secrets/.env поведение прежнееокружение фиксируется при создании контейнераdocker compose up -d --force-recreate <часть> (см. секреты)
часть не достучалась до внешней системыона состоит только в сетях internal: trueдобавить ей не-internal сеть
nginx не стартует: bind() to 0.0.0.0:80 failed (13: Permission denied)сняты все привилегии, порт ниже 1024cap_add: [NET_BIND_SERVICE] либо слушать порт выше 1024
сервис доступен снаружи по http://example.com:8000порт опубликован без адресавернуть 127.0.0.1: в публикацию (Docker, шаг 4)
docker compose ps пуст, хотя контейнеры работаюткоманда выполняется не из каталога с файлом composeвыполнять из /opt/app либо указать -p <имя проекта>

Откат и снятие

Новая версия части не подошла. Откат — возврат прежней метки образа и пересоздание одной части:

bash
cd /opt/app
sed -i 's/^APP_VERSION=.*/APP_VERSION=<прежняя метка>/' .env
docker compose up -d --wait app
curl -sf http://127.0.0.1:8000/health && echo OK

Условие выполнимости: прежний образ ещё лежит на машине. docker image prune до подтверждения новой версии его удаляет, и откат превращается в сборку заново. Выкат без простоя, где прежняя копия остаётся запущенной, описан в релизе и выкате.

Остановка без потери данных:

КомандаЧто удаляетЧто остаётся
docker compose stopничегоконтейнеры, сети, тома
docker compose downконтейнеры и сетиименованные тома, образы, .secrets/
docker compose down -vконтейнеры, сети и именованные томаобразы, .secrets/

Ключ -v удаляет данные базы. Для остановки он не нужен ни в каком случае; применяется только при осознанном сбросе установки и после снятия копии (резервное копирование).

Проверка после остановки:
docker compose ps -a         # ожидается: пусто после down
docker volume ls             # ожидается: том с данными на месте
sudo ss -ltn | grep :8000    # ожидается: пустой вывод

Снятие части совсем:

  1. docker compose down — остановить и удалить контейнеры;
  2. снять копию тома и убедиться, что она восстанавливается;
  3. docker volume rm <том> — удалить данные;
  4. убрать описание части из файла compose и её каталог.

Что не удаляется само: образы (docker image prune), значения в .secrets/ и копии этого каталога в резервных архивах. Если часть выводится из эксплуатации вместе с её доступами, значения ещё и отзываются на проверяющей стороне — порядок в документе о секретах.

Инструкция не помогла?

Откройте исходник документа по ссылке «Предложить правку» — там же видно, что и когда в нём менялось.