Что это решает
Приложение тянет за собой версию языка, набор расширений, системные библиотеки и десяток переменных окружения. На ноутбуке всё это сложилось само за год работы, на сервере — нет. Docker упаковывает код вместе со средой в один образ, и от машины требуется только докер. Выкладка сводится к трём шагам: собрать образ, положить в реестр, забрать на сервере и перезапустить.
Вторая выгода — предсказуемый откат. Образ помечается тегом, старые теги остаются в реестре. Вернуться на прошлую версию означает запустить контейнер из другого тега, а не разбираться, какие файлы затёрлись при последней выкладке.
Как устроено
Образ, слой, контейнер
Dockerfile описывает шаги сборки. Каждая инструкция даёт слой — неизменяемый срез файловой системы. Все слои вместе — образ, доступный только на чтение. При запуске поверх образа кладётся тонкий записываемый слой, и вот эта конструкция с процессом внутри — контейнер.
Отсюда главное следствие: удалили контейнер — записываемый слой исчез вместе с загруженными файлами, логами на диске и данными базы. Всё, что должно пережить пересоздание, выносится в том или во внешнее хранилище.
Порядок строк в Dockerfile решает время сборки
FROM node:22-slim
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY . .
USER node
CMD ["node", "server.js"]
Докер кеширует слои и переиспользует их, пока не изменились входные данные шага. Манифест зависимостей копируется отдельно и раньше остального кода — тогда правка одного файла приложения не выкидывает из кеша установку зависимостей. Если написать COPY . . до npm ci, установка будет повторяться при каждой правке любого файла.
Тег базового образа фиксируется явно. latest означает, что сборка сегодня и сборка через месяц дадут разный результат при том же коде.
.dockerignore
Перед сборкой докер отправляет каталог целиком демону. Без .dockerignore туда уезжают node_modules, .git, локальные .env, артефакты предыдущих сборок. Контекст раздувается, сборка замедляется, а секреты имеют шанс осесть в слое образа:
.git
node_modules
.env
.env.*
dist
tmp
Многоступенчатая сборка
Инструменты сборки в финальном образе не нужны. Первая ступень собирает, вторая забирает только результат:
FROM node:22 AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:22-slim
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY --from=build /app/dist ./dist
USER node
CMD ["node", "dist/server.js"]
В итоговом образе нет компилятора, dev-зависимостей и исходников — меньше вес, меньше поверхность атаки.
Конфигурация и секреты
Настройки передаются переменными окружения при запуске: --env-file, секция environment в compose, механизм секретов в оркестраторе. В ENV внутри Dockerfile и в аргументах RUN секретам не место — команда docker history показывает содержимое слоёв всякому, кто получил образ.
Порты, сеть, тома
EXPOSE — документация, ничего не открывает. Публикация делается при запуске: -p 8080:3000 пробрасывает порт хоста на порт контейнера. Внутри compose-сети сервисы обращаются друг к другу по имени сервиса, и публиковать порт базы наружу не нужно вовсе.
Тома бывают двух видов. Именованный том — хранилище под управлением докера, туда кладут данные (pgdata:/var/lib/postgresql/data). Bind mount пробрасывает каталог хоста внутрь, им удобно подсовывать конфиги и смотреть загруженные файлы.
compose как минимальный оркестратор
services:
app:
image: registry.example.com/app:${TAG}
env_file: .env
ports: ["127.0.0.1:8080:3000"]
depends_on: [db]
restart: unless-stopped
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:3000/health"]
interval: 30s
db:
image: postgres:16
volumes: ["pgdata:/var/lib/postgresql/data"]
restart: unless-stopped
volumes:
pgdata:
Команды на каждый день: docker compose up -d, docker compose logs -f app, docker compose ps, docker compose pull.
Реестр и выкладка
Образ собирается в CI, помечается коротким хешем коммита и уезжает в реестр (Docker Hub, GitLab Registry, GHCR). На сервере остаются две команды:
TAG=9f3c1a2 docker compose pull
TAG=9f3c1a2 docker compose up -d
Откат — тот же вызов с прошлым хешем. Тег latest в этой схеме бесполезен: по нему невозможно сказать, что именно запущено.
Пользователь и права
По умолчанию процесс внутри работает от root. Инструкция USER переводит его на непривилегированного пользователя. Файлы, проброшенные с хоста, сохраняют владельца хоста — расхождение UID даёт «permission denied» при записи в примонтированный каталог.
Логи
Приложение пишет в stdout и stderr, докер складывает это в свой драйвер логов, docker logs читает. Файлы логов внутри контейнера бессмысленны — исчезнут вместе с ним. Ротацию надо настроить явно, иначе журнал забьёт диск.
Полезные сценарии
- Свести окружение разработчика и сервера: один образ проходит путь от ноутбука через CI до прода.
- Разложить на одном VPS несколько сервисов с несовместимыми версиями языка и библиотек.
- Поднять зависимости для локальной разработки одной командой: база, кэш, брокер — без установки в систему.
- Прогнать тесты в CI внутри того же образа, который поедет в прод.
- Откатиться за секунды на предыдущий тег, не трогая файлы на сервере.
- Переехать к другому хостеру: перенос сводится к копированию томов и запуску тех же образов.
Ограничения
Докер не даёт отказоустойчивости, бэкапов, мониторинга и сертификатов. Перед контейнерами всё равно нужен обратный прокси с TLS (Caddy, nginx, Traefik), а резервные копии томов — отдельная задача, которую никто за вас не решит.
Архитектура процессора кусается. Образ, собранный на Apple Silicon, на amd64-сервере встречает exec format error. Лечится сборкой под целевую платформу (--platform linux/amd64 или buildx), а не повторной попыткой.
Alpine экономит вес, но собран на musl вместо glibc: нативные модули и предсобранные бинарники нередко ломаются или молча теряют производительность. Для приложений с нативными зависимостями безопаснее slim-образ на glibc.
Состояние остаётся слабым местом. База в контейнере на одной машине — это та же одна машина, только с дополнительным слоем поверх диска. Контейнеризация не превращает её в кластер.
Диск заполняется незаметно: старые образы, промежуточные слои, брошенные тома. docker system df показывает расход, docker system prune чистит, но удалить нужное им же — вопрос одного неаккуратного флага.
Доступ к сокету докера равен правам root на хосте. Пробрасывать /var/run/docker.sock в контейнер ради удобства — открывать машину целиком.
Отладка внутри slim-образа неудобна: нет ни редактора, ни привычных утилит. Ставить их в прод-образ ради разовой диагностики — плохой размен.
Потолок compose — одна машина. Дальше начинаются Swarm, Kubernetes, Nomad, и это другой класс сложности, который окупается не на трёх сервисах.
Миграции базы и совместимость версий Docker не решает вовсе. Откат образа не откатывает схему.
Как проверить результат
Собрать и запустить локально до всякого сервера:
docker build -t app:test .
docker run --rm -p 8080:3000 --env-file .env.local app:test
curl -i localhost:8080/health
Посмотреть, что попало в слои: docker history app:test показывает команды и вес шагов. Секрет, мелькнувший в аргументе RUN, виден здесь же. Размер контекста печатается в первой строке вывода docker build — цифра в сотни мегабайт означает забытый .dockerignore.
Проверить пользователя: docker run --rm app:test id не должен показывать uid=0(root).
Проверить эфемерность честно: залить файл через приложение, выполнить docker compose down && docker compose up -d, посмотреть, на месте ли он. Пропал — значит, данные пишутся в записываемый слой, а не в том.
Сверить, что на сервере запущено ровно то, что собрано. Дайджест образа локально и в реестре должен совпадать:
docker image inspect --format '{{index .RepoDigests 0}}' registry.example.com/app:9f3c1a2
Проверить конфиг после подстановки переменных: docker compose config печатает итоговый вариант со всеми значениями — здесь видно пустые переменные, которые иначе проявятся падением при старте.
Здоровье: docker ps показывает статус healthcheck. Контейнер в состоянии unhealthy при живом процессе означает, что проверка написана мимо реального признака работоспособности.
Правило одной строкой: в образ кладётся только код с зависимостями, всё переживающее пересоздание контейнера уходит в том, а всё зависящее от окружения — в переменные среды.
