27 април на конференцията , в рамките на секция «DevOps», бе представен докладът «Автомасштабиране и управление на ресурсите в Kubernetes». В него се описва как с помощта на K8s да се осигури висока наличност на приложенията и да се гарантира максималната им производителност.

По традиция с удоволствие представяме (44 минути, много по-информативно от статия) и основната извадка в текстов вид. Да тръгваме!
Да разгледаме темата на доклада по ключови точки и да започнем от края.
Kubernetes
Да предположим, че имаме Docker контейнери на хост. Защо? За осигуряване на повторяемост и изолация, което от своя страна позволява лесно и качествено внедряване, CI/CD. Имаме много машини с контейнери.
Какво предлага Kubernetes в този случай?
- Преставаме да мислим за тези машини и започваме да работим с «облака», кластера от контейнери или pod'ове (групи от контейнери).
- По-голямо от това, ние не мислим дори за отделни pod'ове, а управляваме още по-оголеми групи. Такива високо ниво примитиви ни позволяват да кажем, че има шаблон за стартиране на определена работна натовареност, а ето нужния брой инстанции за нейното стартиране. Ако в последствие променим шаблона — променят се и всички инстанции.
- С помощта на представеното декларативно API вместо изпълнение на последователността от конкретни команди описваме «устройството на света» (в YAML), който се създава от Kubernetes. И отново: при промени в описанието ще се променя и реалното му представяне.
Управление на ресурсите
CPU
Представете си, че стартираме nginx, php-fpm и mysql на сървър. Тези услуги всъщност ще имат още повече работещи процеси, всеки от които изисква изчислителни ресурси:

(числата на слайда — «попугаи», абстрактна нужда за всеки процес от изчислителна мощност)
За да работим удобно с това, е логично да обединим процесите по групи (например всички процеси на nginx в една група «nginx»). Прост и очевиден начин да го направим — да поставим всяка група в контейнер:

За да продължим, е важно да припомним какво точно представлява контейнерът (в Linux). Появата им стана възможна благодарение на три ключови възможности в ядрото, реализирани преди доста време: , и . А по-нататъшното им развитие бе подпомогнато от други технологии (включително удобни «обвивки» като Docker):

В контекста на доклада ни интересува само cgroups, защото именно контролните групи — това е частта от функционалните възможности на контейнерите (Docker и т.н.), която реализира управлението на ресурсите. Процесите, обединени в групи, каквито искахме, — това са именно контролни групи.
Да се върнем към нуждите от CPU на тези процеси, а сега вече — на групите от процеси:

(повтарям, че всички числа — абстрактно изражение на нуждите от ресурси)
При това самият CPU има определен краен ресурс (в примера това е 1000), от който може да не стигне на всички (сумата на нуждите на всички групи — 150+850+460=1460). Какво ще се случи в такъв случай?
Ядрото започва да разпределя ресурсите и го прави "честно", възлагайки еднакво количество ресурси на всяка група. Но в първия случай те са повече от необходимото (333>150), следователно излишъкът (333-150=183) остава в резерв, който също равномерно се разпределя между другите два контейнера:

В крайна сметка: на първия контейнер му стигнали ресурсите, на втория — значително не стигнали, на третия — малко не стигнали. Такъв е резултатът от действията "честния" планировчик в Linux — . Неговата работа може да се регулира чрез назначаване на тежести на всеки от контейнерите. Например, така:

Нека да разгледаме случая на недостатъчност на ресурси при втория контейнер (php-fpm). Всички ресурси на контейнера се разпределят между процесите равномерно. В резултат, master-процесът работи добре, а всички worker-и забавят, получавайки по-малко от половината от необходимото:

Така работи планировчикът CFS. Тежестите, които назначаваме на контейнерите, в последствие ще наречем заявки. Защо точно така — вижте по-нататък.
Нека да погледнем на цялата ситуация от друга страна. Както е известно, всички пътища водят до Рим, а в случая с компютъра — до CPU. CPU е един, задачите са много — нужен е светофар. Най-простият начин за управление на ресурсите е "светофарният": дадоха на един процес фиксирано време за достъп до CPU, след това — на следващия и т.н.

Този подход се нарича строги квоти (hard limiting). Нека го запомним просто като лимити. Въпреки това, ако разпределим лимити на всички контейнери, възниква проблем: mysql минава по пътя и в някакъв момент нуждата му от CPU свършва, но всички останали процеси са принудени да чакат, докато CPU простаива.

Да се върнем към ядрото Linux и взаимодействието му с CPU — общата картина изглежда по следния начин:

