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.

Комментарии (2)


  1. stackshortlist
    16.10.2026 15:06

    Отличный пример того, как аккуратная обработка ошибок превращается в тихий отказ: каждый метод вежливо ловит исключение, и деградация становится нормой. Мне кажется, здесь помогла бы не только явная заглушка, но и метрика hit rate кэша с алертом, ведь ноль попаданий за восемь недель был бы виден сразу. А health check на зависимости при старте вы в итоге добавили, или решили, что сервис должен уметь жить без Redis?


    1. cmtn Автор
      16.10.2026 15:06

      Сделал, и заодно прямой ответ на вопрос: не было ни заглушки, ни health check. Сервис всё это время жил без Redis молча.

      Самое неприятное выяснилось, когда полез проверять. В том же классе RedisCache уже лежали готовые is_available() и get_status(). Написаны тогда же, вместе с кэшем. Не вызывались ниоткуда, grep по сервису давал ноль. То есть проверки зависимости не то чтобы не хватало: она была написана и не подключена к /health.

      Теперь статус считается по зависимости, а рядом лежат счётчики:

      {"status":"ok","environment":"production",
       "cache":{"available":true,"ttl":3600,"db":1,
                "hits":1,"misses":1,"errors":0,"hit_rate":0.5}}
      

      Про hit rate уточнение, которое мне кажется важным. Счётчики пришлось завести в приложении, а не брать из INFO Redis. В том отказе get падал с AttributeError на None и возвращал None, до Redis не доходило ни одной команды. Его keyspace_hits и keyspace_misses остались бы нулями оба, hit rate вышел бы 0/0, то есть неопределённым. На дашборде это читается как «метрика сломалась», а не как «кэш мёртв». Поэтому у меня hit_rate равен null при нулевых обращениях, и отдельно считаются errors: мёртвый кэш это null плюс растущие errors, а не ноль процентов.

      Проверил оба состояния. Живой кэш на проде: первый запрос /occupancy/current 17,4 с, второй 0,013 с, hit_rate 0.5. Состояние без Redis на подменённом клиенте: available false, errors растут, hit_rate null, статус degraded.

      Код ответа при degraded оставил 200, чтобы балансировщик не выбросил живой инстанс: сервис без кэша медленный, но рабочий. Значит проверять надо тело, а не код. И вот этого у меня пока нет: монитора на /health этого сервиса в Uptime Kuma просто не существует, там три проверки и все на фронт и основной API. Ровно та же ошибка, что в статье, этажом выше.