Каждый раз при перезапуске Atlantis — инструмента, с помощью которого мы планируем и применяем изменения Terraform — нам приходилось ждать полчаса, пока он снова заработает. Всё это время никаких plan, никаких apply, никаких изменений инфраструктуры ни в одном из репозиториев, управляемых Atlantis. Примерно 100 перезапусков в месяц (ротации учётных данных и онбординг новых проектов) выливались в более чем 50 часов заблокированного инженерного времени ежемесячно — и каждый раз будили дежурного инженера.
В основе проблемы лежало безопасное по умолчанию поведение Kubernetes, которое незаметно превратилось в узкое место по мере того, как постоянный том (persistent volume) Atlantis разросся до миллионов файлов. Рассказываем, как мы это выяснили и устранили одной строкой конфигурации.
Мы управляем десятками Terraform-проектов через merge request’ы (MR) в GitLab, используя Atlantis — он берёт на себя планирование и применение изменений. Atlantis реализует блокировку: только один MR может в каждый момент изменять конкретный проект.
Atlantis работает в Kubernetes как одиночный StatefulSet и использует PersistentVolume (PV) для хранения состояния репозиториев на диске. При онбординге или офбординге Terraform-проекта, а также при обновлении учётных данных, которые использует Terraform, приходится перезапускать Atlantis — и вот этот перезапуск мог занимать 30 минут.
Проблема с медленным перезапуском в полной мере проявилась, когда мы недавно исчерпали инода (inode) на постоянном хранилище Atlantis и были вынуждены перезапустить его, чтобы расширить том. Иноды расходуются на каждый файл и каталог на диске; их количество определяется параметрами, переданными при создании файловой системы. Реализация постоянного хранилища на базе Ceph, которую предоставляет наша Kubernetes-платформа, не позволяет передавать флаги в mkfs, так что мы ограничены значениями по умолчанию: единственный способ увеличить число доступных инод — вырастить саму файловую систему, а это требует перезапуска пода с PV.
Мы обсуждали расширение окна алертинга, но это лишь маскировало бы проблему и затягивало реакцию на реальные инциденты. Поэтому мы решили разобраться, почему перезапуск занимает так много времени.
При плановом перезапуске Atlantis для подхватывания обновлённых секретов мы выполняли kubectl rollout restart statefulset atlantis. Kubernetes корректно завершал существующий под перед запуском нового. Новый под появлялся почти сразу, но при проверке показывал:
$ kubectl get pod atlantis-0
atlantis-0 0/1
Init:0/1 0 30m
Что происходит? Первым делом — смотрим события пода. Он ждёт выполнения init-контейнера; может, события прояснят причину?
$ kubectl events --for=pod/atlantis-0
LAST SEEN TYPE REASON OBJECT MESSAGE
30m Normal Killing Pod/atlantis-0 Stopping container atlantis-server
30m Normal Scheduled Pod/atlantis-0 Successfully assigned atlantis/atlantis-0 to 36com1167.cfops.net
22s Normal Pulling Pod/atlantis-0 Pulling image "oci.example.com/git-sync/master:v4.1.0"
22s Normal Pulled Pod/atlantis-0 Successfully pulled image "oci.example.com/git-sync/master:v4.1.0" in 632ms (632ms including waiting). Image size: 58518579 bytes.
Выглядит почти нормально… но почему между планированием пода и началом загрузки образа init-контейнера такой огромный разрыв? К сожалению, это всё, что Kubernetes мог нам сообщить. Где ещё искать информацию о причинах задержки?
В Kubernetes компонент kubelet, запущенный на каждом узле, отвечает за координацию создания подов, монтирование постоянных томов и многое другое. Из опыта работы в нашей Kubernetes-команде я знал, что kubelet работает как systemd-сервис, а значит, его логи должны быть доступны в Kibana. Имя узла нам уже известно (под запланирован), а сообщения kubelet содержат связанный объект — можно отфильтровать по atlantis и сосредоточиться на интересующих нас записях.
В логах мы наблюдали монтирование PV Atlantis вскоре после планирования пода. Все тома с секретами также монтировались без проблем. Однако в логах всё равно зияла большая необъяснимая пауза. Мы увидели:
[operation_generator.go:664] "MountVolume.MountDevice succeeded for volume \"pvc-94b75052-8d70-4c67-993a-9238613f3b99\" (UniqueName: \"kubernetes.io/csi/rook-ceph-nvme.rbd.csi.ceph.com^0001-000e-rook-ceph-nvme-0000000000000002-a6163184-670f-422b-a135-a1246dba4695\") pod \"atlantis-0\" (UID: \"83089f13-2d9b-46ed-a4d3-cba885f9f48a\") device mount path \"/state/var/lib/kubelet/plugins/kubernetes.io/csi/rook-ceph-nvme.rbd.csi.ceph.com/d42dcb508f87fa241a49c4f589c03d80de2f720a87e36932aedc4c07840e2dfc/globalmount\"" pod="atlantis/atlantis-0"
[pod_workers.go:1298] "Error syncing pod, skipping" err="unmounted volumes=[atlantis-storage], unattached volumes=[], failed to process volumes=[]: context deadline exceeded" pod="atlantis/atlantis-0" podUID="83089f13-2d9b-46ed-a4d3-cba885f9f48a"
[util.go:30] "No sandbox for pod can be found. Need to start a new one" pod="atlantis/atlantis-0"
Последние два сообщения повторялись в цикле, пока под наконец не запустился нормально.
Итак, kubelet считает, что под в целом готов к запуску, но не стартует его, и что-то истекает по таймауту.
Самые детальные логи по поду не давали ответа. Что ещё можно проверить? Последнее событие перед зависанием — монтирование PV на узел. Обычно, если PV не удаётся смонтировать (например, он ещё висит примонтированным на другом узле), это всплывает в виде события. Но что-то явно происходит, и единственное, куда можно копнуть глубже, — сам PV. Я ввёл его имя в Kibana — оно достаточно уникально, чтобы служить хорошим поисковым запросом — и сразу наткнулся на кое-что примечательное:
[volume_linux.go:49] Setting volume ownership for /state/var/lib/kubelet/pods/83089f13-2d9b-46ed-a4d3-cba885f9f48a/volumes/kubernetes.io~csi/pvc-94b75052-8d70-4c67-993a-9238613f3b99/mount and fsGroup set. If the volume has a lot of files then setting volume ownership could be slow, see https://github.com/kubernetes/kubernetes/issues/69699
Помните, с чего всё началось — мы только что исчерпали иноды? Иными словами, на этом PV очень много файлов. При каждом монтировании kubelet запускает chgrp -R, рекурсивно меняя группу для каждого файла и каталога во всей файловой системе. Неудивительно, что это занимает так много времени — даже на быстром флеш-хранилище обход миллионов записей требует времени.
В секции spec.securityContext пода было задано fsGroup: 1, что обеспечивает доступ к файлам тома для процессов, работающих под GID 1. Atlantis запускается не от root, и без этой настройки он не смог бы читать или писать в PV. Kubernetes обеспечивает это, рекурсивно обновляя права собственности на весь PV при каждом монтировании.
Исправление оказалось… героически скучным. Начиная с версии 1.20, Kubernetes поддерживает дополнительное поле fsGroupChangePolicy в pod.spec.securityContext. По умолчанию оно равно Always — именно это поведение мы и наблюдали. Существует альтернативное значение OnRootMismatch: права меняются только тогда, когда корневой каталог PV имеет неправильные разрешения. Если вы точно не знаете, как создаются файлы в вашем PV, не устанавливайте fsGroupChangePolicy: OnRootMismatch. Мы убедились, что ничто не должно менять группу ни на одном файле в PV, и добавили это поле:
spec:
template:
spec:
securityContext:
fsGroupChangePolicy: OnRootMismatch
Теперь Atlantis перезапускается примерно за 30 секунд — вместо 30 минут, с которых мы начинали.
Настройки Kubernetes по умолчанию разумны для небольших томов, но при росте данных они могут превращаться в узкие места. В нашем случае одна строка с fsGroupChangePolicy вернула почти 50 часов заблокированного инженерного времени в месяц. Именно столько уходило у команд на ожидание прохождения изменений инфраструктуры, а у дежурных инженеров — на реакцию на ложные срабатывания. В год это около 600 часов продуктивной работы, возвращённых ценой исправления, на диагностику которого ушло больше времени, чем на само развёртывание.
Безопасные настройки Kubernetes рассчитаны на небольшие простые нагрузки. Но по мере масштабирования они могут незаметно становиться узкими местами. Если вы запускаете рабочие нагрузки с большими постоянными томами, стоит проверить, не поглощает ли рекурсивная смена прав незаметно всё время ваших перезапусков. Проаудируйте настройки securityContext, особенно fsGroup и fsGroupChangePolicy. Значение OnRootMismatch доступно начиная с версии v1.20.
Не каждое исправление требует героических усилий или сложных решений — и обычно стоит задать вопрос: «Почему система ведёт себя именно так?»
Если отладка инфраструктурных проблем в масштабе кажется вам интересной задачей, мы нанимаем. Также приглашаем в Cloudflare Community и наш Discord — поговорить о деле.