Cgroup има две настройки — по същество това са две прости "контроли", които определят:
- теглото за контейнера (request’и) — това е shares;
- процент от общото CPU време за работа по задачите на контейнера (лимити) — това е quota.
С какво да измерваме CPU?
Има различни начини:
- Какво е папагали, никой не знае — трябва всеки път да се договаряме.
- Процентите са по-разбираеми, но относителни: 50% от сървър с 4 ядра и сървър с 20 ядра — това са съвсем различни неща.
- Можете да използвате вече споменатите тежести, които Linux познава, но те също са относителни.
- Най-адекватният вариант е да измерваме изчислителните ресурси в секунди. Т.е. в секунди процесорно време спрямо секунди реално време: предоставили сме 1 секунда процесорно време в 1 реална секунда — това е един цял CPU ядро.
За да стане още по-лесно, започнахме да измерваме направо в ядра, имайки предвид именно това време на CPU относително към реалното. Тъй като Linux разбира теглата, а не такова процесорно време/ядра, беше необходим механизъм за превод от едното в другото.
Нека разгледаме прост пример със сървър с 3 CPU ядра, където на трите pod’а ще бъдат зададени такива тегла (500, 1000 и 1500), които лесно се конвертират в съответните части от тяхното зададено ядро (0,5, 1 и 1,5).

Ако вземем втори сървър, където ядрото ще е два пъти повече (6), и разположим там същите pod’и, разпределението на ядрата може да се изчисли просто като умножим на 2 (1, 2 и 3 съответно). Но важен момент настъпва, когато на този сървър се появи четвърти pod, чие тегло да е например 3000. Той взема част от ресурсите на CPU (половината ядра), а за останалите pod’и те трябва да се пренастроят (намалят наполовина):

Kubernetes и ресурсите на CPU
В Kubernetes ресурсите на CPU обикновено се измерват в милиядри, т.е. като основно тегло се взема 0.001 ядро. (Същото в терминологията на Linux/cgroups се нарича CPU share, въпреки че, ако говорим точно, 1000 милияди = 1024 CPU shares.) K8s следи да не разполага на сървъра повече pod’и, отколкото ресурси на CPU са налични за сумата от теглата на всички pod’и.
Как става това? При добавяне на сървър в кластера Kubernetes се съобщава колко CPU ядра са налични. А при създаване на нов pod, планировчикът на Kubernetes знае колко ядра ще са необходими на този pod. Така че pod’ът ще бъде определен на сървър, където има достатъчно ядра.
Какво ще се случи, ако не посочване на request (т.е. на pod’а не е зададено количество нужни ядрa)? Нека разгледаме как Kubernetes изчислява ресурсите.
На pod’а могат да се зададат както request-и (планиращ CFS), така и лимити (помните ли светофора?):
- Ако те са зададени равни, pod’ът получава QoS-клас guaranteed. Тази сума ядра е винаги налична за него.
- Ако request е по-малък от лимита — QoS-клас burstable. Т.е. очакваме, че pod’ът например винаги ще използва 1 ядро, но това значение не е ограничение за него: понякога pod’ът може да използва и повече (когато на сървъра има свободни ресурси за това).
- Има и QoS-клас best effort — те са pod’овете, за които не е зададен request. Ресурсите им се предоставят в последна норма.
Памет
С паметта ситуацията е подобна, но малко различна — все пак природата на тези ресурси е различна. В общи линии аналогията е следната:

Нека видим как в паметта се реализират request-ите. Нека pod’овете работят на сървъра, променяйки използваната памет, докато един от тях не стане толкова голям, че паметта да свърши. В този случай се появява OOM killer и убива най-голямия процес:

Това не винаги е приемливо за нас, затова имаме възможност да регулираме кои процеси са важни за нас и не трябва да бъдат убивани. За това се използва параметър oom_score_adj.
Нека се върнем на QoS-класовете за CPU и направим аналогия с стойностите oom_score_adj, които определят приоритетите на pod’овете по отношение на консумацията на памет:
- Най-ниската стойност на oom_score_adj за pod’а — -998 — означава, че такъв pod трябва да бъде убит в последния момент, това guaranteed.
- Най-високата стойност — 1000 — е best effort, такива pod’ове се убиват първи.
- За изчисляване на останалите стойности (burstable) има формула, чиято същност е, че колкото повече ресурси е поискал pod, толкова по-малко шансове има да бъде убит.

Втората „крутилка“ — limit_in_bytes — за лимитите. С нея е по-просто: просто задаваме максимално количество предавана памет и тук (в отличие от CPU) няма въпрос в какво да я измерваме.
Итого
На всеки pod в Kubernetes се задават requests и limits — и двата параметъра за CPU и памет:
- планиращият Kubernetes работи на базата на requests, който разпределя pod’овете по сървъри;
- на базата на всички параметри се определя QoS-класът на pod’а;
- на базата на CPU requests се изчисляват относителни тегла;
- на базата на CPU requests се настраива CFS-планиращият;
- на базата на memory requests се настраива OOM killer;
- въз основа на ограниченията на CPU се настраива "светофор";
- въз основа на ограниченията на паметта се настраива лимит за cgroup.

