Кэш не работал восемь недель. Всё это время он честно писал об этом в лог
16 сентября я полез в сервис прогнозов разбираться с другой задачей и заметил, что эндпоинт загруженности отвечает подозрительно долго. Он должен отдавать готовый ответ из Redis. Отдавал он его за две секунды.
Выяснилось, что Redis у этого сервиса не было восемь недель. Свой контейнер (container_name: redis, порт 6381) завершился с кодом 255 и больше не поднимался. Имя redis в докеровской сети перестало резолвиться, подключение при старте падало, и все восемь недель каждый запрос считался от начала.
Это скучная часть. Интересная дальше.
Код был к этому готов
В инициализации кэша стоит обработка ошибки подключения:
except Exception as e:
logger.error(f"Error connecting to Redis: {e}")
# Создаем заглушку для Redis, чтобы приложение могло работать без кэша
self.redis_client = None
Комментарий обещает заглушку. Заглушки нет. Есть None.
Разница вылезает в методах. Каждый из них выглядит аккуратно: обёрнут в try, пишет ошибку, возвращает безопасное значение.
def get(self, key):
try:
data = self.redis_client.get(key)
...
except Exception as e:
logger.error(f"Error getting from cache: {e}")
return None
Когда redis_client равен None, self.redis_client.get(key) это обращение к атрибуту у None. Исключение ловится тем же except, пишется в лог и подменяется на None, то есть на «в кэше ничего нет». Снаружи поведение неотличимо от пустого кэша.
Я воспроизвёл эту ветку отдельно, чтобы посмотреть, что получается на один запрос:
ERROR redis-cache: Error connecting to Redis: Name or service not known
ERROR redis-cache: Error getting from cache: 'NoneType' object has no attribute 'get'
ERROR redis-cache: Error setting cache: 'NoneType' object has no attribute 'set'
Две строки уровня ERROR на каждый запрос, восемь недель подряд. Авария не была тихой. Она всё это время докладывала о себе ровно тем уровнем логирования, который для этого придуман.
Почему я этого не видел
Потому что в логи заходят, когда что-то сломалось. А ничего не сломалось.
Сайт работал. Карта рисовалась. Данные приходили правильные, просто считались заново на каждый запрос. У аварии не было ни одного симптома, который заставил бы меня открыть лог. А когда я открывал его по другому поводу, эти строки стояли на каждом запросе месяцами и выглядели как часть обычного вывода.
ERROR без последствий перестаёт быть ошибкой и становится фоном. Причём чем регулярнее он пишется, тем надёжнее превращается в фон: редкую строку замечаешь, строку на каждом запросе пролистываешь.
Три «ок» подряд
Отдельно обидно, что спросить сервис о кэше было можно. У класса есть готовый метод:
def get_status(self):
return {
"available": self.is_available(),
"last_update": ...,
"ttl": CACHE_TTL,
}
is_available() дёргает ping() и в этом состоянии вернул бы False. Метод написан, протестировать его некому: он не подключён ни к одному эндпоинту.
А то, что отдаётся наружу, выглядит так. Health сервиса, прямо сейчас, в проде:
$ curl -s https://parkout.ru/api/ml/health
{"status":"ok","environment":"production","timestamp":"2025-05-25T22:12:30"}
status вписан в код константой, никаких проверок за ним нет. timestamp тоже константа, строковый литерал из мая 2025 года. Сегодня октябрь 2026, и этот ответ не менялся семнадцать месяцев.
Выше в стопке nginx:
location /health {
return 200 "OK\n";
add_header Content-Type text/plain;
}
Тоже константа, и она перехватывает путь раньше, чем запрос доходит до любого сервиса.
В Uptime Kuma три монитора, все на публичный сайт, до этого сервиса ни один не дотягивается. У монитора на API проверка по ключевому слову и таймаут 48 секунд, так что разница между 11 миллисекундами и двумя секундами для него неотличима от нуля.
Ни одно из этих «ок» не было ложью про то, что оно проверяет. Каждое честно сообщало, что процесс принимает соединения. Просто ни одно из них не проверяло ничего другого, и их совокупность читалась как «всё в порядке».
Сколько это стоило
Замерил сегодня, внутри контейнера, без сети. Промах кэша это полный пересчёт по базе, попадание это выдача готового ответа на 150 КБ. По десять запросов в одном процессе:
промах 1,91 с 1,88 с
попадание 11,3 мс (медиана), мин 10,7, макс 50,4
Около ста семидесяти раз. В сентябрьском замере, который я записал в коммит, было 1,3 с против 6 мс, то есть мерил я тогда другим способом, но порядок тот же.
Стоит сказать и то, насколько это было терпимо. Две секунды это некрасиво, но не авария. TTL у ключа пять минут, значит и при живом кэше каждый пятиминутный интервал кто-то один платит эту цифру. Восемь недель сервис работал в режиме «каждый платит», и единственное, что это дало, это лишняя нагрузка на базу и медленная карта у тех, кто зашёл первым.
Именно поэтому я и не заметил. Если бы кэш был на критическом пути и его отсутствие ломало ответ, я бы узнал об этом в тот же день.
Чем починил
Второй Redis я поднимать не стал. На этой машине узким местом была память, а рядом уже работал Redis другого сервиса, так что я переключил прогнозы на него и подключил его к нужной сети. Свой контейнер, том и depends_on из compose убрал.
Одна тонкость там оказалась неожиданная: номер базы в общем Redis пришлось менять не из осторожности, а потому что иначе два сервиса затирали бы друг другу кэш. Про это отдельно, история своя.
Заодно пришлось штатно пересобрать образ, и из этого вылезло, что docker-compose.yml этого сервиса вообще не лежал в репозитории, а веса модели доставлялись в контейнер через docker cp. Авария с кэшем сама по себе стоила недорого, но она была единственной причиной, по которой я туда вообще заглянул.
Что из этого следует
Мне кажется, главное тут не про Redis и не про Docker.
Обработка ошибки, которая возвращает безопасное значение, превращает отказ в деградацию. Это ровно то, зачем её пишут. Но у деградации нет момента, когда кто-то узнаёт о ней извне, и значит сообщение в логе становится единственным каналом. Канал этот работает только если кто-то в него смотрит, а смотрят в него по симптомам, которых у деградации по определению нет.
Из этого следует неприятное: аккуратно обработанный отказ нужно мерить, а не логировать. Счётчик промахов, доля попаданий, что угодно, что можно спросить снаружи и сравнить с ожиданием. logger.error в такой ситуации не уведомление, а запись в журнал для того, кто уже пришёл разбираться.
И про комментарии. Тот, который обещал заглушку, был написан честно: автор (я) собирался сделать работу без кэша штатным режимом. Код этого не делает, он проваливается в ту же ветку через исключение на каждом вызове. Комментарий описал намерение, а проверить его было нечем, потому что тест на поведение без Redis никто не писал. Между «приложение работает без кэша» и «приложение работает без кэша через AttributeError дважды на запрос» разница небольшая по последствиям, но она ровно та, из-за которой в логе восемь недель лежал ERROR.
Данные, на которых всё это считается, лежат открыто: 4,6 млн получасовых замеров загруженности парковок, CC BY 4.0.
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.