Большинство небольших продакшен-стеков на наших тарифах VPS запускается через Docker Compose, и это хороший способ. Сожалеть потом приходится из-за настроек по умолчанию: контейнеры, которые не поднимаются после перезагрузки, логи, забивающие диск к третьему месяцу, один взбесившийся процесс, который кладёт весь сервер, и резервная копия, в которую попали контейнеры, но не данные. У каждой из этих проблем есть решение в две строки. Вот все они — в том порядке, в каком вам понадобятся.
Структура каталогов, которая переживёт вас
По одному каталогу на стек в /srv: файл Compose, .env для секретов и каталог data/ для bind-монтирований, которые вы хотите видеть. Для всего остального — именованные тома. Пусть всё будет предельно скучно:
/srv/app/
compose.yaml
.env # chmod 600, never in git
data/caddy/ # bind mounts you want to inspect
/var/lib/docker/volumes/ # named volumes, managed by Docker
Приложение Docker, устанавливаемое в один клик, ставит Engine 27 с Compose v2 и размещает данные демона на корневом NVMe-диске. Если позже вы добавите отдельный том, перенесите /var/lib/docker туда через data-root в daemon.json, а не через символическую ссылку.
Подъём после перезагрузки
Обновления ядра перезагружают сервер ночью. Каждый сервис должен явно объявить, что должно с ним произойти:
services:
app:
image: ghcr.io/example/app:1.8.2
restart: unless-stopped
env_file: .env
depends_on:
db:
condition: service_healthy
db:
image: postgres:16.4
restart: unless-stopped
volumes: [pgdata:/var/lib/postgresql/data]
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 10s
retries: 5
volumes:
pgdata:
Две привычки из этого фрагмента важнее, чем кажется. Фиксируйте теги образов на конкретной версии, никогда не используйте latest: перезагрузка, которая в 4 утра подтягивает новую мажорную версию PostgreSQL, — это авария, в которой потом не разобраться. А healthcheck-проверки с условиями depends_on не дают приложению уйти в crash-loop, пока база данных ещё запускается.
Логи, которые не забивают диск
По умолчанию Docker хранит каждую строку, которую когда-либо записал любой контейнер, в JSON и навсегда. Приложение, которое активно пишет в лог, набирает 40 GB за несколько месяцев. Исправьте это один раз для всего демона:
cat > /etc/docker/daemon.json <<'EOF'
{
"log-driver": "json-file",
"log-opts": { "max-size": "20m", "max-file": "5" },
"default-address-pools": [{ "base": "172.30.0.0/16", "size": 24 }]
}
EOF
systemctl restart docker
Существующие контейнеры сохраняют старые настройки, пока не будут пересозданы; docker compose up -d --force-recreate применит новые. Строка с пулом адресов — бонус: она не даёт сетям Compose позже пересечься с диапазоном WireGuard или офисной сети.
Один взбесившийся процесс не должен класть сервер
Без лимитов утечка памяти в одном контейнере вынуждает OOM killer ядра выбирать жертву, и нередко выбор падает на базу данных. Задайте лимиты памяти для всего — щедрые, но конечные:
deploy:
resources:
limits:
memory: 1g
cpus: "2.0"
Compose v2 учитывает deploy.resources.limits и без Swarm. Подбирайте лимиты так, чтобы их сумма была меньше объёма RAM VPS за вычетом примерно 1 GB для хоста; на VPS-4 с 8 GB это около 6.5 GB, которые нужно распределить. Когда контейнер упирается в свой лимит, перезапускается только он, а журнал событий покажет, какой именно.
Обратный прокси с автоматическим TLS
Привязывайте порты приложений только к localhost и пусть портами 80 и 443 владеет один прокси. Caddy в контейнере сам получает сертификаты и перечитывает конфигурацию при её изменении:
services:
caddy:
image: caddy:2.8
restart: unless-stopped
ports: ["80:80", "443:443", "443:443/udp"]
volumes:
- ./data/caddy/Caddyfile:/etc/caddy/Caddyfile:ro
- caddy_data:/data
app:
expose: ["3000"] # reachable by caddy on the compose network, not published
volumes:
caddy_data:
Caddyfile — это по одной строке на сайт: app.example.com { reverse_proxy app:3000 }. Публикация только портов прокси заодно обходит известную проблему, когда Docker пробивает дыры в ufw: наружу не публикуется ничего, кроме 80 и 443.
Секреты
Храните их в .env и выполните для него chmod 600, ссылайтесь на них в файле Compose как ${DB_PASSWORD} и не добавляйте .env в git, положив рядом .env.example. Если значений больше нескольких,secrets: в файле Compose, монтируемые как файлы, чище переменных окружения, которые утекают в docker inspect и отчёты о сбоях.
Обновление без сюрпризов
cd /srv/app
docker compose pull # fetches the new pinned tags you edited
docker compose up -d # recreates only what changed
docker image prune -f # frees the old layers
Поправьте тег, выполните pull, затем up. Автообновление в стиле Watchtower соблазнительно на одиночном VPS, но именно так несовместимое изменение прилетает в полночь; если вы им пользуетесь, фиксируйте минорные версии, чтобы оно могло применять только патч-релизы.
Резервные копии: тома, а не контейнеры
Контейнеры одноразовы; данные — это тома. Базу данных выгружайте дампом, а не копированием файлов, на которых она работает:
docker compose exec -T db pg_dump -U postgres -Fc app > /srv/backups/app-$(date +%F).dump
Для всего остального подойдёт tar-архив тома, снятый из одноразового контейнера: docker run --rm -v app_uploads:/v -v /srv/backups:/b alpine tar czf /b/uploads-$(date +%F).tgz -C /v .. Отправляйте /srv/backups за пределы сервера с помощью restic; рецепт приведён в материале «Резервные копии, которые действительно восстанавливаются». Снапшот из панели перед крупными обновлениями даёт «отмену» за считанные секунды.
Как узнать о поломке
docker compose ps показывает, здоровы ли контейнеры;docker stats --no-stream показывает, кто съедает RAM. Для постоянного наблюдения дополнение netdata автоматически находит контейнеры и строит графики CPU, памяти и перезапусков по каждому из них. Настройте оповещения на перезапуски: контейнер, который перезапустился три раза за час, сообщает вам о проблеме раньше, чем это сделают ваши пользователи.
Чек-лист
- Зафиксированные теги,
restart: unless-stopped, healthcheck-проверки и условияdepends_on. - Ротация логов на уровне всего демона.
- Лимиты памяти на каждом сервисе, в сумме меньше объёма RAM VPS.
- Один обратный прокси, публикующий порты 80 и 443; всё остальное — во внутренней сети.
- Секреты в
.envс правами 600, вне git. - Дампы и архивы томов каждую ночь уходят за пределы сервера, а раз в месяц восстанавливаются где-нибудь для проверки.
VPS-4, настроенный таким образом, годами обслуживает типичный стек от пяти до восьми сервисов, и никому не приходится заходить на него, — для сервера это высшая похвала.
