Хабрахабр

[Перевод] «Kubernetes увеличил задержку в 10 раз»: кто же в этом виноват?

Прим. перев.: Эта статья, написанная Galo Navarro, что занимает должность Principal Software Engineer в европейской компании Adevinta, — увлекательное и поучительное «расследование» в области эксплуатации инфраструктуры. Её оригинальное название было немного дополнено в переводе по причине, которую объясняет автор в самом начале.

Я до сих пор получаю гневные комментарии о том, что название статьи вводит в заблуждение и что некоторые читатели опечалены. Примечание от автора: Похоже, эта публикация привлекла гораздо больше внимания, чем ожидалось. При переходе команд на Kubernetes я наблюдаю любопытную вещь: каждый раз, когда возникает проблема (например, рост задержек после миграции), первым делом обвиняют Kubernetes, однако потом оказывается, что оркестратор, в общем-то, не виноват. Я понимаю причины происходящего, поэтому, несмотря на риск сорвать всю интригу, хочу сразу рассказать, о чем эта статья. Ее название повторяет восклицание одного из наших разработчиков (потом вы убедитесь, что Kubernetes тут вовсе ни при чем). Эта статья повествует об одном из таких случаев. В ней вы не найдете неожиданных откровений о Kubernetes, но можете рассчитывать на пару хороших уроков о сложных системах.

Переезд носил пробный характер: мы планировали взять его за основу и перенести еще примерно 150 сервисов в ближайшие месяцы. Пару недель назад моя команда занималась миграцией одного микросервиса на основную платформу, включающую CI/CD, рабочую среду на основе Kubernetes, метрики и другие полезности. Все они отвечают за работу некоторых из крупнейших онлайн-площадок Испании (Infojobs, Fotocasa и др.).

Задержка (latency) запросов в Kubernetes была в 10 раз выше, чем в EC2. После того, как мы развернули приложение в Kubernetes и перенаправили на него часть трафика, нас поджидал тревожный сюрприз. В общем, было необходимо либо искать решение этой проблемы, либо отказываться от миграции микросервиса (и, возможно, от всего проекта).

Почему в Kubernetes задержка настолько выше, чем в EC2?

Чтобы найти узкое место, мы собрали метрики на всем пути запроса. Наша архитектура проста: API-шлюз (Zuul) проксирует запросы к экземплярам микросервиса в EC2 или Kubernetes. В Kubernetes мы используем NGINX Ingress Сontroller, а бэкенды представляют собой обычные объекты типа Deployment с JVM-приложением на платформе Spring.

EC2 +---------------+ | +---------+ | | | | | +-------> BACKEND | | | | | | | | | +---------+ | | +---------------+ +------+ |
Public | | | -------> ZUUL +--+
traffic | | | Kubernetes +------+ | +-----------------------------+ | | +-------+ +---------+ | | | | | xx | | | +-------> NGINX +------> BACKEND | | | | | xx | | | | +-------+ +---------+ | +-----------------------------+

Казалось, что проблема связана с задержкой на начальном этапе работы в бэкенде (я пометил проблемный участок на графике как «хх»). В EC2 ответ приложения занимал около 20 мс. В Kubernetes задержка возрастала до 100—200 мс.

Версия JVM осталась прежней. Мы быстро отбросили вероятных подозреваемых, связанных со сменой среды выполнения. Загрузка? Проблемы контейнеризации также были ни при чем: приложение уже успешно работало в контейнерах в EC2. Паузами на сборку мусора также можно было пренебречь. Но мы наблюдали высокие задержки даже при 1 запросе в секунду.

Один из наших администраторов Kubernetes поинтересовался, нет ли у приложения внешних зависимостей, поскольку в прошлом запросы к DNS вызывали схожие проблемы.

Гипотеза 1: разрешение имен DNS

При каждом запросе наше приложение от одного до трех раз обращается к экземпляру AWS Elasticsearch в домене вроде elastic.spain.adevinta.com. Внутри контейнеров у нас имеется shell, поэтому мы можем проверить, действительно ли поиск домена занимает продолжительное время.

DNS-запросы из контейнера:

[root@be-851c76f696-alf8z /]# while true; do dig "elastic.spain.adevinta.com" | grep time; sleep 2; done
;; Query time: 22 msec
;; Query time: 22 msec
;; Query time: 29 msec
;; Query time: 21 msec
;; Query time: 28 msec
;; Query time: 43 msec
;; Query time: 39 msec

Аналогичные запросы из одного из экземпляров EC2, где работает приложение:

