Запуск приложения на сервере: файл compose, сети, проверки
Как довести сервер от состояния «Docker установлен, код и значения на месте» до состояния «приложение работает, шлюз отвечает на 127.0.0.1:8000, наружу не смотрит ни одна часть». Это то исходное состояние, которое требуют HTTPS для домена и релиз и выкат: оба начинают с работающего шлюза на loopback.
22 минутыКак довести сервер от состояния «Docker установлен, код и значения на месте» до состояния
«приложение работает, шлюз отвечает на 127.0.0.1:8000, наружу не смотрит ни одна часть». Это то
исходное состояние, которое требуют HTTPS для домена и
релиз и выкат: оба начинают с работающего шлюза на loopback.
Раскладку слоёв — какой вход к чему ведёт и что публикуется — задаёт сетевой контур. Здесь она записывается в файл compose и проверяется командами.
Что нужно до начала:
| Условие | Проверка | Ожидается |
|---|---|---|
| Docker и Compose работают | docker compose version | Docker Compose version v2.… |
| ограничен размер журналов | docker info --format '{{.LoggingDriver}}' | json-file |
| на сервере лежит каталог продукта с описаниями образов | ls /opt/app | каталоги частей и файл compose.yml |
| значения заполнены и закрыты | stat -c '%a %U %n' /opt/app/app/.secrets/.env | 600 <учётная запись выката> |
| порт публикации свободен | sudo ss -ltn | grep :8000 | пустой вывод |
команды выполняются без sudo | docker 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, лимиты, своя ручка /health | 127.0.0.1:8000:80 | BMGP |
app | предметная логика | нет, только expose | BMBP |
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# /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/tcpsudo 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:
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.
Проверка — поднять с нуля и убедиться, что порядок соблюдён:
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. Ограничения и права контейнера
Ограничения добавляются к заведомо работающей части. Они не улучшают её работу — они локализуют отказ: ошибка в одной части не должна забирать память, процессор и число процессов у соседей и у самой машины.
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корневая ФС остаётся записываемой, а ограничения сводятся к пределам ресурсов.
Проверка — часть работает с ограничениями, и они действительно применены:
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. Сквозная проверка контура
Проверка идёт снизу вверх: первый слой, который не отвечает, и есть место отказа (разбор по слоям — в сетевом контуре).
# 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, первый запрос выполняется разовым контейнером в той
же сети:
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, достигнуто.
Повседневные операции
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 # перезапуск процесса без пересоздания контейнераОбновление одной части. Части обновляются по отдельности; остальные не перезапускаются:
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 -a | Exited (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) | сняты все привилегии, порт ниже 1024 | cap_add: [NET_BIND_SERVICE] либо слушать порт выше 1024 |
сервис доступен снаружи по http://example.com:8000 | порт опубликован без адреса | вернуть 127.0.0.1: в публикацию (Docker, шаг 4) |
docker compose ps пуст, хотя контейнеры работают | команда выполняется не из каталога с файлом compose | выполнять из /opt/app либо указать -p <имя проекта> |
Откат и снятие
Новая версия части не подошла. Откат — возврат прежней метки образа и пересоздание одной части:
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 # ожидается: пустой выводСнятие части совсем:
docker compose down— остановить и удалить контейнеры;- снять копию тома и убедиться, что она восстанавливается;
docker volume rm <том>— удалить данные;- убрать описание части из файла compose и её каталог.
Что не удаляется само: образы (docker image prune), значения в .secrets/ и копии этого каталога
в резервных архивах. Если часть выводится из эксплуатации вместе с её доступами, значения ещё и
отзываются на проверяющей стороне — порядок в документе о секретах.
Откройте исходник документа по ссылке «Предложить правку» — там же видно, что и когда в нём менялось.