Еще 4 года назад использование контейнеров в production было экзотикой, но сейчас это уже норма как для маленьких компаний, так и для больших корпораций. Давайте попробуем посмотреть на всю эту историю с devops/контейнерами/микросервисами ретроспективно, взглянуть еще раз свежим взглядом на то, какие задачи мы изначально пытались решить, какие решения у нас есть сейчас и чего не хватает для полного счастья?
Я буду в большей степени рассуждать про production окружение, так как основную массу нерешенных проблем я вижу именно там.
Раньше production окружение выглядело примерно так:
В какой-то момент бизнес начал сильно смещаться в IT (к цифровому продукту, как модно сейчас говорить), это повлекло за собой необходимость наращивать как объемы разработки, так и скорость. Методология разработки изменилась, чтобы соответствовать новым требованиям, а это в свою очередь вызвало появления ряда проблем на стыке разработки и эксплуатации:
В качестве решения этих проблем мы получили сначала микросервисы, которые перенесли сложность из области кода в интеграционное поле.
Ежели где-то что-то убыло, то где-то что-то прибыть должно непременно. М. Ломоносов
В общем случае ни один вменяемый админ, отвечающий за доступность инфраструктуры в целом, не согласился бы на подобные изменения. Чтобы это как-то компенсировать, у нас появилось нечто под названием DevOps. Я даже не буду пытаться рассуждать о том, что же такое devops, лучше посмотрим, какие результаты мы получили в результате участия разработчиков в эксплутационных вопросах:
Побочным эффектом этих новых технологий и подходов стало то, что окончательно исчезли барьеры в порождении микросервисов.
Когда начинающий админ или разработчик-романтик смотрит на новую картину мира, он думает, что инфраструктура теперь является "облаком" поверх какого-то количества серверов, которое легко масштабируется добавлением серверов в случае необходимости. Мы как бы построили над нашей инфраструктурой абстракцию, и нас теперь не интересует, что происходит внутри.
Эта иллюзия разбивается вдребезги сразу после того, как у нас появляется нагрузка и мы начинаем трепетно относиться к времени ответа наших сервисов. Например:
Почему некоторые инстансы сервиса работают медленнее остальных? Сразу после этого, начинаются вопросы такого вида:
То есть, мы начали разрушать нашу абстракцию: теперь мы хотим знать топологию нашего "облака", а следующим шагом захотим ей управлять.
В итоге типичная "облачная" инфраструктура на текущий момент выглядит примерно так (плюсуй, если узнал свою:)
Я решил попробовать собрать воедино (из давно известных компонентов) и немного потестировать подход, который смог бы сохранить "облако" черным ящиком для пользователя.
Начнем конечно с постановки задачи:
Я попробовал подсмотреть решение или подход у существующих оркестраторов, точнее их облачные комерческие инсталяции Google Сontainer Engine, Amazon EC2 Container Service. Как оказалось, это просто отдельная инсталяция kubernetes поверх арендованных вами виртуалок. То есть, они не пытаются решать задачу распределения ресурсов виртуалок на ваши сервисы.
Потом я вспомнил про свой давний опыт работы с Google App Engine (самое true облако на мой взгляд). В случае с GAE ты действительно ничего не знаешь про низлежащую инфраструктуру, а просто заливаешь туда код, он работает и автоматически масштабируется. Платим мы за каждый час работы каждого инстанса выбранного класса (CPU Mhz + Memory), облако само регулирует количество таких инстансов в зависимости от текущей нагрузки на приложение. Отдельно отмечу, что частота процессора в данном случае, показывает лишь то, какую часть процессорного времени выделят вашему инстансу. То есть, если у нас есть проц 2.4Ghz, и мы выделяем 600Mhz, значит мы отдаем 1/4 времени одного ядра.
Этот подход мы и возьмем за основу. С технической стороны в этом нет ничего сложного, в linux с 2008 года есть cgroups (на хабре есть подробное описание). Сосредоточимся на открытых вопросах:
Для теста я написал 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:
Выделим сервису1 два ядра (2ms cpu за 1ms) и подадим возрастающую нагрузку:
docker run -d --name service1 --cpu-period=1000 --cpu-quota=2000 …
Гистограмма:
Фактическое потребление cpu:
Throttled time:
В результате этого теста мы нашли предел производительности сервиса без деградации времени ответа при текущей квоте.
Распределяем ресурсы:
Чтобы "добить" машину фоновой нагрузкой попробуем поставить максимальный 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) и изучением его внутреннего устройства. Выводы без подробностей такие:
Пробуем 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:
История с памятью более очевидная, но я хотел бы все равно мельком остановиться на паре примеров. Зачем нам вообще может понадобиться ограничивать память сервисам:
Я решил протестировать следующий сценарий: наш сервис активно работает с диском (читает кусок из файла 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
Теперь видим, что лимит работает.
Поступим так же, как с 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 диск):
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
Эти результаты я посчитал приемлемыми и дальше тему не исследовал.
Реклама: все интересные метрики, которые я нашел в ходе данного тестирования, мы добавили в наш агент (сейчас они в beta тестировании, скоро будут доступны всем). У нас есть 2х недельный триал, но если вы хотите посмотреть именно на метрики cgroups, напишите нам, мы расширим вам триал.
К сожалению, не доступен сервер mySQL