DevOps придумали разработчики, чтобы админы больше работали +87


Еще 4 года назад использование контейнеров в production было экзотикой, но сейчас это уже норма как для маленьких компаний, так и для больших корпораций. Давайте попробуем посмотреть на всю эту историю с devops/контейнерами/микросервисами ретроспективно, взглянуть еще раз свежим взглядом на то, какие задачи мы изначально пытались решить, какие решения у нас есть сейчас и чего не хватает для полного счастья?


Я буду в большей степени рассуждать про production окружение, так как основную массу нерешенных проблем я вижу именно там.


Раньше production окружение выглядело примерно так:


  • монолитное приложение, работающее в гордом одиночестве на сервере или виртуалке
  • БД на отдельных серверах
  • фронтенды
  • вспомогательные инфраструктурные сервисы (кэши, брокеры очередей итд)

В какой-то момент бизнес начал сильно смещаться в IT (к цифровому продукту, как модно сейчас говорить), это повлекло за собой необходимость наращивать как объемы разработки, так и скорость. Методология разработки изменилась, чтобы соответствовать новым требованиям, а это в свою очередь вызвало появления ряда проблем на стыке разработки и эксплуатации:


  • монолитное приложения сложно разрабатывать толпой разработчиков
  • сложно управлять зависимостями
  • сложно релизить
  • сложно разбираться с проблемами/ошибками в большом приложении

В качестве решения этих проблем мы получили сначала микросервисы, которые перенесли сложность из области кода в интеграционное поле.


Ежели где-то что-то убыло, то где-то что-то прибыть должно непременно. М. Ломоносов

В общем случае ни один вменяемый админ, отвечающий за доступность инфраструктуры в целом, не согласился бы на подобные изменения. Чтобы это как-то компенсировать, у нас появилось нечто под названием DevOps. Я даже не буду пытаться рассуждать о том, что же такое devops, лучше посмотрим, какие результаты мы получили в результате участия разработчиков в эксплутационных вопросах:


  • docker — удобный способ упаковки софта для разворачивания в различных окружениях (да, я реально считаю, что докер это всего лишь пакет:)
  • infrastructure as a code — у нас сильно усложнилась инфраструктура и теперь мы просто обязаны где-то зафиксировать способ восстанавливать ее с нуля (раньше это было опционально)
  • оркестрация — раньше мы могли себе позволить наливать виртуалки/железные серверы руками под каждое приложение, сейчас их стало много и нам хочется иметь какое-то "облако", которому мы просто говорим "запусти сервис в трех копиях на разных железках"
  • огромное количество tooling'а для управления всем этим хозяйством

Побочным эффектом этих новых технологий и подходов стало то, что окончательно исчезли барьеры в порождении микросервисов.


Когда начинающий админ или разработчик-романтик смотрит на новую картину мира, он думает, что инфраструктура теперь является "облаком" поверх какого-то количества серверов, которое легко масштабируется добавлением серверов в случае необходимости. Мы как бы построили над нашей инфраструктурой абстракцию, и нас теперь не интересует, что происходит внутри.


Эта иллюзия разбивается вдребезги сразу после того, как у нас появляется нагрузка и мы начинаем трепетно относиться к времени ответа наших сервисов. Например:



Почему некоторые инстансы сервиса работают медленнее остальных? Сразу после этого, начинаются вопросы такого вида:


  • может там серверы слабее?
  • может кто-то ресурсы съел?
  • нужно найти, на каких серверах работают инстансы:
    dc1d9748-30b6-4acb-9a5f-d5054cfb29bd
    7a1c47cb-6388-4016-bef0-4876c240ccd6
    и посмотреть там на соседние контейнеры и потребление ресурсов

То есть, мы начали разрушать нашу абстракцию: теперь мы хотим знать топологию нашего "облака", а следующим шагом захотим ей управлять.


В итоге типичная "облачная" инфраструктура на текущий момент выглядит примерно так (плюсуй, если узнал свою:)


  • есть серверы docker-, kube- на каждом из них 20-50 контейнеров
  • базы работают на отдельных железках и как правило общие
  • resource-intensive сервисы на отдельных железках, чтобы никому не мешать
  • latency-sensitive сервисы на отдельных железках, чтобы им никто не мешал

