Client-side load balancing стал ключом к стабильной latency при экстремальном fan-out. Разберём, как Zalando перенес маршрутизацию внутрь процесса и какие компромиссы это принесло.
Система упёрлась в предсказуемость latency при высоком fan-out. Один batch-запрос разветвлялся до 100 параллельных вызовов к product pods. Каждый проходил через Skipper — edge load balancer кластера. В результате общая задержка зависела от самого медленного из 100 переходов. Команда не могла отделить собственные проблемы от всплесков latency внутри Skipper, который они не контролировали на уровне конкретного запроса. При нагрузке около 1 миллиона запросов в секунду это превращалось в системную непрозрачность и рост tail latency.
Решение выглядело контринтуитивно: не усиливать внешний балансировщик, а убрать его из критического пути для внутреннего fan-out. Команда перенесла маршрутизацию внутрь процесса, реализовав client-side load balancing. При этом Skipper остался для edge-трафика и простых запросов. Это важный компромисс: они не заменяли существующую инфраструктуру, а изолировали горячий путь с высокой кратностью вызовов. Такой подход снижает сетевые хопы и даёт контроль над routing decisions, но переносит сложность в приложение.
Ключевой деталью стала консистентность маршрутизации. Они воспроизвели алгоритм Skipper один в один: xxHash64 и 100 виртуальных нод на endpoint. Это позволило сохранить идентичные hash rings. Важно, потому что при миграции иначе возник бы cache split. При добавлении или удалении ноды перераспределяется только около 1/N ключей, что ограничивает churn. Поведение закрепили unit-тестами, чтобы исключить расхождения между реализациями. Это пример инженерного приоритета: стабильность данных важнее скорости внедрения.
В реализации пришлось решить несколько нетривиальных задач. Polling, который перегружал control plane, заменили на Kubernetes informer с watch-моделью. Это снизило нагрузку и ускорило реакцию на изменения. Для rollout использовали постепенное включение через toggles от 1% до 100%. Отдельное внимание уделили cache-aware scaling: новые поды не должны сразу получать полный поток. Для этого добавили N-ring fade-in с кривой ^2.5 на 30 секунд. Новые инстансы прогревают только релевантные ключи, что снижает latency spikes при масштабировании.
Попытка оптимизировать расходы через availability-zone routing показала обратную сторону. Кэш фрагментировался, и это вызвало всплеск чтений в DynamoDB. Это классический trade-off между локальностью и консистентностью кэша. В итоге от этой оптимизации отказались. Вместо этого систему усилили через jittered retry, FIFO shedding при перегрузке и более детальную observability. Улучшенное логирование позволило выявить кратковременные фризы отдельных нод, которые теперь обходятся автоматически.
Результаты выглядят прагматично. Latency стала более предсказуемой. Стоимость инфраструктуры снизилась: флот Skipper уменьшился с более чем 50 подов до 8, а ежедневные расходы — с $450 до $110. Улучшилась наблюдаемость (observability): команда получила контроль над точкой принятия решений. При этом метрики по точному выигрышу в latency не раскрыты, но косвенные признаки указывают на снижение tail latency.
Важно, что команда явно ограничивает применимость подхода. Client-side load balancing оправдан только на экстремальных нагрузках и высоком fan-out. В большинстве случаев зрелые решения вроде Skipper или Envoy дают достаточную функциональность без переноса сложности в код приложения. Это не универсальный паттерн, а точечная оптимизация под конкретный профиль нагрузки.
В индустрии этот подход обсуждается давно, но кейс показывает, где проходит граница его целесообразности. Когда сеть и внешний балансировщик становятся частью latency budget, перенос логики внутрь процесса может быть оправдан. Но цена — рост сложности, необходимость поддерживать алгоритмы маршрутизации и tighter coupling с инфраструктурой.