Прим. перев.: Эта статья, написанная Galo Navarro, что занимает должность Principal Software Engineer в европейской компании Adevinta, — увлекательное и поучительное «расследование» в области эксплуатации инфраструктуры. Её оригинальное название было немного дополнено в переводе по причине, которую объясняет автор в самом начале.
Примечание от автора: Похоже, эта публикация привлекла гораздо больше внимания, чем ожидалось. Я до сих пор получаю гневные комментарии о том, что название статьи вводит в заблуждение и что некоторые читатели опечалены. Я понимаю причины происходящего, поэтому, несмотря на риск сорвать всю интригу, хочу сразу рассказать, о чем эта статья. При переходе команд на Kubernetes я наблюдаю любопытную вещь: каждый раз, когда возникает проблема (например, рост задержек после миграции), первым делом обвиняют Kubernetes, однако потом оказывается, что оркестратор, в общем-то, не виноват. Эта статья повествует об одном из таких случаев. Ее название повторяет восклицание одного из наших разработчиков (потом вы убедитесь, что Kubernetes тут вовсе ни при чем). В ней вы не найдете неожиданных откровений о Kubernetes, но можете рассчитывать на пару хороших уроков о сложных системах.
Пару недель назад моя команда занималась миграцией одного микросервиса на основную платформу, включающую CI/CD, рабочую среду на основе Kubernetes, метрики и другие полезности. Переезд носил пробный характер: мы планировали взять его за основу и перенести еще примерно 150 сервисов в ближайшие месяцы. Все они отвечают за работу некоторых из крупнейших онлайн-площадок Испании (Infojobs, Fotocasa и др.).
После того, как мы развернули приложение в Kubernetes и перенаправили на него часть трафика, нас поджидал тревожный сюрприз. Задержка (latency) запросов в Kubernetes была в 10 раз выше, чем в EC2. В общем, было необходимо либо искать решение этой проблемы, либо отказываться от миграции микросервиса (и, возможно, от всего проекта).
Почему в 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 действительно вносит вклад в возрастание задержки.
Однако это было странно по двум причинам:
- У нас уже есть масса приложений в Kubernetes, которые взаимодействуют с ресурсами AWS, но не страдают от больших задержек. Какой бы ни была причина, она имеет отношение конкретно к этому случаю.
- Мы знаем, что 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.
Зеленый поток, начинающийся с 328-го пакета, показывает, как клиент (172.17.22.150) установил TCP-соединение с контейнером (172.17.36.147). После первичного рукопожатия (328-330), пакет 331 принес HTTP GET /v1/..
— входящий запрос к нашему сервису. Весь процесс занял 1 мс.
Серый поток (с пакета 339) показывает, что наш сервис послал HTTP-запрос к экземпляру Elasticsearch (TCP-рукопожатие отсутствует, поскольку используется уже имеющееся соединение). На это ушло 18 мс.
Пока все нормально, и времена примерно соответствуют ожидаемым задержкам (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. С точки зрения приложения ничего не меняется.
KIAM поставляет краткосрочные сертификаты pod'ам. Это разумно, если учесть, что средняя продолжительность существования pod'а меньше, чем экземпляра EC2. По умолчанию срок действия сертификатов равен тем же 15 минутам.
В итоге, если наложить оба значения по умолчанию друг на друга, возникает проблема. Каждый сертификат, предоставленный приложению, истекает через 15 минут. При этом AWS Java SDK принудительно обновляет любой сертификат, до срока окончания действия которого остается менее 15 минут.
В результате, временный сертификат принудительно обновляется с каждым запросом, что влечет за собой пару обращений к API AWS и приводит к значительному увеличению задержки. В AWS Java SDK мы обнаружили feature request, в котором упоминается аналогичная проблема.
Решение оказалось простым. Мы просто перенастроили KIAM на запрос сертификатов с более длительным сроком действия. Как только это произошло, запросы стали проходить без участия сервиса AWS Metadata, а задержка упала даже до более низкого уровня, чем в EC2.
Выводы
Исходя из нашего опыта с миграциями можно сказать, что один из наиболее частых источников проблем — это не ошибки в Kubernetes или других элементах платформы. Также он не связан с какими-либо фундаментальными изъянами в микросервисах, которые мы переносим. Проблемы часто возникают просто потому, что мы соединяем вместе различные элементы.
Мы перемешиваем сложные системы, которые никогда ранее не взаимодействовали друг с другом, ожидая, что вместе они образуют единую, более крупную систему. Увы, чем больше элементов, тем больше простор для ошибок, тем выше энтропия.
В нашем случае высокая задержка не была результатом ошибок или плохих решений в Kubernetes, KIAM, AWS Java SDK или нашем микросервисе. Она стала итогом объединения двух независимых параметров, заданных по умолчанию: одного в KIAM, другого — в AWS Java SDK. По отдельности оба параметра имеют смысл: и активная политика обновления сертификатов в AWS Java SDK, и короткий срок действия сертификатов в KAIM. Но если собрать их вместе, результаты становятся непредсказуемыми. Два независимых и логичных решения вовсе не обязаны иметь смысл при объединении.
P.S. от переводчика
Узнать подробнее про архитектуру утилиты KIAM для интеграции AWS IAM с Kubernetes можно в этой статье от её создателей.
А в нашем блоге также читайте:
- «3 истории сбоев Kubernetes в production: anti-affinity, graceful shutdown, webhook»;
- «Как приоритеты pod'ов в Kubernetes стали причиной простоя в Grafana Labs»;
- «6 занимательных системных багов при эксплуатации Kubernetes [и их решение]»;
- «6 практических историй из наших SRE-будней».
Автор: Wimbo