Я решил попробовать собрать воедино (из давно известных компонентов) и немного потестировать подход, который смог бы сохранить "облако" черным ящиком для пользователя.


Начнем конечно с постановки задачи:


  • у нас есть наши серверные ресурсы и какое-то количество сервисов (приложений), между которыми их нужно разделить
  • нагрузка на соседние сервисы не должна влиять на целевой сервис
  • возможность доутилизировать простаивающие ресурсы
  • хотим понимать, сколько ресурсов осталось и когда пора добавлять мощности

Я попробовал подсмотреть решение или подход у существующих оркестраторов, точнее их облачные комерческие инсталяции Google Сontainer Engine, Amazon EC2 Container Service. Как оказалось, это просто отдельная инсталяция kubernetes поверх арендованных вами виртуалок. То есть, они не пытаются решать задачу распределения ресурсов виртуалок на ваши сервисы.


Потом я вспомнил про свой давний опыт работы с Google App Engine (самое true облако на мой взгляд). В случае с GAE ты действительно ничего не знаешь про низлежащую инфраструктуру, а просто заливаешь туда код, он работает и автоматически масштабируется. Платим мы за каждый час работы каждого инстанса выбранного класса (CPU Mhz + Memory), облако само регулирует количество таких инстансов в зависимости от текущей нагрузки на приложение. Отдельно отмечу, что частота процессора в данном случае, показывает лишь то, какую часть процессорного времени выделят вашему инстансу. То есть, если у нас есть проц 2.4Ghz, и мы выделяем 600Mhz, значит мы отдаем 1/4 времени одного ядра.


Этот подход мы и возьмем за основу. С технической стороны в этом нет ничего сложного, в linux с 2008 года есть cgroups (на хабре есть подробное описание). Сосредоточимся на открытых вопросах:


  • как выбрать ограничения? Если спросить у любого разработчика, сколько памяти нужно его сервису, с вероятностью 99% он ответит: "ну дай 4Gb, наверное влезет". Тот же вопрос про CPU точно останется без ответа:)
  • насколько лимиты ресурсов вообще работают на практике?

Cgroups:CPU


  • shares: пропорции выделения процессорного времени
  • quota: жесткое ограничение количества процессорного времени в единицу реального времени
  • cpusets: привязка процессов к конкретным cpu (+NUMA)

Для теста я написал http сервис, который половину времени запроса молотит cpu и половину времени просто спит. Будем запускать его на сервере 8 ядер/32Gb (hyper-threading выключен для простоты). Подадим на него нагрузку через yandex.tank с соседней машины (по быстрой сети) сначала только на него, а через какое-то время на соседний сервис. Время ответа будем отслеживать по гистограмме с бакетами от 20ms до 100ms с шагом 10ms.


Отправная точка:


docker run -d --name service1 --net host -e HTTP_PORT=8080 httpservice
docker run -d --name service2 --net host -e HTTP_PORT=8081 httpservice

Гистограмма



Потребление cpu в разрезе контейнеров:



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


perf stat -p <pid> sleep 10 

Медленно (без нагрузки на соседа):



Быстро (с нагрузкой на соседа):



На картинках видно, что мы тратим одинаковое количество процессорных циклов за 10 секунд в обоих случаях, но скорость их "траты" разная (1.2Ghz vs 2.5Ghz). Конечно же это оказался "лучший друг производительности" — режим энергосбережения.


Чиним:


for i in `seq 0 7`
do 
     echo “performance” > /sys/devices/system/cpu/cpu$i/cpufreq/scaling_governor 
done

Запускаем снова тот же тест:




Теперь, мы видим как сервис2 начинает ожидаемо мешать сервису1. На самоме деле, когда никакие ограничения/приоритеты не заданы, мы имеем распределение долей процессорного времени поровну (cpu shares 1024:1024). При этом пока нет конкуренции за ресурсы, процесс может утилизировать все имеющиеся ресурсы. Если мы хотим предсказуемого времени ответа, нам нужно ориентироваться на худший случай.


