Миф: Docker решает проблему «у меня всё работало»
«Заверните это в Docker, и проблема "у меня всё работало" исчезнет» — фраза, которую произносит тимлид на созвоне после третьего подряд инцидента с несовпадением окружений. Через месяц выясняется, что в проде контейнер падает там, где на ноутбуке разработчика всё было идеально, только теперь разбираться приходится не с «у меня работает», а с «у меня в Docker работает, а у вас в Docker — нет», и это заметно неприятнее, потому что все были уверены, что именно эту категорию проблем контейнеризация закрыла раз и навсегда.
Содержание
- Откуда берётся миф и его рациональное зерно
- Что Docker действительно решает: изоляция ОС-слоя и рантайма
- Что Docker не решает: внешние зависимости, конфигурация, ресурсы, сеть
- Docker-compose для разработки vs продакшн-оркестрация: новый источник расхождений
- Непинингованный Dockerfile воспроизводит ту же проблему внутри контейнера
- Дисциплина, которая реально закрывает разрыв dev/staging/prod
Откуда берётся миф и его рациональное зерно
Миф не выдуман на пустом месте — у него есть реальное основание. До контейнеров классическая формула «у меня работает» почти всегда сводилась к трём вещам: другая версия языка/рантайма на машине разработчика и на сервере, другой набор системных библиотек (glibc, OpenSSL, версии которых незаметно различаются между дистрибутивами), и ручная установка зависимостей «на глаз», без фиксации точного состояния. Docker-образ реально убирает эту категорию проблем: он пакует конкретную версию ОС-слоя, конкретную версию рантайма и все системные зависимости в один артефакт, который запускается одинаково что на ноутбуке, что на сервере — при условии, что это буквально один и тот же образ (docker run myapp:sha-a1b2c3), а не «пересобранный из того же Dockerfile».
Именно поэтому миф так живуч: часть проблемы Docker действительно решает, причём заметно и на практике. Команда, которая раньше тратила день на настройку окружения нового разработчика через инструкцию «поставь Python 3.9, потом вот этот пакет через apt, потом эту версию libpq», теперь тратит на это одну команду docker compose up. На этом фоне легко решить, что раз рутинная возня с окружением исчезла, то и все различия сред закрыты, — а это уже неверный вывод из верной посылки.
Что Docker действительно решает: изоляция ОС-слоя и рантайма
Технически Docker-образ — это слоёный снимок файловой системы: базовый образ ОС (или минимальный слой вроде distroless), поверх него — установленный рантайм (Node.js, Python, PHP, Java), поверх — системные пакеты и зависимости приложения. Когда вы собираете образ через docker build, вы фиксируете это состояние целиком:
FROM node:20.17.0-bookworm-slim
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY . .
CMD ["node", "server.js"]
Этот образ действительно устраняет класс проблем «на сервере другая версия Node» или «на сервере нет нужного .so-файла для нативного модуля» — при запуске docker run вы разворачиваете ровно тот файловый слой, который был собран, а не полагаетесь на то, что администратор настроил сервер идентично ноутбуку. Это реальная и полезная граница изоляции.
Но граница здесь конкретная — она проходит по файловой системе и рантайму *внутри* образа. Всё, что снаружи: сама база данных, конфигурация окружения, доступные ресурсы хоста, сетевые условия между сервисами — Docker не трогает, потому что это не входит в границы того, что упаковывается в образ.
Нужен сервер под эту задачу?
Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.
Арендовать серверЧто Docker не решает: внешние зависимости, конфигурация, ресурсы, сеть
Здесь и рвётся миф. Приложение почти никогда не работает в вакууме — оно ходит в базу данных, читает переменные окружения, пишет во внешнее хранилище, стучится по сети к другим сервисам. Ни одна из этих вещей не запечатана внутрь Docker-образа.
Версия и конфигурация внешних сервисов. Если разработчик локально поднимает postgres:16 через docker compose, а прод годами крутится на управляемом PostgreSQL 14 у облачного провайдера, то различие в поведении конкретных функций, планировщика запросов или доступных расширений (pg_trgm, версия pgvector) никуда не денется от того, что приложение обёрнуто в контейнер — оно просто подключается к другой версии БД по сети, и контейнеризация клиента на это не влияет.
Конфигурация окружения. Переменные окружения, секреты, feature-флаги, региональные настройки — всё это по определению внешне по отношению к образу (иначе вы бы намертво зашили пароли и адреса в артефакт, что само по себе антипаттерн). Если в .env для разработки один набор значений, а в проде — другой набор, собранный вручную в другой момент времени другим человеком, расхождение конфигурации воспроизведёт баг независимо от того, что код запущен в одинаковом образе.
Ресурсные лимиты. Локальный Docker Desktop обычно не ограничивает контейнер по CPU и памяти, а в проде через оркестратор почти всегда выставлены лимиты (mem_limit, cpus, либо resources.limits в Kubernetes/Swarm). Код, который на ноутбуке ни разу не подходил близко к границе памяти, в проде может упираться в OOM-killer или получать CPU throttling — и это никак не связано с тем, что приложение «в Docker», сам факт контейнеризации ресурсные лимиты не выравнивает, наоборот — именно оркестратор их и вводит асимметрично между средами.
Сетевые условия. Локально сервисы внутри одного docker-compose.yml общаются практически без задержки через общий bridge-network на одной машине. В проде между теми же сервисами может быть реальная сеть, балансировщик, service mesh с дополнительным hop, TLS-терминация, и как следствие — задержки, таймауты, поведение при разрыве соединения, которые локально просто неоткуда взять.
| Что упаковано в образ | Изолирует ли Docker |
|---|---|
| Версия рантайма (Node/Python/PHP) | Да |
| Системные библиотеки и их версии | Да |
| Зависимости приложения (если пины зафиксированы) | Да, при аккуратной сборке |
| Версия и конфигурация внешней БД | Нет — образ клиента не влияет на образ БД |
| Переменные окружения, секреты, конфиги | Нет — задаются снаружи образа |
| Лимиты CPU/памяти | Нет — задаёт оркестратор, а не образ |
| Сетевые задержки, топология, файрвол | Нет — свойство инфраструктуры, не контейнера |
Docker-compose для разработки vs продакшн-оркестрация: новый источник расхождений
Есть менее очевидная ловушка: сама разница между тем, как контейнер запускается в разработке, и тем, как он запускается в проде, может стать новым источником «works on my machine» — только теперь фраза звучит как «у меня в docker-compose работает».
Типичный dev docker-compose.yml монтирует исходный код как volume для горячей перезагрузки:
services:
app:
build: .
volumes:
- ./src:/app/src
environment:
- NODE_ENV=development
ports:
- "3000:3000"
Строка volumes: - ./src:/app/src — это удобство для разработки и одновременно скрытая мина. Она подменяет собой файлы, которые реально оказались бы внутри образа после COPY в Dockerfile. Если в Dockerfile забыли скопировать какую-то директорию или файл попал в .dockerignore по ошибке, локально всё будет работать — потому что volume поверх образа маскирует пропущенный COPY. Проблема всплывает только там, где образ запускают без такого монтирования — то есть в проде, где docker-compose.yml для прод-оркестрации (или манифест Kubernetes/Swarm) как раз не монтирует исходники поверх, а полагается на то, что уже "запечено" в образ на этапе сборки.
Прод-оркестрация обычно отличается от dev-compose сразу по нескольким параметрам: не один инстанс, а несколько реплик за балансировщиком; секреты приходят из внешнего хранилища (Vault, Docker/Swarm secrets, Kubernetes Secrets), а не из открытого .env-файла рядом с кодом; healthcheck и restart-policy реально влияют на поведение при сбоях; ресурсы явно ограничены. Каждое из этих различий — самостоятельный источник поведения, которого не существовало в dev-конфигурации.
Частично эту асимметрию снимают профили docker-compose — можно описать несколько окружений (dev/staging) в одном файле с общей базой и явными различиями, вместо того чтобы держать конфигурацию прод-поведения только в голове того, кто настраивал сервер. Это не заменяет полноценную прод-оркестрацию, но заметно сокращает дистанцию между тем, что тестирует разработчик, и тем, что реально разворачивается.
Непинингованный Dockerfile воспроизводит ту же проблему внутри контейнера
Отдельная и очень частая ошибка — писать Dockerfile так, будто версия базового образа не имеет значения:
FROM node:20
RUN apt-get update && apt-get install -y curl
RUN npm install
Здесь три отдельных источника нестабильности сразу. Тег node:20 — плавающий: сегодня он указывает на один конкретный минорный/патч-релиз, через месяц производитель образа пересоберёт его с новой версией Node внутри того же тега 20, и docker build без изменений в Dockerfile молча подтянет другой рантайм. apt-get install -y curl без версии ставит последнюю доступную в репозитории на момент сборки, которая тоже меняется со временем. А npm install без закоммиченного package-lock.json (или с npm install вместо npm ci) может подтянуть новые минорные версии зависимостей, разрешённые семантическим версionированием в package.json, но не идентичные тем, что стояли неделю назад.
Итог — тот самый парадокс: два человека независимо собирают образ из одного и того же Dockerfile в разное время и получают два разных контейнера с разным поведением. Формально оба «в Docker», а проблема несовпадения окружений никуда не делась — просто переехала на уровень выше, из «разные машины» в «разные сборки одного и того же образа». Мы уже разбирали похожий случай, когда тег latest среди ночи подтянул новую версию и уронил API — это ровно тот сценарий, когда контейнеризация создаёт иллюзию контроля там, где реального контроля над версией нет. Общий разбор того, почему latest в проде — это антипаттерн, стоит прочитать отдельно, если в текущих Dockerfile ещё остались плавающие теги.
Правильный вариант того же файла с зафиксированными версиями:
# digest узнаётся командой `docker inspect --format='{{.RepoDigests}}' node:20.17.0-bookworm-slim`
# после первого pull и дальше фиксируется в Dockerfile как есть
FROM node:20.17.0-bookworm-slim@sha256:<digest-после-pull>
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY . .
USER node
CMD ["node", "server.js"]
Разница не в объёме кода, а в том, что npm ci (в отличие от npm install) строго ставит версии из lock-файла и падает с ошибкой, если package.json и package-lock.json рассогласованы, а привязка образа по digest (@sha256:...) гарантирует, что через полгода docker build вытянет байт-в-байт тот же базовый слой, а не «то, что сегодня лежит под тегом 20.17.0-bookworm-slim» (теги теоретически неизменяемы по конвенции, но на практике реестры позволяют их перезаписывать, и издатели образов иногда так делают при критических патчах).
Дисциплина, которая реально закрывает разрыв dev/staging/prod
Docker — это инструмент упаковки и изоляции файловой системы, а не гарантия идентичности поведения. Гарантию даёт дисциплина вокруг инструмента:
- Пиньте версии на каждом уровне. Базовый образ — по тегу с полной версией, а в критичных местах и по digest. Системные пакеты — через
apt-get install pkg=1.2.3-1там, где это доступно, либо через собственный базовый образ с фиксированным набором пакетов. Зависимости приложения — через lock-файл, закоммиченный в репозиторий, иci-режим установки (npm ci,pip install -r requirements.txt --require-hashes,poetry install --no-update), а не «install» без ограничений. - Продвигайте один и тот же образ по средам, а не пересобирайте его для каждой. Правильный pipeline: собрали образ один раз, тегировали хешем коммита или SHA, прогнали через staging, и в прод катится именно тот артефакт, что тестировался, — а не заново собранный из тех же исходников образ, где за час между staging и prod успел обновиться какой-нибудь транзитивный пакет без пина.
- Выносите конфигурацию наружу единообразно. Один и тот же механизм передачи переменных окружения и секретов и в dev, и в проде (пусть и с разными значениями) — через
.env-файл локально и через секреты оркестратора в проде, но по одному и тому же списку ключей, чтобы не оказалось, что в проде забыли завести переменную, которую разработчик считал сама собой разумеющейся. - Синхронизируйте ресурсные лимиты между окружениями. Если в проде контейнер ограничен
mem_limit: 512m, добавьте тот же лимит в dev-compose — лучше поймать OOM на своей машине заранее, чем в момент реального трафика. Это особенно важно для памяти в JVM/Node/Python-приложениях, где поведение при нехватке ресурсов сильно отличается от «просто чуть медленнее». - Держите staging максимально похожим на прод, а не как облегчённую версию «для проверки, что вообще запускается». Разбор того, как выглядит настройка staging-копии сайта, полезен именно как чек-лист, что конкретно должно совпадать помимо кода: версии сервисов, объём данных, сетевые правила.
- Автоматизируйте контролируемые обновления. Полный отказ от обновления версий — тоже не решение, потому что тогда вы копите технический долг и рискуете security-патчами. Правильный баланс — управляемые обновления через Renovate/Dependabot с обзором PR, а не бесконтрольный
latest, который тянет новую версию сам по себе в непредсказуемый момент.
Ничего из этого списка не делает Docker сам по себе — контейнер лишь честно воспроизводит то, что вы в него положили, включая непин и рассинхронизацию конфигов, если вы их туда положили.
Нужен сервер под эту задачу?
Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.
Арендовать серверНужны сами нейросети для контента?
Генерируйте изображения, видео и озвучку нейросетями на falapi.io — десятки моделей в одном окне. Оплата картой РФ и по СБП.
Частые вопросы
Значит ли всё это, что Docker бесполезен для решения проблемы «у меня всё работало»?
Нет. Он реально устраняет целый класс проблем — разные версии рантайма и системных библиотек на разных машинах. Миф не в том, что Docker ничего не даёт, а в том, что он закрывает только часть проблемы, а не всю.
Если использовать один и тот же docker-compose.yml и в разработке, и в проде — это решит вопрос полностью?
Станет заметно лучше, но не полностью: продакшн почти всегда требует того, чего нет в простом dev-compose — реальных ресурсных лимитов, секретов из отдельного хранилища, нескольких реплик, healthcheck. Профили docker-compose помогают держать эти различия явными в одном файле, а не в двух неизбежно расходящихся конфигурациях.
Как проверить, что версия базового образа не «уехала» со временем?
Сравните digest образа, который сейчас в реестре, с тем, что зафиксирован в Dockerfile или в манифесте сборки: docker inspect --format='{{.RepoDigests}}' myapp:tag. Если Dockerfile ссылается на тег без digest, при пересборке легко получить другой слой без единого изменения в самом файле.
Что делать, если пинить образ по digest неудобно — он часто обновляется по соображениям безопасности?
Пинить полную версию тега (например, 20.17.0-bookworm-slim вместо 20) и обновлять её осознанно через Renovate или аналогичный инструмент с ревью PR — это даёт контроль над моментом обновления, но без ручного отслеживания digest вручную при каждой сборке.
База данных версии 16 у разработчика и версии 14 в проде — это вообще проблема Docker?
Нет, это проблема несовпадения версий внешнего сервиса, и контейнеризация клиентского приложения тут ни при чём — она решается синхронизацией версий БД между средами, а не оборачиванием во что-либо дополнительное.
Обсудить статью, задать вопрос или начать новую тему
Есть вопрос по этой статье, идея для обсуждения или просто хотите поделиться опытом? Сообщество MAATRIX ждёт. Для общения, пожалуйста, зарегистрируйтесь в нашем личном кабинете.
Перейти в сообщество →