bash-4.4# while true; do dig "elastic.spain.adevinta.com" | grep time; sleep 2; done
;; Query time: 77 msec
;; Query time: 0 msec
;; Query time: 0 msec
;; Query time: 0 msec
;; Query time: 0 msec

Учитывая, что поиск занимает около 30 мс, стало ясно, что разрешение DNS при обращении к Elasticsearch действительно вносит вклад в возрастание задержки.

Однако это было странно по двум причинам:

  1. У нас уже есть масса приложений в Kubernetes, которые взаимодействуют с ресурсами AWS, но не страдают от больших задержек. Какой бы ни была причина, она имеет отношение конкретно к этому случаю.
  2. Мы знаем, что JVM осуществляет in-memory-кеширование DNS. В наших образах значение TTL прописано в $JAVA_HOME/jre/lib/security/java.security и установлено на 10 секунд: networkaddress.cache.ttl = 10. Другими словами, JVM должна кэшировать все DNS-запросы на 10 секунд.

Чтобы подтвердить первую гипотезу, мы решили на время отказаться от обращений к DNS и посмотреть, исчезнет ли проблема. Сперва мы решили перенастроить приложение, чтобы оно связывалось с Elasticsearch напрямую по IP-адресу, а не через доменное имя. Это потребовало бы правки кода и нового развертывания, поэтому мы просто сопоставили домен с его IP-адресом в /etc/hosts:

34.55.5.111 elastic.spain.adevinta.com

Теперь контейнер получал IP почти мгновенно. Это привело к некоторому улучшению, но мы лишь слегка приблизились к ожидаемому уровню задержки. Хотя разрешение DNS занимало много времени, настоящая причина по-прежнему ускользала от нас.

Диагностика с помощью сети

Мы решили проанализировать трафик из контейнера с помощью tcpdump, чтобы проследить, что именно происходит в сети:

[root@be-851c76f696-alf8z /]# tcpdump -leni any -w capture.pcap

Затем мы послали несколько запросов и скачали их capture (kubectl cp my-service:/capture.pcap capture.pcap) для дальнейшего анализа в Wireshark.

Но были определенные странности в том, как наш сервис обрабатывал каждый запрос. В DNS-запросах не было ничего подозрительного (кроме одной мелочи, о которой я расскажу позже). Ниже приведен скриншот capture'а, показывающий принятие запроса до начала ответа:

Для ясности я выделил цветом различные потоки TCP. Номера пакетов приведены в первом столбце.

17. Зеленый поток, начинающийся с 328-го пакета, показывает, как клиент (172. 150) установил TCP-соединение с контейнером (172. 22. 36. 17. После первичного рукопожатия (328-330), пакет 331 принес HTTP GET /v1/.. — входящий запрос к нашему сервису. 147). Весь процесс занял 1 мс.

На это ушло 18 мс. Серый поток (с пакета 339) показывает, что наш сервис послал HTTP-запрос к экземпляру Elasticsearch (TCP-рукопожатие отсутствует, поскольку используется уже имеющееся соединение).

Пока все нормально, и времена примерно соответствуют ожидаемым задержкам (20-30 мс при замерах с клиента).

Что в ней происходит? Однако синяя секция занимает 86 мс. С пакетом 333 наш сервис послал HTTP GET-запрос на /latest/meta-data/iam/security-credentials, а сразу после него, по тому же TCP-соединению, еще один GET-запрос на /latest/meta-data/iam/security-credentials/arn:...

Разрешение DNS действительно чуть медленнее в наших контейнерах (объяснение этого феномена весьма интересно, но я приберегу его для отдельной статьи). Мы обнаружили, что это повторяется с каждым запросом во всей трассировке. Оказалось, что причиной больших задержек являются обращения к сервису AWS Instance Metadata при каждом запросе.

Гипотеза 2: лишние обращения к AWS

Оба endpoint'а принадлежат AWS Instance Metadata API. Наш микросервис использует этот сервис во время работы с Elasticsearch. Оба вызова являются частью базового процесса авторизации. Endpoint, к которому происходит обращение при первом запросе, выдает роль IAM, связанную с экземпляром.

/ # curl http://169.254.169.254/latest/meta-data/iam/security-credentials/
arn:aws:iam::<account_id>:role/some_role

Второй запрос обращается ко второму endpoint'у за временными полномочиями для данного экземпляра:

/ # curl http://169.254.169.254/latest/meta-data/iam/security-credentials/arn:aws:iam::<account_id>:role/some_role`
{ "Code" : "Success", "LastUpdated" : "2012-04-26T16:39:16Z", "Type" : "AWS-HMAC", "AccessKeyId" : "ASIAIOSFODNN7EXAMPLE", "SecretAccessKey" : "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", "Token" : "token", "Expiration" : "2017-05-17T15:09:54Z"
}

