Если у вас фича задеплоилась без единой ошибки, контейнер стоит зелёный и горит Up, а сама фича почему-то не работает, то первым делом лезьте смотреть, доходит ли переменная окружения внутрь контейнера, а не просто лежит ли она в .env. Потому что docker-compose с явным блоком environment пробрасывает в контейнер ТОЛЬКО те переменные, которые вы там перечислили руками, а весь остальной env-file он подставляет лишь в сам compose-файл через ${...} и до процесса внутри не доносит. Я на эту граблю наступил уже второй раз, поэтому пишу не как урок свысока, а как человек, который реально упустил утренний пуш живым юзерам и теперь рассказывает, как это выглядит изнутри.
Дело было так. У меня в Картаре есть утренний дайджест — пуш, который должен прилетать пользователю с утра. Накануне мы вроде как всё сделали, выкатили, я лёг спать спокойный. А утром открываю телефон и пишу в сессию буквально: «Кстати! Пуша не было нового!!! никакого не было!». И тут же ловлю себя на второй странной детали — тогла этой фичи в настройках тоже нет. Ни в Telegram Mini App, ни в веб-версии. То есть фича как будто и не родилась, хотя по логам деплоя всё прошло. Я ведь ещё накануне это чувствовал, прямо так и написал: «Мы вчера вроде делали пуши. Но чет настройки я смотрю не поменялись».
Что на самом деле сломалось
Стали с Claude Code разбирать — и вылезло сразу два бага, причём оба из той самой породы, которую я больше всего не люблю: когда каждый отдельный слой вроде сделан правильно, а целиком не работает, потому что между слоями потерялась связка.
Баг номер раз. Флаг MORNING_DIGEST_ENABLED честно лежал в config.py, честно был в .env. Казалось бы, что ещё надо? А надо, чтобы он попал в whitelist блока environment у сервиса bot в docker-compose.yml. А его там не было. И вот тут включается главная подстава Compose, о которой почему-то узнаёшь только когда уже больно: --env-file и блок environment — это две разные вещи. env-file нужен, чтобы внутри compose-файла можно было писать ${SOME_VAR} и оно подставилось при сборке конфига. А чтобы переменная реально оказалась внутри запущенного контейнера, она должна быть перечислена в блоке environment этого сервиса. Нет строчки — нет переменной. Compose не пробрасывает весь env-file внутрь автоматом, он отдаёт контейнеру ровно то, что вы ему явно назвали.
Дальше всё разворачивается по цепочке. Внутри контейнера os.getenv('MORNING_DIGEST_ENABLED') возвращает None, None превращается в дефолт 'False', а у меня на этой фиче стоит kill-switch — если флаг выключен, job делает ранний return и просто молча выходит. Никакой ошибки, никакого падения, ничего красного в логах. Job отработал, ничего не сделал и ушёл. Снаружи это выглядит как «всё нормально», а на деле пуш не ушёл ни одному человеку.
Баг номер два, параллельный. Тогла morningDigestPush не было в UI обоих фронтов — ни в TMA, ни в Web. То есть даже если бы флаг доходил, пользователь всё равно не смог бы этой штукой управлять руками. Классическая cross-cutting регрессия: бэкенд написали, миграцию сделали, API подняли, а UI забыли. Каждый слой по отдельности окей, а вместе — дыра. Про то, почему фичи вечно расходятся между двумя фронтами, у меня есть отдельный пост — это ровно тот же зверь.
Почему это бесит сильнее обычного бага
Обычный баг — это когда что-то падает, ты видишь стектрейс, идёшь и чинишь. А тут падать нечему. Всё зелёное. Контейнер Up, healthcheck отвечает, деплой прошёл. И вот это самое опасное: «работает» в смысле «контейнер поднялся» и «работает» в смысле «фича реально выполняется для юзера» — это два разных слова, которые случайно пишутся одинаково. Я ровно на этой подмене и попался, потому что у меня в голове галочка «задеплоил» автоматом приравнивалась к «фича живёт», а между ними целая пропасть, в которую и провалилась переменная окружения.
И ладно бы первый раз. Но это повтор той же самой грабли, что уже была с morning-digest раньше — env банально не доезжает до контейнера, один в один. Вот это меня и добило больше всего: не сам баг, а то, что я наступил на него дважды. А что, кожаные разработчики не наступают на одни и те же грабли по два раза? не смешите меня. Но если ты соло и тебя никто не страхует, второй раз на той же грабле — это уже сигнал, что проблема не в конкретной строчке, а в том, как ты вообще проверяешь работу.
Как чинили
Сам фикс прозаичный — три строчки и одно правило. Добавили MORNING_DIGEST_ENABLED в блок environment сервиса bot в docker-compose.yml, прописали её в .env.example и в .env.dev, чтобы в следующий раз это не было сюрпризом ни для меня, ни для будущего меня, который про это забудет. И главное — занёс правило прямо в CLAUDE.md, чтобы Claude Code при работе сам по нему сверялся. Потому что фикс одной фичи — это пластырь, а правило ловит весь класс таких косяков.
Я тогда прямо в сессии и сформулировал, как смог: «Давай ка новое правило какое то придумаем, что при делании проверять все связи. Потому что вот мы пуши то сделали а вот настройки не трогали!». Звучит грубо, но суть железная — при любом изменении надо проходить по всем слоям, которых это изменение касается, а не только по тому, где ты сейчас руками копаешь. Добавил флаг? Пройди: миграция, бэкенд-логика, API, UI на TMA, UI на Web, env в .env.example, env в .env.dev, и отдельным пунктом — env в блоке environment обоих сервисов в compose. И последним гвоздём — после деплоя зайти в контейнер и глазами проверить, что переменная там реально есть, через docker exec и grep по флагу. Не «должна быть», а «вот она, вижу».
Что я из этого вынес
Вывод первый и самый практичный, про вайбкодинг и вообще разработку с ИИ: нейросеть пишет код отлично, но она пишет его по тому слою, который вы ей показали. Если вы сказали «сделай бэкенд утреннего пуша», она сделает идеальный бэкенд и не обязана сама догадаться, что эту переменную ещё надо протащить через docker-compose до контейнера и нарисовать тогл на двух фронтах. Связки между слоями — это зона ответственности того, кто оркестрирует, то есть моя. ИИ не телепат, он видит ровно тот кусок карты, который вы подсветили.
Вывод второй, про сам деплой. Healthcheck отвечает «я живой» — это не то же самое, что «фича работает». После каждого деплоя надо делать не пинг контейнера, а реальную проверку самой фичи: дёрнуть её ровно так, как дёрнул бы живой пользователь, и убедиться, что результат появился. В моём случае — дождаться утреннего пуша или принудительно запустить job и посмотреть, ушло ли сообщение. Я писал про похожий капкан в посте Тесты зелёные, экран пустой — там тоже всё было зелёное, а фичи для юзера не было. Один и тот же зверь, только морда разная.
И вывод третий, философский, но он мне дороже всех. В одиночку, когда за тобой нет ни QA, ни второго разраба, единственная защита от таких тихих дыр — это не геройство и не «буду внимательнее», а тупой письменный чеклист, который ты проходишь руками каждый раз и которому учишь своего ИИ-агента через CLAUDE.md. Не надеяться на память — она подведёт ровно в тот момент, когда ты лёг спать довольный, а чеклист не ляжет. Вот и делайте выводы. А кому интересно, как этот же класс багов выглядит со стороны env-переменных при разработке mvp, расскажу подробнее в следующий раз.