Перейти к основному содержанию

От 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, который останавливает все контейнеры разом.

Это обстоятельство высветило две фундаментальные проблемы:

  1. Единая точка отказа. Docker daemon — централизованный процесс. Его перезапуск или падение затрагивает все сервисы одновременно, вне зависимости от их связности.
  2. Работа от root. dockerd работает с привилегиями суперпользователя. Компрометация любого контейнера потенциально даёт атакующему полный контроль над хостом.

Поиск альтернатив

Для анализа возможных вариантов я использовал Claude Code. В течение нескольких сессий за несколько дней было проведено исследование с рассмотрением более 70 источников. Результатом стала развёрнутая документация с детальным сравнением подходов.

ВариантСутьИтоговая оценка
systemd-nspawnСистемные контейнеры, встроенные в systemdОтвергнут: отсутствие экосистемы образов, ручная сборка окружений
Incus (LXD)Системные контейнеры с полным LinuxОтвергнут: двойная контейнеризация, избыточная сложность
NixOS / GuixДекларативное управление системойОтвергнут: радикальное изменение парадигмы, длительная кривая обучения
PodmanDrop-in замена Docker без демонаВыбран: rootless по умолчанию, совместимость с OCI, независимые процессы

Архитектура: Docker vs Podman

Наглядная разница в управлении контейнерами.

Docker — единый демон, все контейнеры зависят от него:

flowchart TB dockerd["dockerd (root)\nединая точка отказа"] c1[Docker Container 1] c2[Docker Container 2] c3[Docker Container 3] dockerd --> c1 dockerd --> c2 dockerd --> c3 style dockerd fill:#ffcccc

Podman + Quadlet — каждый контейнер управляется systemd независимо:

flowchart TB systemd[systemd --user] p1[Podman Container 1] p2[Podman Container 2] p3[Podman Container 3] systemd -.->|quadlet| p1 systemd -.->|quadlet| p2 systemd -.->|quadlet| p3 style systemd fill:#ccffcc

Почему 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-резолвинг работает внутри общей сети.

Топология сетей

Схема подключения сервисов к сетям:

flowchart LR subgraph Internet users[Клиенты] end subgraph caddy_net[caddy.network] caddy[Caddy :80/:443] vaultwarden[Vaultwarden] forgejo[Forgejo] miniflux_app[Miniflux] end subgraph miniflux_net[miniflux_db.network] miniflux_app miniflux_db[(PostgreSQL)] end users --> caddy caddy --> vaultwarden caddy --> forgejo caddy --> miniflux_app miniflux_app --> miniflux_db

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"

Процесс миграции

flowchart TD A[Подготовка Quadlet-файлов] --> B[Копирование данных из Docker volumes] B --> C[Дамп PostgreSQL] C --> D[podman pull — загрузка образов] D --> E[Остановка Docker-контейнеров] E --> F[systemctl --user daemon-reload] F --> G[Запуск Podman-сервисов] G --> H[Восстановление дампa PostgreSQL] H --> I[Проверка доступности]

Миграцию я проводил с помощью Claude Code. Изначально план предполагал последовательный перенос сервисов группами, но после подготовки всех Quadlet-файлов решил мигрировать все 15 сервисов одновременно. Весь процесс занял около 15 минут. Перенос данных для bind mount — простое копирование. Для named Docker volumes потребовалось копирование из /var/lib/docker/volumes/ с последующей сменой владельца. PostgreSQL — стандартный pg_dump / pg_restore.

Сводное сравнение

АспектDockerPodman + Quadlet
Конфигурацияdocker-compose.ymlQuadlet .container файлы
ОркестрацияDocker daemon (root)Нет демона (rootless)
Управлениеdocker compose up/downsystemctl --user start/stop
Логиdocker logsjournalctl --user -u
МетрикиcAdvisorprometheus-podman-exporter
Reverse proxyLabels → caddy-docker-proxyCaddyfile + имена контейнеров

Версионный контроль конфигурации

Одно из преимуществ 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-режиме, но после понимания принципа это становится рутиной.

Я доволен результатом. Сервер стал безопаснее, экономнее в потреблении ресурсов и проще в обслуживании.