Попробуем зажать сервис1 квотами, но сначала быстро разберемся, как настраиваются квоты на cpu:


  • period – реальное время
  • quota – сколько процессорного времени можно потратить за period
  • если хотим отрезать 2 ядра: quota = 2 * period
  • если процесс потратил quota, процессорное время ему не выделяется (throttling), пока не кончится текущий period

Выделим сервису1 два ядра (2ms cpu за 1ms) и подадим возрастающую нагрузку:


docker run -d --name service1 --cpu-period=1000 --cpu-quota=2000 …

Гистограмма:



Фактическое потребление cpu:



Throttled time:



В результате этого теста мы нашли предел производительности сервиса без деградации времени ответа при текущей квоте.


  • мы знаем, сколько в пределе можно подать запросов с балансировщика на такой инстанс
  • можем посчитать в % утилизацию cpu сервисом
    Факт: /sys/fs/cgroup/cpu/docker/id/cpuacct.usage
    Лимит: period/quota
    Триггер: [service1] cpu usage > 90% (как на каждой машине кластера, так и по кластеру в целом)

Распределяем ресурсы:


  • делим машину на ”слоты” без overselling для latency-sensitive сервисов (quota)
  • если готовимся к повышению нагрузки – запускаем каждого сервиса столько, чтобы потребление было < N %
  • если есть умный оркестратор и желание – делаем это динамически
  • количество свободных слотов – наш запас, держим его на комфортном уровне


Чтобы "добить" машину фоновой нагрузкой попробуем поставить максимальный cpu-shares нашим слотам с квотами, а "фоновым" задачам поставим минимальный приоритет.


docker run --name service1 --cpu-shares=262144 --cpu-period=1000 --cpu-quota=2000 ...

docker run --name=stress1 --rm -it --cpu-shares=2 
    progrium/stress --cpu 12 --timeout 300s



После этого теста я на 2-3 дня залип на упражнения с различными настройками планировщика (CFS) и изучением его внутреннего устройства. Выводы без подробностей такие:


  • время выделяется слотами (slices)
  • можно крутить ручки sysctl –a |grep kernel.sched_ уменьшая погрешность планирования, но для моего теста значимого эффекта я не получил
  • я поставил квоту 2ms/1ms, это оказался достаточно маленький слот
  • в результате я решил попробовать квоту 20ms/10ms (те же 2 ядра)
  • 200ms/100ms на 8 ядрах можно "спалить" за 200/8 = wall 50ms, то есть throttling в пределе будет 50ms, это ощутимо на фоне времени ответа моего тестового сервиса

Пробуем 20ms/10ms:


docker run --name service1 --cpu-shares=262144 --cpu-period=10000 --cpu-quota=20000 ...

docker run --name=stress1 --rm -it --cpu-shares=2 
    progrium/stress --cpu 12 --timeout 300s



Такие показатели я посчитал приемлемыми и решил закончить с CPU:


  • мы догрузили машину до 100% cpu usage, но время ответа сервиса осталось на приемлемом уровне
    • нужно тестировать и подбирать параметры
    • “слоты” + фоновая нагрузка – рабочая модель распределения ресурсов

Cgroups:memory


История с памятью более очевидная, но я хотел бы все равно мельком остановиться на паре примеров. Зачем нам вообще может понадобиться ограничивать память сервисам:


  • cервис с утечкой может съесть всю память, а OOM killer может прибить не его, а соседа
  • cервис с утечкой или активно читающий с диска может "вымыть" page cache, который очень нужен соседнему сервису
    Более того, при использовании cgroups мы получаем расширенную статистику потребления памяти различными группами процессов. Например, можно понять, какой из сервисов сколько page cache использует.

Я решил протестировать следующий сценарий: наш сервис активно работает с диском (читает кусок из файла 20Gb со случайного offset на каждый запрос), объем данных целиком влезает в память (предварительно прогреем кэш), рядом запустим сервис, который прочитает соседний огромный файл (как будто кто-то логи пришел читать).