Клиент может пользоваться ими в течение небольшого периода времени и периодически должен получать новые сертификаты (до их Expiration). Модель проста: AWS проводит частую ротацию временных ключей по соображениям безопасности, но клиенты могут кэшировать их на несколько минут, компенсируя снижение производительности, связанное с получением новых сертификатов.

AWS Java SDK должен на себя взять обязанности по организации данного процесса, однако по какой-то причине этого не происходит.

Она помогла нам определить направление, в котором следует «копать» дальше. Проведя поиск по issues на GitHub, мы натолкнулись на проблему #1921.

AWS SDK обновляет сертификаты при наступлении одного из следующих условий:

  • Срок окончания их действия (Expiration) попадает в EXPIRATION_THRESHOLD, жестко установленный в коде на 15 минут.
  • С момента последней попытки обновить сертификаты прошло больше времени, чем REFRESH_THRESHOLD, за'hardcode'енный на 60 минут.

Чтобы посмотреть фактический срок истечения получаемых нами сертификатов, мы выполнили приведенные выше cURL-команды из контейнера и из экземпляра EC2. Время действия сертификата, полученного из контейнера, оказалось гораздо короче: ровно 15 минут.

Поскольку срок их действия не превышал 15 минут, при последующем запросе AWS SDK решал обновить их. Теперь все стало ясно: для первого запроса наш сервис получал временные сертификаты. И такое происходило с каждым запросом.

Почему срок действия сертификатов стал короче?

Сервис AWS Instance Metadata предназначен для работы с экземплярами EC2, а не Kubernetes. С другой стороны, нам не хотелось менять интерфейс приложений. Для этого мы воспользовались KIAM — инструментом, который с помощью агентов на каждом узле Kubernetes позволяет пользователям (инженерам, развертывающим приложения в кластер) присваивать IAM-роли контейнерам в pod'ах так, словно они являются инстансами EC2. KIAM перехватывает вызовы к сервису AWS Instance Metadata и обрабатывает их из своего кэша, предварительно получив от AWS. С точки зрения приложения ничего не меняется.

Это разумно, если учесть, что средняя продолжительность существования pod'а меньше, чем экземпляра EC2. KIAM поставляет краткосрочные сертификаты pod'ам. По умолчанию срок действия сертификатов равен тем же 15 минутам.

Каждый сертификат, предоставленный приложению, истекает через 15 минут. В итоге, если наложить оба значения по умолчанию друг на друга, возникает проблема. При этом AWS Java SDK принудительно обновляет любой сертификат, до срока окончания действия которого остается менее 15 минут.

В AWS Java SDK мы обнаружили feature request, в котором упоминается аналогичная проблема. В результате, временный сертификат принудительно обновляется с каждым запросом, что влечет за собой пару обращений к API AWS и приводит к значительному увеличению задержки.

Мы просто перенастроили KIAM на запрос сертификатов с более длительным сроком действия. Решение оказалось простым. Как только это произошло, запросы стали проходить без участия сервиса AWS Metadata, а задержка упала даже до более низкого уровня, чем в EC2.

Выводы

Исходя из нашего опыта с миграциями можно сказать, что один из наиболее частых источников проблем — это не ошибки в Kubernetes или других элементах платформы. Также он не связан с какими-либо фундаментальными изъянами в микросервисах, которые мы переносим. Проблемы часто возникают просто потому, что мы соединяем вместе различные элементы.

Увы, чем больше элементов, тем больше простор для ошибок, тем выше энтропия. Мы перемешиваем сложные системы, которые никогда ранее не взаимодействовали друг с другом, ожидая, что вместе они образуют единую, более крупную систему.

Она стала итогом объединения двух независимых параметров, заданных по умолчанию: одного в KIAM, другого — в AWS Java SDK. В нашем случае высокая задержка не была результатом ошибок или плохих решений в Kubernetes, KIAM, AWS Java SDK или нашем микросервисе. Но если собрать их вместе, результаты становятся непредсказуемыми. По отдельности оба параметра имеют смысл: и активная политика обновления сертификатов в AWS Java SDK, и короткий срок действия сертификатов в KAIM. Два независимых и логичных решения вовсе не обязаны иметь смысл при объединении.

P.S. от переводчика

Узнать подробнее про архитектуру утилиты KIAM для интеграции AWS IAM с Kubernetes можно в этой статье от её создателей.

А в нашем блоге также читайте:

Показать больше

Похожие публикации

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *

Кнопка «Наверх»