От Docker к Podman: миграция 15 сервисов на выделенном сервере
Много лет я арендовал виртуальные серверы у разных провайдеров. VPS вполне справлялись с задачами: несколько контейнеров, reverse proxy, мониторинг. Всё работало, и я не задумывался об альтернативах — пока в разговоре со знакомыми не узнал, что некоторые из них давно перешли на выделенные серверы. Аргументы были простые: предсказуемая производительность, отсутствие «шумных соседей» и полный контроль над железом.
Выделенный сервер
Я оформил dedicated-сервер: Xeon на 8 ядер, 32 ГБ ECC-памяти, SSD в RAID на 480 ГБ. Разница в производительности оказалась заметной сразу. Сборка образов, запуск контейнеров, отклик сервисов — всё стало ощутимо быстрее, чем на VPS с теми же заявленными характеристиками. После переноса всех сервисов на новый сервер ресурсы оставались загруженными не более чем на десятую часть. Появился простор для экспериментов.
Проблемы Docker
Изначально я настроил сервер привычным образом: Docker, docker-compose, отдельные проекты для каждой группы сервисов. Всё работало до момента, когда потребовалось перенастроить сеть на сервере. После изменения DNS-настроек хоста пришлось перезагружать Docker daemon и пересоздавать все контейнеры — Docker встраивает DNS-прокси с кешированием и не пробрасывает изменения /etc/resolv.conf в работающие контейнеры. Единственный надёжный способ применить новые настройки — рестарт dockerd, который останавливает все контейнеры разом.
Это обстоятельство высветило две фундаментальные проблемы:
- Единая точка отказа. Docker daemon — централизованный процесс. Его перезапуск или падение затрагивает все сервисы одновременно, вне зависимости от их связности.
- Работа от root. dockerd работает с привилегиями суперпользователя. Компрометация любого контейнера потенциально даёт атакующему полный контроль над хостом.
Поиск альтернатив
Для анализа возможных вариантов я использовал Claude Code. В течение нескольких сессий за несколько дней было проведено исследование с рассмотрением более 70 источников. Результатом стала развёрнутая документация с детальным сравнением подходов.
| Вариант | Суть | Итоговая оценка |
|---|---|---|
| systemd-nspawn | Системные контейнеры, встроенные в systemd | Отвергнут: отсутствие экосистемы образов, ручная сборка окружений |
| Incus (LXD) | Системные контейнеры с полным Linux | Отвергнут: двойная контейнеризация, избыточная сложность |
| NixOS / Guix | Декларативное управление системой | Отвергнут: радикальное изменение парадигмы, длительная кривая обучения |
| Podman | Drop-in замена Docker без демона | Выбран: rootless по умолчанию, совместимость с OCI, независимые процессы |
Архитектура: Docker vs Podman
Наглядная разница в управлении контейнерами.
Docker — единый демон, все контейнеры зависят от него:
Podman + Quadlet — каждый контейнер управляется systemd независимо:
Почему Podman и Quadlet
Выбор пал на Podman, причём не на podman-compose (который по сути воспроизводит парадигму docker-compose), а на Quadlet — нативную интеграцию Podman с systemd.
Quadlet превращает декларативные .container-файлы в systemd unit-файлы. Каждый сервис управляется через systemctl, имеет собственный жизненный цикл, зависимости и политику перезапуска. Это принципиально отличается от compose-подхода, где все сервисы проекта управляются как единое целое.
Преимущества:
- Нет демона — нет единой точки отказа
- Rootless — контейнеры работают от обычного пользователя
- Независимые процессы — перезапуск одного сервиса не затрагивает остальные
- DNS задаётся явно в настройках сети, не наследуется от хоста
- Стандартные инструменты:
systemctl,journalctlдля управления и логов
Подготовка
Прежде чем мигрировать, потребовалась подготовка хоста.
Настройка хоста
Разрешение привилегированных портов для rootless-контейнеров:
echo 'net.ipv4.ip_unprivileged_port_start=80' | sudo tee /etc/sysctl.d/90-unprivileged-ports.conf
sudo sysctl --system
Включение linger для работы пользовательских сервисов после отключения SSH:
sudo loginctl enable-linger $USER
Установка Podman и настройка окружения
sudo apt install podman
systemctl --user enable --now podman.socket
Настройка реестра образов (Podman не подставляет docker.io по умолчанию):
mkdir -p ~/.config/containers
cat > ~/.config/containers/registries.conf << 'EOF'
unqualified-search-registries = ["docker.io"]
EOF
Создание структуры директорий для bind mount (Podman не создаёт их автоматически):
mkdir -p ~/volumes/{caddy,vaultwarden,linkding,forgejo,miniflux,grafana,prometheus}
Конвертация сервисов в Quadlet
Инструмент podlet
Утилита podlet выполняет конвертацию docker-compose в Quadlet:
podlet -i -a compose compose.yaml
Однако podlet не поддерживает external networks. Если в compose-файле есть секция с external: true, решение — убрать её перед конвертацией и вручную добавить Network=caddy.network в сгенерированный файл. На практике для простых сервисов с одним-двумя контейнерами проще написать Quadlet-файл вручную — формат тривиален.
Маппинг compose → Quadlet
Типичный сервис в Docker Compose:
services:
vaultwarden:
image: vaultwarden/server:latest
restart: unless-stopped
environment:
- DOMAIN=https://vault.example.org
- SIGNUPS_ALLOWED=false
volumes:
- ./data:/data
networks:
- caddy
Quadlet-эквивалент:
[Unit]
Description=Vaultwarden password manager
[Container]
ContainerName=vaultwarden
Image=docker.io/vaultwarden/server:latest
Volume=%h/volumes/vaultwarden/data:/data:Z
Environment=DOMAIN=https://vault.example.org
Environment=SIGNUPS_ALLOWED=false
Network=caddy.network
DNS=1.1.1.1
AutoUpdate=registry
[Service]
Restart=always
[Install]
WantedBy=default.target
Маппинг понятен: image → Image=, volumes → Volume=, networks → Network=, depends_on → After= в секции [Unit].
Сети и связи между контейнерами
В Docker Compose сети, сервисы и их связи описаны в одном файле. В Quadlet каждая сущность — отдельный файл: .network для сети, .container для сервиса. Рассмотрим на примере Miniflux — RSS-ридера, которому нужна PostgreSQL. В Docker Compose это один файл с двумя сервисами и внутренней сетью. В Quadlet — три отдельных файла: сеть, контейнер базы данных и контейнер приложения.
Сеть miniflux_db.network:
[Network]
NetworkName=miniflux_db
Контейнер PostgreSQL:
[Unit]
Description=PostgreSQL for Miniflux
[Container]
ContainerName=miniflux-db
Image=docker.io/library/postgres:16
Volume=%h/volumes/miniflux-db/data:/var/lib/postgresql/data:Z
Environment=POSTGRES_USER=miniflux
Environment=POSTGRES_PASSWORD=secret
Environment=POSTGRES_DB=miniflux
Network=miniflux_db.network
HealthCmd=pg_isready -U miniflux
HealthInterval=10s
AutoUpdate=registry
[Service]
Restart=always
[Install]
WantedBy=default.target
Контейнер Miniflux:
[Unit]
Description=Miniflux RSS reader
After=miniflux-db.service
[Container]
ContainerName=miniflux
Image=docker.io/miniflux/miniflux:latest
Environment=DATABASE_URL=postgres://miniflux:secret@miniflux-db/miniflux?sslmode=disable
Environment=RUN_MIGRATIONS=1
Environment=BASE_URL=https://reader.example.org
Network=caddy.network
Network=miniflux_db.network
DNS=1.1.1.1
AutoUpdate=registry
[Service]
Restart=always
[Install]
WantedBy=default.target
Обратите внимание: Miniflux подключён к двум сетям. Сеть miniflux_db.network — для связи с PostgreSQL, сеть caddy.network — для доступа через reverse proxy. PostgreSQL при этом подключён только к miniflux_db.network и снаружи недоступен. Зависимость After=miniflux-db.service гарантирует порядок запуска. Сервис Miniflux обращается к базе по имени контейнера miniflux-db — DNS-резолвинг работает внутри общей сети.
Топология сетей
Схема подключения сервисов к сетям:
Caddy как reverse proxy
В Docker-конфигурации я использовал caddy-docker-proxy — плагин, который автоматически генерирует конфигурацию Caddy из labels контейнеров. Удобно, но добавляет неявность: конфигурация reverse proxy размазана по docker-compose файлам всех сервисов. При миграции на Podman я отказался от этого подхода в пользу единого Caddyfile. Все маршруты описаны явно в одном файле — проще читать, проще отлаживать:
reader.example.org {
reverse_proxy miniflux:8080
}
vault.example.org {
reverse_proxy vaultwarden:80
}
git.example.org {
reverse_proxy forgejo:3000
}
Caddy работает как отдельный Podman-контейнер на сети caddy.network. Все сервисы, которые должны быть доступны извне, подключены к этой же сети. Caddy обращается к ним по именам контейнеров — порты не публикуются на хост. Это принципиальный момент: ни один сервис не открывает порты даже на localhost. Единственный контейнер с опубликованными портами — сам Caddy (80 и 443).
После изменения Caddyfile его можно провалидировать и применить без перезапуска контейнера:
podman exec caddy caddy validate --config /etc/caddy/Caddyfile
systemctl --user reload caddy
Подводные камни
Миграция выявила несколько нюансов, специфичных для rootless Podman.
UID mapping
В rootless Podman root внутри контейнера соответствует пользователю хоста. При работе контейнера от другого UID bind mount может быть недоступен. Решение — UserNS=keep-id:
UserNS=keep-id:uid=65534,gid=65534 # Prometheus (nobody)
UserNS=keep-id:uid=472,gid=0 # Grafana
Узнать нужный UID можно командой:
podman run --rm <image> id
Привилегированные порты
Rootless Podman по умолчанию не может использовать порты ниже 1024. Для Caddy, которому нужны порты 80 и 443, решение — понизить границу на уровне ядра (см. раздел «Подготовка» выше).
Docker socket
Инструменты, работающие с Docker API, ожидают /var/run/docker.sock. Podman socket совместим — достаточно монтировать его по привычному пути:
Volume=%t/podman/podman.sock:/var/run/docker.sock:ro
XDG_RUNTIME_DIR при SSH
При SSH-подключении переменная XDG_RUNTIME_DIR не устанавливается, а systemctl --user без неё не работает:
ssh server "export XDG_RUNTIME_DIR=/run/user/\$(id -u) && systemctl --user status caddy"
Процесс миграции
Миграцию я проводил с помощью Claude Code. Изначально план предполагал последовательный перенос сервисов группами, но после подготовки всех Quadlet-файлов решил мигрировать все 15 сервисов одновременно. Весь процесс занял около 15 минут. Перенос данных для bind mount — простое копирование. Для named Docker volumes потребовалось копирование из /var/lib/docker/volumes/ с последующей сменой владельца. PostgreSQL — стандартный pg_dump / pg_restore.
Сводное сравнение
| Аспект | Docker | Podman + Quadlet |
|---|---|---|
| Конфигурация | docker-compose.yml | Quadlet .container файлы |
| Оркестрация | Docker daemon (root) | Нет демона (rootless) |
| Управление | docker compose up/down | systemctl --user start/stop |
| Логи | docker logs | journalctl --user -u |
| Метрики | cAdvisor | prometheus-podman-exporter |
| Reverse proxy | Labels → caddy-docker-proxy | Caddyfile + имена контейнеров |
Версионный контроль конфигурации
Одно из преимуществ Quadlet — вся конфигурация сервисов представляет собой набор текстовых файлов в одной директории. Я организовал её с группировкой по сервисам:
~/.config/containers/systemd/
├── caddy/
│ └── caddy.container
├── forgejo/
│ └── forgejo.container
├── miniflux/
│ ├── miniflux.container
│ └── miniflux-db.container
├── monitoring/
│ ├── prometheus.container
│ ├── grafana.container
│ └── prometheus-podman-exporter.container
├── networks/
│ ├── caddy.network
│ ├── miniflux_db.network
│ └── searxng_cache.network
├── configs/
│ ├── Caddyfile
│ └── prometheus.yml
└── sockets.volume
Инициализация заняла несколько команд:
cd ~/.config/containers/systemd/
git init
git add .
git commit -m "init: add all quadlet and service configs"
git remote add origin git@git.example.org:user/server-config.git
git push -u origin main
Теперь любое изменение конфигурации находит отражение в истории коммитов. Рабочий процесс прост: отредактировать файл, применить через daemon-reload и перезапуск сервиса, убедиться что всё работает, закоммитить. Откат при необходимости — git checkout нужной версии файла и перезапуск сервиса. При развёртывании на другом сервере достаточно склонировать репозиторий, скорректировать пути и секреты, выполнить daemon-reload — и все сервисы готовы к запуску.
Результат
После миграции потребление памяти сократилось примерно в полтора раза. Нагрузка на процессор снизилась пропорционально — отсутствие Docker daemon и его подсистем сказывается. Вместо одного монолитного процесса работает множество независимых контейнеров, каждый от имени обычного пользователя.
Перезапуск или обновление одного сервиса никак не затрагивает остальные. Автоматическое обновление образов через podman auto-update. Логи — через стандартный journalctl. Управление — через привычный systemctl.
Миграция оказалась значительно проще, чем я ожидал. Формат Quadlet-файлов минималистичен и читается лучше, чем docker-compose. Экосистема образов полностью совместима. Единственная область, требующая внимания — UID mapping в rootless-режиме, но после понимания принципа это становится рутиной.
Я доволен результатом. Сервер стал безопаснее, экономнее в потреблении ресурсов и проще в обслуживании.