dd if=/dev/zero of=datafile count=20024 bs=1048576 # создаем файл 20GB
docker run -d --name service1 .. DATAFILE_PATH=/datadir/datafile …

Прогреваем кэш от cgroup сервиса:


cgexec -g memory:docker/<id> cat datafile > /dev/null

Проверяем, что файл в кэше:


pcstat /datadir/datafile


Проверяем, что кэш засчитался нашему сервису:




Запускаем нагрузку и пробуем "вымыть" кэш:


docker run --rm -ti --name service2 ubuntu cat datafile1 > /dev/null



Как только мы немного "вымыли" кэш сервису1, это сразу сказалось на времени ответа.


Проделаем тоже самое, но ограничим сервис2 1Gb (лимит распространяется и на RSS и на page cache):


docker run --rm -ti --memory=1G --name service2 ubuntu cat datafile1 > /dev/null



Теперь видим, что лимит работает.


Cgroups:blkio (disk i/o)


  • все по аналогии с CPU
  • есть возможность задать вес (приоритет)
  • лимиты по iops/traffic на чтение/запись
  • можно настроить для конкретных дисков

Поступим так же, как с CPU: отрежем квоту iops критичным сервисам, но поставим максимальный приоритет, фоновым задачам поставим минимальный приоритет. В отличие от CPU здесь не очень понятен предел (нет никаких 100%).


Сначала выясним предел нашего конкретного SATA диска при нашем профиле нагрузки. Cервис из предыдущего теста: 20Gb файл и случайное чтение по 1Mb на запрос, но в этот раз мы зажали наш сервис по памяти, чтобы исключить использование page cache.





Получили чуть больше 200 iops, попробуем зажать сервис на 100 iops на чтение:


docker run -d --name service1 -m 10M --device-read-iops /dev/sda:100 …



Лимит работает, нам не дали прочитать больше 100 iops. Помимо ограничения теперь у нас есть расширенная статистика по утилизации диска конкретными группами процессов. Например, можно узнать фактическое количество операций чтения/записи по каждому диску (/sys/fs/cgroup/blkio/[id]/blkio.throttle.io_serviced), причем это только те запросы, которые реально долетели до диска.


Попробуем догрузить диск фоновой задачей (пока без лимитов/приоритетов):


docker run -d --name service1 -m 10M …

docker run -d --name service2 -m 10M ubuntu cat datafile1 > /dev/null



Получили вполне ожидаемую картину, но так как сервис2 читал последовательно, в сумме мы получили больше iops.


Теперь настроим приоритеты:


docker run -d --name service1 -m 10M  --blkio-weight 1000

docker run -d --name service2 -m 10M --blkio-weight 10 ubuntu cat datafile1 > /dev/null



Я уже привык к тому, что из коробки ничего сразу не работает:) После пары дней упражнений с IO планировщиками linux (напоминаю, у меня был обычный шпиндельный SATA диск):


  • cfq у меня настроить не получилось, но там есть, что покрутить
  • лучший результат на данном тесте дал планировщик deadline с такими настройками:
    echo deadline > /sys/block/sda/queue/scheduler
    echo 1 > /sys/block/sda/queue/iosched/fifo_batch
    echo 250 > /sys/block/sda/queue/iosched/read_expire




Эти результаты я посчитал приемлемыми и дальше тему не исследовал.


Итого


  • если очень захотеть, настроить и все хорошенько протестировать, можно запустить hadoop рядом с боевой БД в prime time :)
  • до "true" облака нам всем ещё очень далеко и есть вагон нерешенных вопросов
  • нужно смотреть на правильные метрики, это очень дисциплинирует и заставляет разобраться с каждой аномалией, как на production, так и во время подобных тестов

Реклама: все интересные метрики, которые я нашел в ходе данного тестирования, мы добавили в наш агент (сейчас они в beta тестировании, скоро будут доступны всем). У нас есть 2х недельный триал, но если вы хотите посмотреть именно на метрики cgroups, напишите нам, мы расширим вам триал.




К сожалению, не доступен сервер mySQL