В общи линии, тази картинка отговаря на всички въпроси относно основната част на управлението на ресурсите в Kubernetes.
Автоматично мащабиране
K8s cluster-autoscaler
Да си представим, че целият клъстер вече е зает и трябва да бъде създаден нов pod. Докато pod не може да се появи, той остава в статус Pending. За да се появи все пак, можем да свържем нов сървър към клъстера или… да инсталираме cluster-autoscaler, който ще направи това вместо нас: ще поръча виртуална машина от облачен доставчик (чрез API) и ще я свърже към клъстера, след което pod ще бъде добавен.

Това е автомасштабиране на клъстерите в Kubernetes, което работи прекрасно (според нашия опит). Въпреки това, както навсякъде, и тук има нюанси…
Докато увеличавахме размерите на клъстера, всичко беше наред, но какво се случва, когато клъстерът започне да се освобождава? Проблема в том, что мигрировать pod’ы (для освобождения хостов) очень технически сложно и дорого по ресурсам. В Kubernetes работает совсем другой подход.
Нека разгледаме клъстер от 3 сървъра, в който има Deployment. Има 6 pod-а: сега по 2 на всеки сървър. По някаква причина искаме да изключим един от сървърите. За целта ще използваме командата kubectl drain, която:
- ще забрани изпращането на нови pod-ове на този сървър;
- ще изтрие съществуващите pod-ове на сървъра.
Тъй като Kubernetes следи за поддържането на броя pod-ове (6), той просто ще ги пресъздаде на другите възли, но не на изключения сървър, тъй като той вече е маркиран като недостъпен за разполагане на нови pod-ове. Това е основополагаещата механика за Kubernetes.

Но и тук има нюанс. В аналогична ситуация за StatefulSet (вместо Deployment) действията ще бъдат различни. Сега имаме stateful-приложение — например, три pod-а с MongoDB, при който един от тях е имал проблем (данните са повредени или друга грешка, която не позволява на pod-а да стартира коректно). И отново решаваме да изключим един сървър. Какво ще се случи?

MongoDB може да Той ще умре, тъй като му е необходим кворум: за клъстер от три инсталации поне две трябва да функционират. Обаче това не се случва — благодарение на PodDisruptionBudget. Този параметър определя минимално необходимо количество работещи pod-ове. Знаейки, че един от pod-овете с MongoDB вече не работи и виждайки, че за MongoDB в PodDisruptionBudget е зададено minAvailable: 2, Kubernetes няма да уреди изтриването на pod.
Резюме: за да функционира правилно преместването (всъщност — реконструирането) на pod’овете при освобождаване на клъстера, е необходимо да се конфигурира PodDisruptionBudget.
Хоризонтално мащабиране
Нека разгледаме друга ситуация. Има приложение, стартирано като Deployment в Kubernetes. Към неговите pod’ове (например, три на брой) постъпва потребителски трафик, а ние в тях измерваме някакъв показател (да кажем, натоварването на CPU). Когато натоварването се увеличи, ние го фиксираме по графика и увеличаваме броя на pod’овете за разпределение на заявките.
Днес в Kubernetes не е необходимо да се прави ръчно: конфигурира се автоматично увеличаване/намаляване на броя pod’ове в зависимост от стойностите на измерените показатели за натоварване.

Основните въпроси тук са: какво точно да измерваме и как да интерпретираме получените стойности (за вземане на решение за промяна на броя pod’ове). Може да се измерва много:

Как технически да го направим — събиране на метрики и т.н. — подробно изложих в доклада си за . Основният съвет за избор на оптимални параметри е: експериментирайте!
Има (Utilization Saturation and Errors), смисълът на който е следният. На каква база има смисъл да се мащабира, например, php-fpm? На базата на това, че worker’ите свършват, — това е utilization. А ако worker’ите свършат и нови връзки не се приемат — това вече е saturation. И двата тези параметъра трябва да се измерват, а в зависимост от стойностите да се провежда мащабиране.
Вместо заключение
Докладът има продължение: за вертикалното мащабиране и за това как правилно да подбираме ресурсите. За това ще разкажа в бъдещи видеа на — абонирайте се, за да не пропуснете!
Видео и слайдове
Видео от изявата (44 минути):

Презентация на доклада:
P.S.
Други доклади за Kubernetes в нашия блог:
- «» (Андрей Половов; 8 април 2019 на Saint HighLoad++);
- «» (Дмитрий Столяров; 8 ноември 2018 на HighLoad++);
- «» (Дмитрий Столяров; 28 май 2018 на RootConf);
- «» (Дмитрий Столяров; 7 ноември 2017 на HighLoad++);
- «» (Дмитрий Столяров; 6 юни 2017 на RootConf).
Източник: habr.com
