Миграция приложения в Docker: с чего начать и где остановиться
Если приложение годами живёт прямо на сервере — установлено через apt, настроено руками, конфиги разбросаны по /etc — рано или поздно встаёт вопрос переезда в Docker. Обычно повод конкретный: нужно поднять копию для тестов, перенести на другой сервер без недельного квеста «а что я тут вообще ставил», или просто надоело бояться трогать прод. Ниже — пошаговый план, как довести существующее приложение до контейнеров, не сломав его по дороге, и честный разговор о том, когда стоит остановиться и не тащить в проект оркестрацию, которая ему не нужна.
Содержание
- Шаг 1: Dockerfile для самого приложения
- Шаг 2: зависимости в отдельные контейнеры через docker-compose
- Шаг 3: персистентные данные — volumes для базы и файлов
- Шаг 4: конфигурация через переменные окружения
- Шаг 5: логи и мониторинг контейнеризованного приложения
- Где стоит остановиться: не спешите в Kubernetes
Шаг 1: Dockerfile для самого приложения
Первая и самая важная ошибка — начинать миграцию с docker-compose, базы данных, сетей и томов одновременно. Не надо. Сначала нужно упаковать в контейнер только само приложение и убедиться, что оно там вообще стартует.
Возьмите текущую установку и honestly выпишите, что нужно приложению для работы: версия языка/рантайма (Node, PHP, Python, Java — что угодно), системные пакеты, файл зависимостей (package.json, requirements.txt, composer.json), команда запуска. Это и есть черновик Dockerfile.
Пример для типичного Node-приложения, которое раньше запускалось через pm2 start app.js:
FROM node:20-bookworm-slim
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY . .
EXPOSE 3000
CMD ["node", "app.js"]
Для приложения на PHP (например, Laravel) стартовая точка обычно другая — нужен php-fpm или встроенный сервер, плюс расширения:
FROM php:8.3-fpm
RUN apt-get update && apt-get install -y \
libzip-dev unzip git \
&& docker-php-ext-install pdo_mysql zip
WORKDIR /var/www
COPY . .
RUN composer install --no-dev --optimize-autoloader
Соберите образ и запустите его локально, изолированно, без всего остального окружения:
docker build -t myapp:test .
docker run -p 3000:3000 myapp:test
Здесь вылезет первая порция проблем: приложение не находит базу данных (её ещё нет в контейнере — это нормально на этом шаге, проверяйте хотя бы что процесс стартует и отвечает на базовый health-эндпоинт), падает из-за отсутствующей системной библиотеки, или не видит файлы, которые раньше лежали в абсолютном пути вида /opt/myapp/uploads. Это и есть смысл шага 1 — вытащить наружу все скрытые зависимости, о существовании которых вы, возможно, забыли, потому что они были установлены на сервере три года назад и с тех пор просто работали.
Критерий готовности этого шага не «образ собрался», а «приложение в контейнере ведёт себя идентично боевому — с той же версией рантайма, с теми же ответами на тех же запросах». Если можете временно подключить его к боевой базе (по сети, не пересобирая инфраструктуру) и прогнать через него реальные запросы — сделайте это перед тем, как двигаться дальше.
Шаг 2: зависимости в отдельные контейнеры через docker-compose
Когда само приложение стабильно стартует в контейнере, пора вынести базу данных, кеш и очереди — всё, что раньше стояло на том же сервере как системный сервис (postgresql, redis-server, rabbitmq и т.д.).
Идея docker-compose проста: каждый сервис — отдельный контейнер, все они видят друг друга по имени сервиса благодаря встроенной docker-сети, и поднимаются одной командой.
version: "3.9"
services:
app:
build: .
ports:
- "3000:3000"
depends_on:
- db
- redis
environment:
- DATABASE_URL=postgres://myapp:myapp@db:5432/myapp
- REDIS_URL=redis://redis:6379
db:
image: postgres:16
environment:
- POSTGRES_USER=myapp
- POSTGRES_PASSWORD=myapp
- POSTGRES_DB=myapp
redis:
image: redis:7-alpine
Обратите внимание на строку DATABASE_URL=postgres://myapp:myapp@db:5432/myapp — хост db, а не localhost и не IP-адрес. Это ключевой момент, который ломает миграцию у новичков: внутри compose-сети контейнеры обращаются друг к другу по имени сервиса из docker-compose.yml, docker сам поднимает для этого DNS внутри своей сети. Если в коде приложения хост базы захардкожен как 127.0.0.1 — это первое, что нужно поправить.
depends_on определяет только порядок старта контейнеров, а не готовность сервиса принимать соединения — postgres может ещё разворачиваться, пока приложение уже пытается подключиться. Для реального ожидания готовности используйте healthcheck (см. ниже) и condition service_healthy в depends_on, либо retry-логику на стороне приложения при подключении к базе.
Поднимаете всё разом:
docker compose up -d
docker compose logs -f app
На этом шаге стоит перенести данные из старой установки в новый контейнер базы — через pg_dump/pg_restore или mysqldump, а не пытаться примонтировать старую директорию данных напрямую (версии и структура каталогов между хостовой установкой и официальным образом обычно не совпадают один в один).
Нужен сервер под эту задачу?
Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.
Арендовать VPSШаг 3: персистентные данные — volumes для базы и файлов
Вот тут происходит самая частая и самая болезненная ошибка первой докеризации: всё работает несколько недель, а потом кто-то делает docker compose down или пересобирает образ — и база данных пустая, а загруженные пользователями файлы исчезли.
Причина в том, что по умолчанию всё, что контейнер пишет внутри себя, живёт в writable-слое самого контейнера. Слой уничтожается вместе с контейнером. Volume — это способ сказать docker «эту директорию хранить отдельно от контейнера, на диске хоста (или в управляемом docker-объёме), и не трогать её при пересоздании».
Добавьте volumes в тот же docker-compose.yml:
services:
app:
build: .
volumes:
- uploads:/app/uploads
# ...
db:
image: postgres:16
volumes:
- pgdata:/var/lib/postgresql/data
# ...
volumes:
pgdata:
uploads:
Здесь pgdata и uploads — именованные volumes, которыми управляет сам docker (обычно физически лежат в /var/lib/docker/volumes/). Их можно посмотреть командой docker volume ls и docker volume inspect pgdata. Такой контейнер можно пересобрать, обновить образ, даже полностью удалить — данные в volume останутся и подключатся заново к новому контейнеру.
Альтернатива — bind mount, когда вы явно указываете путь на хосте:
volumes:
- /srv/myapp/uploads:/app/uploads
Bind mount удобен, когда вам важно видеть и трогать файлы напрямую с хоста (например, для бэкапа стандартными средствами или ручной правки), именованный volume — когда важна переносимость и вы не хотите завязываться на конкретную структуру путей хоста. Для базы данных чаще используют именованный volume, для загружаемых пользователями файлов — оба варианта в ходу, выбор зависит от того, как вы бэкапите сервер.
Проверить, что персистентность реально работает, легко:
docker compose down
docker compose up -d
# данные в БД и файлы в uploads должны быть на месте
Если после этой команды данные пропали — значит, где-то в Dockerfile или compose-файле путь с данными не примонтирован как volume, и стоит перепроверить конфигурацию до того, как это обнаружится на проде. Подробно про типы volume и когда какой использовать разобрано в статье про типы docker volumes.
Шаг 4: конфигурация через переменные окружения
Пока приложение жило на голом сервере, конфигурация обычно хранилась в config-файле рядом с кодом или вообще была захардкожена в исходниках — пароль от базы, ключ API платёжной системы, секрет для подписи сессий. При переезде в Docker это нужно менять: образ должен быть одинаковым для dev, staging и прод-окружения, а различаться должны только переменные, которые в него передаются при запуске.
services:
app:
build: .
env_file:
- .env
environment:
- NODE_ENV=production
Файл .env рядом с docker-compose.yml:
DATABASE_URL=postgres://myapp:${DB_PASSWORD}@db:5432/myapp
DB_PASSWORD=change_me_in_real_deploy
REDIS_URL=redis://redis:6379
SESSION_SECRET=another_real_secret_here
Важно: .env с реальными паролями не должен попадать в git — добавьте его в .gitignore и держите в репозитории только .env.example с именами переменных без значений. Это тот момент, где чаще всего спотыкаются: пароли от базы утекают в публичный репозиторий именно потому, что .env закоммитили один раз «на скорую руку» и забыли. Подробный разбор этой ошибки и вариантов, как хранить секреты правильно (docker secrets, внешние хранилища, права доступа к файлу) — в статье про антипаттерн с паролями в docker-compose.
В коде приложения на этом шаге нужно убрать все места, где конфигурация читается из файла или захардкожена, и заменить на чтение переменных окружения (process.env.DATABASE_URL в Node, os.environ.get(...) в Python, env('DB_PASSWORD') в Laravel). Если приложение большое и таких мест много — это может оказаться самой трудоёмкой частью всей миграции, честно закладывайте на неё время.
Шаг 5: логи и мониторинг контейнеризованного приложения
После переезда в контейнеры привычные команды вроде tail -f /var/log/myapp.log перестают работать так же — по умолчанию docker перехватывает stdout/stderr процесса внутри контейнера и складывает их в собственный лог-драйвер. Смотреть их нужно так:
docker logs myapp_app_1
docker logs -f --tail 100 myapp_app_1
Чтобы это работало нормально, приложение должно писать логи в stdout/stderr, а не в файл внутри контейнера — это стандартная практика для контейнеризованных приложений (её называют "12 factor logging"). Если приложение исторически писало в файл (/var/log/myapp/app.log), придётся либо перенастроить логгер на вывод в stdout, либо (как временное решение) пробрасывать этот файл через volume и читать его извне.
Полезно сразу ограничить размер логов, чтобы диск не забился — по умолчанию json-file драйвер docker может расти бесконечно:
services:
app:
build: .
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
Для полноценного мониторинга одного docker logs мало — вам нужно знать, что контейнер вообще жив и отвечает, а не просто что процесс запущен. Добавьте healthcheck:
services:
app:
build: .
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:3000/health"]
interval: 30s
timeout: 5s
retries: 3
docker ps после этого покажет статус healthy/unhealthy прямо в списке контейнеров, а внешние системы мониторинга (Zabbix, Prometheus + node-exporter/cadvisor, или простой скрипт с алертом в Telegram) смогут опираться на этот статус, а не гадать по факту запущенности процесса. Практики логирования в контейнерах — лог-ротация, агрегация через отдельный стек, что писать в логи, а что нет — подробнее разобраны в статье про best practices логирования в docker.
Где стоит остановиться: не спешите в Kubernetes
Здесь важно сказать прямо: докеризация приложения и переход к оркестрации (Kubernetes, Docker Swarm, Nomad) — это два разных решения, и путать их не стоит. Сама по себе докеризация уже даёт большую часть пользы: воспроизводимое окружение (одинаковый образ на любой машине), изоляцию зависимостей (два проекта с разными версиями Node не конфликтуют на одном сервере), простой перенос на другой сервер (docker compose up вместо восстановления окружения по памяти).
Оркестрация нужна тогда, когда у вас реально несколько серверов, между которыми нужно распределять нагрузку, автоматически перезапускать упавшие контейнеры на других узлах, автомасштабировать количество реплик под нагрузкой, и когда сервисов достаточно много, чтобы их взаимодействие вручную уже не удержать в голове. Для одного приложения на одном-двух серверах это решает проблемы, которых у вас пока нет — а взамен добавляет реальную сложность: control plane, конфигурацию сети между узлами, отдельный набор манифестов вместо одного понятного docker-compose.yml, кривую обучения для всей команды.
Для подавляющего большинства небольших и средних проектов связка docker-compose + один-два сервера (второй — как горячий резерв или для разделения нагрузки) закрывает потребности полностью, и остаётся такой на годы вперёд без апгрейда до оркестрации. Признаки, что вам реально пора смотреть в сторону Kubernetes: у вас уже 15+ независимых сервисов, нагрузка скачет так, что вручную не успеваете добавлять серверы, или требование по SLA не допускает даже минуты простоя при обновлении. Если ничего из этого не про вас — не тратьте месяцы на изучение и внедрение оркестратора ради галочки «у нас современный стек». Разбор мифа о том, что Kubernetes нужен любому проекту, и конкретных признаков, когда он действительно оправдан — в статье «Kubernetes нужен любому проекту» — миф или нет.
Нужен сервер под эту задачу?
Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.
Арендовать VPSНужны сами нейросети для контента?
Генерируйте изображения, видео и озвучку нейросетями на falapi.io — десятки моделей в одном окне. Оплата картой РФ и по СБП.
Частые вопросы
Можно ли мигрировать в Docker без простоя продакшена?
Да, если готовить всё параллельно: собрать и протестировать образ и compose-файл на отдельном сервере или порту, перенести данные заранее (dump базы, копия файлов), и переключить трафик только когда новая версия подтверждённо работает идентично старой. Полная докеризация «на живую» без подготовки — плохая идея.
Что делать, если приложение писало важные данные не в базу и не в понятную директорию, а куда попало?
Это стоит исправить ещё до миграции — аудит того, куда приложение реально пишет (можно посмотреть через strace или логи файловой системы), и сведение записи к 1-2 явным директориям, которые потом станут volumes. Иначе после переезда часть данных будет тихо теряться.
Нужно ли сразу переносить весь стек (nginx, cron-задачи, воркеры очередей) в контейнеры?
Не обязательно одним шагом. Можно начать с основного приложения и базы, а nginx на хосте временно оставить как обратный прокси к контейнеру по порту — и переносить остальное по частям, когда будет время протестировать каждый кусок отдельно.
Как перенести уже докеризованный проект на другой сервер, если такая необходимость появится позже?
Короткий ответ — синхронизировать volumes и compose-файл, при этом сами образы можно пересобрать на новом сервере из того же Dockerfile. Подробный план с частыми граблями — в статье как перенести docker-проект на другой сервер.
Стоит ли использовать Docker Swarm как промежуточный шаг перед Kubernetes?
Если у вас уже 2-3 сервера и назрела реальная потребность в распределении контейнеров между ними, Swarm — гораздо более простой шаг, чем сразу Kubernetes, и для многих проектов этого достаточно навсегда.
Обсудить статью, задать вопрос или начать новую тему
Есть вопрос по этой статье, идея для обсуждения или просто хотите поделиться опытом? Сообщество MAATRIX ждёт. Для общения, пожалуйста, зарегистрируйтесь в нашем личном кабинете.
Перейти в сообщество →