Оскільки GitLab нещодавно змінив політику надання Free-доступу, і тепер по Free підписці буде доступно лише 5 користувачів, то вирішили ми переїжджати на self-hosted версію.
Взагалі з ліцензією у них цікаво: ціна залежить від кількості користувачів, купити можна щонайменше на рік, і після покупки зменшити кількість користувачів у ліцензії не можна (але можна збільшити).
GitLab буде жити в Kubernetes, і питань перед запуском багато, тим більше особисто я раніше GitLab взагалі не дуже користувався.
Деплоїти GitLab будемо через ArgoCD, запускати будемо в AWS Elastic Kubernetes Service, для object store використовуємо AWS S3. Але про це все потім, а для початку подивимося що GitLab є “зсередини” і як його взагалі деплоїти.
GitLab Workhorse: реверс-проксі для завантаження та вивантаження файлів, Git push/pull і т.д.
Puma: веб-сервер на Ruby, використовується GitLab для API та своїх веб-сторінок
Sidekiq: для створення та управління чергами завдань
використовує Redis для зберігання інформації про джоби
PostgreSQL: зберігання інформації про користувачів, права доступу, метаданих і т.д.
GitLab Shell: працює з репозиторіями по SSH та керує ключами доступів
звертається до Gitaly для обробки Git-об’єктів
надсилає інформацію в Redis для створення завдань у Sidekiq
Gitaly: Git RPC server, займається завданнями в репозиторіях, які отримує від GitLab Shell і GitLab веб-додатки
Інфраструктура
Для роботи GitLab потрібні PostgreSQL, Redis, бакети AWS S3 та поштовий сервіс для отримання та надсилання листів.
Helm-чарт за замовчуванням встановлює PostgreSQL та Redis, але для production PostgreSQL рекомендується встановлювати окремо, див. Configure the GitLab chart with an external database та Configure PostgreSQL, хоча ми будемо використовувати PostgreSQL Operator і крутити кластер PostgreSQL у Kubernetes.
Аналогічні вимоги Redis та Gitaly – їх теж бажано запускати не з чарту та не в Kubernetes-кластері. Див. Installing GitLab by using Helm. У нас замість Redis швидше за все буде KeyDB, теж через оператор, також у Kubernetes.
Документація з розгортання Gitaly говорить, що до 500 користувачів достатньо однієї окремої віртуальної машини з самим Gitaly. Якщо планується 1000 і більше користувачів, то рекомендується запускати Gitaly Cluster з Praefect. Див. Gitaly > High Availability. Враховуючи кількість користувачів у нас – не бачу сенсу виносити на окремий EC2, тому будемо деплоїти разом із чартом, потім подивимося на його роботу та ресурси, можливо винесемо на окрему ноду Kubernetes.
Є гарний приклад інфраструктури в GitLab on AWS Partner Solution Deployment Guide, але це для прям серйозних масштабів, ми будемо робити простіше. Проте, сама схема цікава і корисна для розуміння загальної концепції при плануванні інфраструктури:
Крім того, є Reference architectures, в яких описані варіанти запуску GitLab під різні навантаження. З них нам можуть бути особливо цікаві Cloud native hybrid, які описують запуск у Kubernetes (hybrid – тому що частину сервісів таки рекомендується запускати не в кластері):
Для LoadBalancer рекомендується least outstanding requests замість стандартного round-robin – треба не забути.
Корисно пройтися по всіх доступних опціях, подивитися що і як можна налаштувати, див. GitLab Helm chart deployment options, займемося цим в наступному пості.
Моніторинг GitLab – окрема тема, дійдемо до неї пізніше, поки див. Monitoring GitLab.
Запуск GitLab в Minikube
Взагалі використовується для розробників, які пиляють фічі під Kubernetes, але ми використовуємо для знайомства з чартом GitLab. Див. Developing for Kubernetes with minikube.
Запускаємо Minikube:
[simterm]
$ minikube start --cpus 4 --memory 10240
[/simterm]
Включаємо Ingress плагін:
[simterm]
$ minikube addons enable ingress
[/simterm]
Клонуємо репозиторій та встановлюємо залежності – корисно на них подивитися, хоча вони описані в документації до чарту:
[simterm]
$ git clone https://gitlab.com/gitlab-org/charts/gitlab.git
$ cd gitlab
$ helm dependency update
...
Dependency gitlab did not declare a repository. Assuming it exists in the charts directory
Dependency certmanager-issuer did not declare a repository. Assuming it exists in the charts directory
Dependency minio did not declare a repository. Assuming it exists in the charts directory
Dependency registry did not declare a repository. Assuming it exists in the charts directory
Downloading cert-manager from repo https://charts.jetstack.io/
Downloading prometheus from repo https://prometheus-community.github.io/helm-charts
Downloading postgresql from repo https://raw.githubusercontent.com/bitnami/charts/eb5f9a9513d987b519f0ecd732e7031241c50328/bitnami
Downloading gitlab-runner from repo https://charts.gitlab.io/
Downloading grafana from repo https://grafana.github.io/helm-charts
Downloading redis from repo https://raw.githubusercontent.com/bitnami/charts/eb5f9a9513d987b519f0ecd732e7031241c50328/bitnami
Dependency nginx-ingress did not declare a repository. Assuming it exists in the charts directory
...
kas – Kubernetes agent server для Gitlab Agent, “GitLab Agent for Kubernetes is an active in-cluster component for solving any GitLab<->Kubernetes integration tasks” – подивимось на нього уважніше іншим разом
migrations – джоби для роботи з міграціями бази даних
minio – High Performance Object Storage, API compatible with Amazon S3 – використовується, коли немає S3
postgresql – база даних, читали
prometheus-server – моніторинг GitLab
redis-master – Редіс)
registry – Container Registry для зберігання образів
Дуже правильне діло – моніторити, наскільки ефективно використовується кластер, особливо, якщо ресурси деплояться розробниками, які не сильно вникають у requests, і встановлюють завищені значення “про запас”. Запас, звичайно, потрібен, але й просто так реквестити ресурси ідеї погана.
Наприклад, у вас є WorkerNode з 4 vCPU (4000 milicpu) та 16 ГБ оперативної пам’яті, і ви створюєте Kubernetes Deployment, у якому для подів задаєте CPU requests 2500m і 4 Гб пам’яті. Після запуску одного пода – він зареквестить більше половини доступного процесорного часу, і під час запуску другого поду Kubernetes повідомить про нестачу ресурсів на доступних нодах, що призведе до запуску ще одного WorkerNode, який, зрозуміло, відразиться на загальній вартості кластера.
Kubernetes Resource Report – найпростіший у запуску та можливостях: просто виводить ресурси групуючи їх за типом, і відображає статистику – скільки CPU/MEM requested і скільки реально використовується.
Мені вона подобається саме простотою – просто запускаємо, раз в пару тижнів дивимося що відбувається в кластері, і за необхідності пінгуємо розробників із питанням “А вам насправді потрібно 100500 гіг пам’яті для цієї апки? »
Є Helm-чарт, але він оновлюється рідко, тому простіше встановити з маніфестів.
Створюємо Namespace:
[simterm]
$ kk create ns kube-resource-report
namespace/kube-resource-report created
$ kubectl apply -k deploy/
serviceaccount/kube-resource-report created
clusterrole.rbac.authorization.k8s.io/kube-resource-report created
clusterrolebinding.rbac.authorization.k8s.io/kube-resource-report created
configmap/kube-resource-report created
service/kube-resource-report created
deployment.apps/kube-resource-report created
Далі переходимо, наприклад, до Namespaces, сортуємо колонки за CR (CPU Requested):
При наведенні курсора на повзунок Kube Resource Report підкаже оптимальне з його точки зору значення.
Далі думаємо самі – чи дійсно потрібно стільки requests, чи його можна зменшити.
У цьому випадку у нас Apache Druid з 16 подами, в кожній працює JVM, яка і процесор і пам’ять любить, і для Druid процесор бажано виділяти по одному ядру на кожен thread виконання Java, тому ОК – нехай буде 14,65 процесора.
Kubecost
Kubecost – це Kube Resource Report на стероїдах. Вміє рахувати трафік, відправляти алерти, генерує метрики для Prometheus, має свої дашборди Grafana, може підключатися до кількох кластерів Kubernetes та багато іншого.
Місцями не без багів, але в цілому штука приємна.
Правда, вартість бізнес-ліцензії в 499 доларів трохи завищена, як на мене. Втім для базових речей цілком доступна Free версія.
“Під капотом” використовує свій Prometheus для зберігання даних. Можна відключити і використовувати зовнішній, але не рекомендується .
Installation
Основні доступні параметри описані в документації на Github , також можна переглянути дефолтні values його Helm-чарту.
Потребує реєстрації для отримання ключа – заходимо на https://www.kubecost.com/install.html , вказуємо пошту – і відразу переадресує на інструкцію з вашим ключем:
Helm chart values
Поспішати з установкою не будемо – спочатку створимо свої values.
Якщо ви вже маєте Kube Prometheus Stack, є Grafana і NodeExporter – то в Kubecost їх має сенс відключити. Крім того, відключимо kube-state-metrics, що б не дублювали дані в моніторингу.
Що б Prometheus із нашого власного стека почав збирати метрики Kubecost – налаштуємо створення ServiceMonitor і додамо йому labels – тоді можна буде генерувати свої алерти та використовувати Grafana dashboard.
А ось якщо відключити запуск вбудованої Grafana – под с kubecost-cost-analyzer не стартує. Не знаю – бага чи фіча. Але в ній є свої дашборды які можуть бути корисними, тож можна залишити.
Ще можна включити networkCosts, але мені так і не вдалося побачити адекватних витрат на трафік – можливо, неправильно налаштував.
networkCosts може бути достатньо прожорливим по ресурсам – треба моніторити використання ЦПУ.
Тут скрин з Kubecost, який вже майже тиждень запущено на одному з наших кластерів:
Коротко оглянемо основні пункти меню.
Assets
Для розуміння витрат краще почати з пункту Assets , де виводиться вартість “заліза”:
Бачимо, що в день наш кластер коштує 43 долари.
Можна “провалитися” глибше в деталі кластера, і побачити розбивку по ресурсам – WorkerNodes, лоад-балансерам, дискам та вартість самого AWS Elastic Kubernetes Service:
Переходимо ще далі, в Nodes:
І дивимось деталі вартості по конкретній ноді:
Перевіряємо:
0.167 за годину, як Kubecost і репортить в Hourly Rate.
Тут наш Apache Druid має реквестів CPU на цілих 48 долларів за тиждень або 4.08 в день.
Переходимо далі, і маємо картину по конкретним контролерам – StatefulSet, Deployment:
Колонки тут:
CPU, RAM: вартість використовуваних ресурсів в залежності від вартості ресурсів WorkerNode
PV: вартість PersistentVolume у вибраному контролері, тобто для StaefulSet MiddleManager маємо PV, котрий являє собою AWS EBS, за який ми платимо гроші
Network: треба перевіряти, бо якось дивно рахує – дуже мало
LB: LoadBalancers по вартості в AWS
Shared: загальні ресурси, які не будут рахуватися окремо, наприклад неймспейс kube-system, налаштовується в http://localhost:9090/settings > Shared Cost
Efficiency: утилізація vs реквесты за формулою: ((CPU Usage / CPU Requested) * CPU Cost) + (RAM Usage / RAM Requested) * RAM Cost) / (RAM Cost + CPU Cost)) основний показник ефективності ресурсу, див. Efficiency and Idle
Якщо перейти ще глибше – буде посилання на Grafana, де можна переглянути використання ресурсів конкретним подом:
Правда, “з коробки” не відображаються метрики в RAM Requested.
Для перевірки метрик можна зайти на локальний Pometheus:
І так – kube_pod_container_resource_requests_memory_bytes пуста:
Тому що метрика теперь називаєтся kube_pod_container_resource_requests з resource="memory", треба оновити запрос в цій Графані:
avg(kube_pod_container_resource_requests{namespace=~"$namespace", pod="$pod", container!="POD", resource="memory"}) by (container)
__idle__
Витрати __idle__ – різниця між вартістю ресурсів виділених під існуючі об’єкти (поди, деплойменти) – їх реквести і реальний usage, та “заліза, що простоює”, на якому вони працюють, тобто не зайняті ЦПУ/пам’ять, які можна використовувати під запуск нових ресурсів.
Savings
Тут зібрані поради щодо оптимізації витрат:
Наприклад, у “Right-size your container requests” зібрані рекомендації щодо налаштувань реквестів для ресурсів – аналог репортів у Kubernetes Resource Report:
Глянемо той же Apache Druid:
Тут явний овер-реквест по CPU, і Kubecost рекомендує зменшити ці реквести:
Але про Druid вже писалося вище – JVM, на кожен под MiddleManager ми запускаємо один Supervisor із двома Tasks, а під кожну Task бажано виділяти по повному ядру. Тож залишаємо, як є.
Корисна штука “Delete unassigned resources” – у нас, наприклад, знайшлася пачка EBS, що не використовуються:
Health
Теж корисна штука, що відображає основні проблеми із кластером:
kubecost-network-costs активно використовує CPU, вище своїх реквестів, і Kubernetes його тротлить.
Alerts
Тут можемо налаштувати алерти, але мені вдалося налаштувати відправку тільки через Slack Webhook:
Prometehus Alertmanager можна налаштувати через values , але використовується свій локальний, який запускається разом з Prometheus, а як йому налаштувати роути – не знайшов.
Приклад алерту, який можна налаштувати в Kubecost:
Тут тестові алерти, які можна буде в принципі тягнути у продакшен.
ServiceMonitor для отримання метрик у зовнішньому Prometheus відключив, бо сенсу поки що не бачу – алертити буде через Slack Webhook своїми алертами, а дашборда для Grafana у вбудованій Графані краща, і їх там кілька.
Додав direct-classification для networkCosts – подивимося, можливо покаже більш правильні дані щодо трафіку.
#TODO
З чим поки що не вдалося розібратися:
алерти через Alertmanager (можливо, має сенс таки спробувати відключити внутрішній Prometheus)
Kubecost не бачить Node Exporter (перевіряти на сторінці http://localhost:9090/diagnostics ), але це наче ні на що не впливає – основні метрики отримує від cAdvisor
витрати на нетворкінг занадто маленькі (але це не точно)
Kustomize – система управління конфігураціями (configuration management tool) для Kubernetes, що дозволяє використовувати загальні набори маніфестів, які можуть бути змінені для кожного конкретного оточення/кластера, і може бути альтернативою шаблонам Helm (або доповнювати його).
Загальна концепція Kustomize – “where, what, and how” – “де, що і як”:
“де” – це наш базовий маніфест, наприкладdeployment.yaml
“що” – що саме в маніфесті мінятимемо, наприклад кількість подів (replicas) у цьому деплойменті
Для простого прикладу візьмемо файл kustomization.yamlз таким змістом:
resources:
- deployment.yaml
- service.yaml
namePrefix: dev-
namespace: development
commonLabels:
environment: development
Тут описується, що потрібно взяти ресурси описані у файлах deployment.yaml таservice.yaml, до імені кожного створюваного ресурсу додати префікс dev- ( namePrefix), деплоїти в namespace development , і додати labels environment: development.
Крім того, Kustomize зручний для створення конфігурацій із загальних файлів, але для різних оточень.
У такому випадку використовується каталог overlaysзі своїм набором kustomization.yaml:
Починаючи з версії 1.14, Kustomize вбудований у kubectl:
[simterm]
$ kubectl kustomize --help
Build a set of KRM resources using a 'kustomization.yaml' file. The DIR argument must be a path to a directory
containing 'kustomization.yaml', or a git repository URL with a path suffix specifying same with respect to the
repository root. If DIR is omitted, '.' is assumed.
Examples:
# Build the current working directory
kubectl kustomize
...
[/simterm]
І може використовуватися при apply, щоб спочатку зібрати (build) необхідний маніфест, і відразу відправити його в Kubernetes:
[simterm]
$ kubectl apply --help
...
# Apply resources from a directory containing kustomization.yaml - e.g. dir/kustomization.yaml
kubectl apply -k dir/
...
[/simterm]
А з версії 1.16 доступний і в kubeadm.
Крім kuberctl apply, Kustomize можна використовувати для:
kubectl get -k – отримати ресурс із Kubernetes кластера
kubectl describe -k – опис ресурсу в Kubernetes кластері
kubectl diff -k – порівняти локально згенерований маніфест із ресурсом у кластері
kubectl delete -k – видалити ресурс із кластера
Деплой з Kustomize
Створюємо тестову директорію:
[simterm]
$ mkdir -p kustomize_example/base
$ cd kustomize_example/
[/simterm]
У каталозі baseстворимо два файли – в одному опишемо Deployment, в іншому Service:
[simterm]
$ vim -p base/deployment.yaml base/service.yaml
[/simterm]
У файлі deployment.yaml робимо запуск поду з контейнером nginxdemo :
$ kubectl apply -k base/
service/nginxdemo created
deployment.apps/nginxdemo created
[/simterm]
Перевіряємо:
[simterm]
$ kubectl get all -l app=nginxdemo
NAME READY STATUS RESTARTS AGE
pod/nginxdemo-7f8f587c74-kbczf 1/1 Running 0 26s
NAME DESIRED CURRENT READY AGE
replicaset.apps/nginxdemo-7f8f587c74 1 1 1 26s
[/simterm]
Тепер подивимося, як налаштувати ці Deployment та Service для двох оточень – Dev та Prod.
Kustomize Overlays
Створюємо каталоги overlays/devта overlays/prod:
[simterm]
$ mkdir -p overlays/{dev,prod}
[/simterm]
Отримуємо таку структуру:
[simterm]
$ tree .
.
|-- base
| |-- deployment.yaml
| |-- kustomization.yaml
| `-- service.yaml
`-- overlays
|-- dev
`-- prod
[/simterm]
У каталогах devта prodстворюємо окремі kustomization.yaml, в яких описуємо bases:
bases:
- ../../base
Якщо зараз виконати kustomize build overlays/dev/, то отримаємо маніфест аналогічний до того, який створювали раніше.
Можливості Kustomize
namePrefix
Що б змінити цей маніфест – у файлах kustomization.yamlдля Dev і Prod додамо, наприклад, namePrefix:
$ kubectl apply -k overlays/dev/
service/dev-nginxdemo created
deployment.apps/dev-nginxdemo created
$ kubectl apply -k overlays/prod/
service/prod-nginxdemo created
deployment.apps/prod-nginxdemo created
[/simterm]
Перевіряємо:
[simterm]
$ kubectl get all -l app=nginxdemo
NAME READY STATUS RESTARTS AGE
pod/dev-nginxdemo-7f8f587c74-vh2gn 1/1 Running 0 37s
pod/nginxdemo-7f8f587c74-kbczf 1/1 Running 0 104m
pod/prod-nginxdemo-7f8f587c74-dpc76 1/1 Running 0 33s
pod/prod-nginxdemo-7f8f587c74-f5j4f 1/1 Running 0 33s
pod/prod-nginxdemo-7f8f587c74-zqg8z 1/1 Running 0 33s
NAME DESIRED CURRENT READY AGE
replicaset.apps/dev-nginxdemo-7f8f587c74 1 1 1 37s
replicaset.apps/nginxdemo-7f8f587c74 1 1 1 104m
replicaset.apps/prod-nginxdemo-7f8f587c74 3 3 3 33s
[/simterm]
configMapGenerator та secretGenerator
Kustomize також вміє генерувати нові ресурси із шаблонів.
Тепер, щоб не міняти чарт, але створити свій власний Secret – в каталозі kustomize-helmстворюємо файл kustomization.yaml, в якому використовуємо resourcesз файлом helm-all.yaml який згенеруємо за допомогою helm template:
Основна умова циклу for – він виконуватиметься доти, поки в переданій йому команді є об’єкти для дії. Виходячи з прикладу вище – допоки в лістингу ls -1 є файли для відображення – цикл передаватиме їх у змінну і виконуватиме “тіло циклу”. Як тільки список файлів у директорії закінчиться – цикл завершить виконання.
Нам необхідно вибрати з них лише ті, які в назві не мають слова “no“:
#!/bin/bash
lsl=`ls -1`
for variable in $lsl
do
echo "$variable" | grep -v "no"
done
Запускаємо:
[simterm]
$ ./loop.sh
file1
file2
file3
file4
file5
loop.sh
[/simterm]
У циклі також можна використовувати умовні вирази (conditional expressions) для перевірки умов та оператор break для переривання циклу у разі спрацювання умови.
Розглянемо такий приклад:
#!/bin/bash
lsl=`ls -1`
for variable in $lsl
do
if [ $variable != "loop.sh" ]
then
echo "$variable" | grep -v "no"
else
break
fi
done
Цикл буде виконуватися доти, доки не буде знайдено файл loop.sh. Як тільки виконання циклу дійде до цього файлу – цикл буде перерваний командою break:
[simterm]
$ ./loop.sh
file1
file2
file3
file4
file5
[/simterm]
Ще один приклад – використання арифметичних операцій безпосередньо перед виконанням тіла циклу:
#!/bin/bash
for (( count=1; count<11; count++ ))
do
echo "$count"
done
Тут ми задаємо три керуючих команди:
count=1 – контролююча умова
допоки count менше 11
і команду до виконання – count +1:
[simterm]
$ ./loop.sh
1
2
3
4
5
6
7
8
9
10
[/simterm]
Цикл WHILE
Простий приклад, що добре демонструє принцип роботи циклу while:
#!/bin/bash
count=0
while [ $count -lt 10 ]
do
(( count++ ))
echo $count
done
Ми задаємо змінну $count рівною нулю, після чого запускаємо цикл while з умовою “поки $count менше десяти – виконувати цикл”. У тілі циклу ми виконуємо постфіксний інкремент +1 до змінної $count і виводимо результат в stdout.
Результат виконання:
[simterm]
$ ./loop.sh
1
2
3
4
5
6
7
8
9
10
[/simterm]
Щойно значення змінної $count стало 10 – цикл прервався.
Infinite loops
Гарний приклад “нескінченного” циклу, який демонструє роботу while:
#!/bin/bash
count=10
while [ 1 = 1 ]
do
(( count++ ))
echo $count
done
Запускаємо:
[simterm]
$ ./loop.sh
...
5378
5379
5380
5381
5382
5383
^C
[/simterm]
Цикл UNTIL
Аналогічно, але “у зворотний бік” працює і цикл until:
#!/bin/bash
count=0
until [ $count -gt 10 ]
do
(( count++ ))
echo $count
done
Тут ми задаємо схожу умову, але замість “поки змінна менше 10” – вказуємо “поки змінна не стане більше ніж 10”.
Результат виконання:
[simterm]
$ ./loop.sh
1
2
3
4
5
6
7
8
9
10
11
[/simterm]
Якщо ж наведений вище приклад “нескінченного циклу” виконати з використанням until – він на відміну від while не виведе нічого:
#!/bin/bash
count=10
until [ 1 = 1 ]
do
(( count++ ))
echo $count
done
Запускаємо:
[simterm]
$ ./loop.sh
$
[/simterm]
Оскільки “умова” початково “true” – тіло циклу виконуватися не буде.
Як і в циклі for – в циклах while та until можна використовувати функції.
Для прикладу – цикл із скрипту, що реально використовується та виконує перевірку статусу сервера Tomcat (PIDбереться в системі SLES , в інших системах може відрізнятися), трохи спрощений варіант:
#!/bin/bash
check_tomcat_status () {
RUN=`ps aux | grep tomcat | grep -v grep | grep java | awk '{print $2}'`
}
while check_tomcat_status
do
if [ -n "$RUN" ]
then
printf "WARNING: Tomcat still running with PID $RUN."
else
printf "Tomcat stopped, proceeding...nn"
break
fi
done
Результат виконання:
[simterm]
$ ./loop.sh
WARNING: Tomcat still running with PID 14435
26548.WARNING: Tomcat still running with PID 14435
26548.WARNING: Tomcat still running with PID 14435
26548.WARNING: Tomcat still running with PID 14435
26548.WARNING: Tomcat still running with PID 14435
26548.WARNING: Tomcat still running with PID 14435
26548.WARNING: Tomcat still running with PID 14435
26548.WARNING: Tomcat still running with PID 14435
[/simterm]
Повний варіант:
#!/bin/bash
check_tomcat_status () {
RUN=`ps aux | grep tomcat | grep -v grep | grep java | awk '{print $2}'`
}
while check_tomcat_status; do
if [ -n "$RUN" ]
then
printf "WARNING: Tomcat still running with PID $RUN. Stop it? "
answer "Stopping Tomcat..." "Proceeding installation..." && $CATALINA_HOME/bin/shutdown.sh 2&>1 /dev/null || break
sleep 2
if [ -n "$RUN" ]
then
printf "Tomcat still running. Kill it? "
answer "Killing Tomcat..." "Proceeding installation...n" && kill $RUN || break
sleep 2
fi
else
printf "Tomcat stopped, proceeding...nn"
break
fi
done
Переклад поста 2013 року з деякими правками, але все ще актуальний для вивчення BASH.
По суті функція в bashє звичайною змінною, але з більшими можливостями.
Основне застосування – у тих випадках, коли один і той же код необхідно використовувати кілька разів та/або у різних зв’язаних скриптах.
Оголошення та виклик функції
Оголошується функція так:
function function_name ()
{
function body
}
Або:
function one {
echo "One"
}
two () {
echo "Two"
}
function three () {
echo "Three"
}
Однак найбільш правильним варіантом, з метою сумісності скрипта з різними shell буде такий:
two () {
echo "Two"
}
І намагайтеся ніколи не використовувати третій варіант:
function three () {
echo "Three"
}
Викликати функцію можна просто вказавши її ім’я у тілі скрипта:
#!/bin/bash
function one {
echo "One"
}
one
[simterm]
$ ./example.sh
One
[/simterm]
Важливо, щоб оголошення функції було виконано до того, як вона буде викликана, інакше буде отримана помилка:
#!/bin/bash
function one {
echo "One"
}
one
two
function two {
echo "Two"
}
[simterm]
$ ./example.sh
One
./example.sh: line 7: two: command not found
[/simterm]
Виклик функції з аргументами
Перейдемо до більш складних функцій та розглянемо виклик функції з аргументами.
Наприклад, візьмемо функцію, яка викликається у тому місці коду, де потрібно отримати відповідь користувача:
#!/bin/bash
answer () {
while read response; do
echo
case $response in
[yY][eE][sS]|[yY])
printf "$1"
$2
break
;;
[nN][oO]|[nN])
printf "$3"
$4
break
;;
*)
printf "Please, enter Y(yes) or N(no)! "
esac
done
}
echo "Run application? (Yes/No) "
answer "Run" "" "Not run" ""
У цьому випадку функція answer()очікує відповіді від користувача в стилі Yesабо No(або будь-яка варіація, задана у виразі [yY][eE][sS]|[yY]або [nN][oO]|[nN]), і в залежності від відповіді виконує певну дію.
У разі відповіді Yes буде виконано дію, задану в першому аргументі $1, з яким було викликано функцію.
Перевіримо:
[simterm]
$ bash test.sh
Run application? (Yes/No)
y
Run
[/simterm]
З відповіддю No:
[simterm]
$ ./example.sh
Run application? (Yes/No)
no
Not run
[/simterm]
Виклик команд безпосередньо з аргументів, а тим більше зі змінних, вважається не найкращим рішенням, тому перепишемо її і викличемо з операторами && (у разі успішного виконання, тобто при отриманні коду 0) і || – у разі помилки та отримання коду відповіді 1:
#!/bin/bash
answer () {
while read response; do
echo
case $response in
[yY][eE][sS]|[yY])
printf "$1\n"
return 0
break
;;
[nN][oO]|[nN])
printf "$2\n"
return 1
break
;;
*)
printf "Please, enter Y(yes) or N(no)! "
esac
done
}
echo -e "\nRun application? (Yes/No) "
answer "Run" "Will not run" && echo "I'm script" || echo "Doing nothing"
Тепер ми першим аргументом передаємо функції відповідь “Run“, і у разі відповіді користувача Yes– виконуємо printf "Run"та echo "I'm script". Якщо вибрано відповідь No– ми друкуємо другий аргумент Will not run і виконуємо дію echo "Doing nothing":
[simterm]
$ bash test.sh
Run application? (Yes/No)
y
Run
I'm script
$ bash test.sh
Run application? (Yes/No)
no
Will not run
Doing nothing
[/simterm]
Відповідно, замість echo можна виконати будь-яку іншу команду:
#!/bin/bash
answer () {
while read response; do
echo
case $response in
[yY][eE][sS]|[yY])
printf "$1\n"
return 0
break
;;
[nN][oO]|[nN])
printf "$2\n"
return 1
break
;;
*)
printf "Please, enter Y(yes) or N(no)! "
esac
done
}
echo -e "\nKill TOP application? (Yes/No) "
answer "Killing TOP" "Left it alive" && pkill top || echo "Doing nothing"
[simterm]
$ ./example.sh
Kill TOP application? (Yes/No)
y
Killing TOP
[/simterm]
Важливо враховувати, що якщо перша команда завершиться невдало (у даному прикладі – pkillне знайде зазначений процес) – то функція поверне код 1, і буде виконана друга частина:
[simterm]
$ ./example.sh
Kill TOP application? (Yes/No)
y
Killing TOP
Doing nothing
[/simterm]
Змінні у функціях
В аргументах також можна використовувати змінні.
Наприклад, можна визначити кілька варіантів відповідей у різних змінних, і використовувати потрібну у різних випадках:
#!/bin/bash
answer () {
while read response; do
echo
case $response in
[yY][eE][sS]|[yY])
printf "$1\n"
return 0
break
;;
[nN][oO]|[nN])
printf "$2\n"
return 1
break
;;
*)
printf "Please, enter Y(yes) or N(no)! "
esac
done
}
replay1="Killing TOP"
replay2="Left it alive"
echo -e "\nKill TOP application? (Yes/No) "
answer "$replay1" "$replay2" && echo "I'm script" || echo "Doing nothing"
[simterm]
$ ./example.sh
Kill TOP application? (Yes/No)
y
Killing TOP
I'm script
$ ./example.sh
Kill TOP application? (Yes/No)
n
Left it alive
Doing nothing
[/simterm]
Як і зі звичайними змінними, функції використовують “позиційні агрументи”, тобто:
$#– відображення кількості переданих аргументів;
$*– відображення списку всіх переданих аргументів;
$@– те саме, що і $*– але кожен аргумент вважається як просте слово (рядок);
$1 - $9– нумеровані аргументи залежно від позиції у списку
Наприклад – створимо такий скрипт із функцією, яка має вивести кількість переданих аргументів:
#!/bin/bash
example () {
echo $#
shift
}
example $*
[simterm]
$ ./example.sh 1 2 3 4
4
[/simterm]
Або просто вивести на екран усі передані їй аргументи:
#!/bin/bash
example () {
echo $*
shift
}
example $*
[simterm]
$ ./example.sh 1 2 3 4
1 2 3 4
[/simterm]
Або можна аргументи передавати прямо при виклику функції, а не при виклику скрипту як у прикладі вище:
#!/bin/bash
example () {
echo $*
shift
}
example 1 2 3 4
[simterm]
$ ./example.sh
1 2 3 4
[/simterm]
Локальні змінні
За замовчуванням, всі задані змінні в bash скриптах вважаються глобальними в рамках самого скрипту, але в функції можна оголосити змінну, яка буде доступна тільки під час її (функції) виконання.
Щоб передати функцію в наступний скрипт, що викликається в новому (дочірньому) екземплярі shell – її необхідно експортувати.
Для прикладу візьмемо два файли – у файлі 1.sh ми оголосимо функцію та виклик скрипт 2.sh:
#!/bin/bash
one () {
echo "one"
}
bash 2.sh
А у файлі 2.sh спробуємо цю функцію викликати:
#!/bin/bash
one
Перевіряємо:
[simterm]
$ ./1.sh
2.sh: line 3: one: command not found
[/simterm]
Тепер експортуємо функцію за допомогою export та ключа -f:
#!/bin/bash
one () {
echo "one"
}
export -f one
bash 2.sh
Виконуємо:
[simterm]
$ ./1.sh
one
[/simterm]
Інший варіант – викликати наступний скрипт у тому ж екземплярі шела:
#!/bin/bash
one () {
echo "one"
}
source 2.sh
Або так:
#!/bin/bash
one () {
echo "one"
}
. 2.sh
Обидва варіанти рівнозначні і дадуть один результат:
[simterm]
$ ./1.sh
one
[/simterm]
Перевірка наявності функцій
Іноді перед виконанням функції потрібно перевірити її наявність. Для цього зручно використовувати команду declare.
Викликана з ключем -f і без аргументів declare виведе зміст усіх функцій:
#!/bin/bash
one () {
echo "one"
}
two () {
echo "two"
}
declare -f
Результат:
[simterm]
$ ./test.sh
one ()
{
echo "one"
}
two ()
{
echo "two"
}
[/simterm]
З ключем -F лише назви:
#!/bin/bash
one () {
echo "one"
}
two () {
echo "two"
}
declare -F
Результат:
[simterm]
$ ./test.sh
declare -f one
declare -f two
[/simterm]
Якщо задати імена функцій як аргументи – declare просто виведе їх імена:
#!/bin/bash
one () {
echo "one"
}
two () {
echo "two"
}
declare -F one two
Перевіряємо:
[simterm]
$ ./test.sh
one
two
[/simterm]
Можна задати ключ -f та ім’я функції – тоді буде виведено лише тіло вказаної функції:
#!/bin/bash
one () {
echo "one"
}
two () {
echo "two"
}
declare -f one
Запускаємо:
[simterm]
$ ./test.sh
one () {
echo "one"
}
[/simterm]
Перевірити наявність функцій перед їх виконанням можна за допомогою додаткової функції, якій передаються імена функцій, що перевіряються:
#!/bin/bash
one () {
echo "one"
}
two () {
echo "two"
}
isDefined() {
declare -f "$@" > /dev/null && echo "Functions exist" || echo "There is no some functions!"
}
isDefined one two
Зверніть увагу на використання “ $@” – як писалося вище, саме такий параметр виводить аргумент “як є”, без будь-яких інтерпретацій bash.
Запустимо скрипт для перевірки:
[simterm]
$ ./test.sh
Functions exist
[/simterm]
А тепер – спробуємо додати одну “зайву” функцію до виклику isDefined() :
#!/bin/bash
one () {
echo "one"
}
two () {
echo "two"
}
isDefined() {
declare -f "$@" > /dev/null && echo "Functions exist" || echo "There is no some functions!"
}
isDefined one two three
Результат:
[simterm]
$ ./test.sh
There is no some functions!
[/simterm]
declare виявив відсутність функції з ім’ям three та повернув код 1, що викликало спрацювання оператора ||.
Вже давно просили написати пост про те, як я готувався до зими – ось, таки вмовили.
Хоча вже трошки запізно, бо половина зими пройдено, але – нехай буде.
Голове, що дуже спасає цією зимою це те, що ЖК, в якому живу, по-перше має газові плити, по-друге – опалення газовими котлами.
Однак, все ж були проблеми, которі довелося вирішувати.
В перші дні, коли почали вимикати світло, я мав пару павербанків на 10.000 mAh, і досі пам’ятаю це відчуття, коли в ноутбуку сіла батарея, провайдер інтернету вирубився, мобільний інтернет теж не працював, ще й телефон почав сідати і не було можливості навіть почитати книгу (хоча є ще паперова бібліотека – але чим світити ввечері?)
Повне відчуття ізоляції, як в печері.
Тож перше, що я зробив – це докупив пару павербанок на 20.000 для телефона. Але це було тільки початком.
Інтернет
Друге питання, яке потрібно було вирішити – це інтернет. Живу за містом, і як вимикається світло – то вежі мобільного вирубаються теж, лишається тільки одна. З телефону інтернет через неї не тягнуло зовсім, тож докупив собі комплект зовнішньої антени з 3/4G роутером. Див детальнішне тут – Networking: коли немає світла – модем 4G ZTE + зовнішня антена:
Антена стоїть за вікном, в квартирі мав два модеми з двома операторами – Водафон та Лайф: то один, то другий працювали більш-менш, і свої 1-2 мб/с я мав.
Обидва модеми були підключені до павербанок, тож працювали постійно: як тільки виключалось світло, і провайдер “падав” – ноутбук переключався на один з модемів.
Ще пізніше в ЖК нарешті дотягнули оптику, і тепер маю GPON з гігабітним підключенням:
Так як це GPON (Gigabit Passive Optical Network) – то ми не дуже залежимо від електрохарчування (с), і інтернет працює стабільно навіть декілька діб без світла поспіль.
Електрохарчування (с)
Заряд ноубтука
Ще до того, як я затарився купою всяких акумуляторів (зараз до них дойдемо) я максимально оптимізував роботу свого ноутбука – визначив, які саме процеси/сервіси найбільше витрачають заряд батареї, та почав іх відключати, див. Linux: збереження заряду батареї ноутбуку.
Щоб спростити процесс – накидав скрипта:
#!/usr/bin/bash
#sudo ifconfig enp2s0f0 down
sudo nmcli radio wifi off
sudo bash -c 'echo -n 60 > /sys/class/backlight/amdgpu_bl0/brightness'
#xrandr --output eDP-1 --auto --mode 1280x720 --output HDMI-1 --auto --left-of eDP-1 --output DP-1 --off
##xrandr --output eDP-1 --auto --mode 1280x720 --primary --output HDMI-1 --off --output DP-1 --off
#systemctl --user stop pulseaudio.socket
#systemctl --user stop pulseaudio.service
sudo systemctl stop bluetooth
sudo ip link set docker0 down
sudo systemctl stop docker.service
sudo systemctl stop docker.socket
killall polybar
killall ktorrent
killall slack
killall thunderbird
Далі в rc.xml (в мене Arch Linux та Openbox) додав дві комбінації для клавіатури – одна запускає скрипт setPowerDown.sh, наведений вище, друга – setPowerUp.sh, в якому робиться все теж саме, тільки навпаки – включає сервіси:
По-перше – замовив зарядну станцію. Мене душила жаба віддавати купу грошей, та ще й чекати на доставку, тож я замовив не EcoFlow, а Kseon-168 – 168.000 mAh (14.000 грн, для військових дешевше), плюс інвертор на 500 ват. 4 виходи USB (без QuickCharge, нажаль), та з двома автомобільними виходами. І вже для автомобільного конектора – докупив Baseus Particular Digital Display QC+PPS Dual Quick Charger 65W Dark, тож спокійно можу заряджати і мобільні, і ноутбук:
Єдиний, як на мене, недолік Kseon – довга зарядка, до 18 годин для повного заряду. Але пізніше купив ще один, тож маю запас.
Ще треба було вирішити питання з опаленням: газовий котел теж потребує електроенергії для свого насоса, яким він жене воду по системі.
Спеціально для нього замовив звичайні автомобільні акумулятори та інвертор:
Інвертор до 600 ват, тож котел тягне без проблем, можна підключити ще й холодильник. Обійшлося все це приблизно в 15.000 грн – не скажу, де бралося, бо цим займався знайомий прораб.
Два кумулятори по 60 ампер-годин вистачає на 5-6 годин роботи котла, але квартира та будинок нові, і тепло тримають добре.
Ще пізніше купив аналогічний набор, але вже з гелевими AGM на 72 а/г (близько 10.000 грн) та інвертором CyberPower CPS1000E (обійшовся тоді у 23.000 грн – зато привезли все на наступний день – ніяких тобі “місяць чекати”, та ще й мати справу з поштою):
Цей акум та інвертор переважно живлять настільну лампу, колонки, ноутбук, зовнішній монітор та роутер.
Тож зараз в мене запаси по енергії:
3х павербанка на 10.000 mAh
1х павербанк на 20.000 mAh
2х павербанка Baseus на 30.000 mAh, 65 ват
1х павербанк на 60.000 mAh, 65 ват
2х автоакуми на 60.000 mAh + інвертор 600 ват
1х автоакум на 72.000 mAh + інвертор 700 ват
2х зарядні станції Kseon по 168.000 mAh + інвертор 500 ват
Зараз, думаю, можу спокійно (якщо економно) прожити без світла до тижня.
Безпека
Акумлятори стоять на балконі, де температура мінімум +7. До того ж докупив декілька Вологовбирач Pouce, бо трохи переймався високою вологістю повітря та кондесатом.
Один в коридорі, біля вхідних дверей, другий в шкафчику біля балкона.
Брав два, бо ніколи ними не користувався, і якщо з першого разу щось не вийде – то буде друга спроба.
Продукти харчування
Ну, тут все просто: затарився консервами від Вербена, брав на Prom.ua (хоча взагалі цю площадку дуже не люблю) – коробок 20 різноманітних каш з м’ясом, плюс “мівіни”, паштети, галети і все таке інше із супермаркету.
Знайомі купляли Консерви Tushe, прям ящиками – теж рекомендують.
Крім того, купив 1.5 кг сала – лежить в морозильнику.
Ще гарна їдея мати запас шоколаду.
Вода
В ЖК свої насоси і ми не залежимо від міського водопостачання, але тут проблема, бо насоси – сюрпрайз! – працють від електрики.
Тому маю запас 2х20 літрів технічної води, плюс 2х20 питної бутильованної:
Пізніше ЖК купив генератор, мешканці щомісяця скидаються по 200 грн на паливо, і тепер маємо воду шонайменш двічі на добу.
Також, так як вода є не завжди, купив собі ось такий рукомийник:
Ще купив звичайний чайник для плити Ofenbach Happy Kettle 2л, бо гріти воду в кастрюльці швидко набридло.
Так наче і все.
В цілому – зараз проблеми не відчуваються зовсім, хоча спочатку було трохи… Неспокійно, скажімо так, да. Але все вийшло чудово 🙂
Тепер час розібратися з тим, що взагалі ми можемо робити в Loki використовуючи її LogQL.
Підготовка
Далі для прикладів будемо використовувати два поди – один з nginxdemo/hello для звичайних логів nginx, а інший thorstenhans/fake-logger, який буде писати логи в JSON.
Для Nginx додамо Service, что б мати можливість слати запити з curl:
$ watch -n 1 curl -X POST localhost:8080 2&>1 /dev/null
[/simterm]
Поїхали.
Grafana Explore: Loki – інтерфейс
Декілька слів про сам інтерфейс Grafana Explore > Loki.
Ви можете використовувати декілька запитів одночасно:
Також, є можливість розділити інтефейс на дві частини, і в кожній виконувати окремі запити:
Як і в звичайних дашбордах Grafana, є можливість вибрати період, за який ви хочете отримати дані, та задати інтервал для автооновлювання:
Або можете включити Live-режим – тоді дані будуть з’являтися як тільки вони потраплять до Loki:
Для створення запитів є два режими – Builder та Code.
В режимі Builder Loki видає список доступних тегів та фільтрів:
В режимі Code вони будуть підставлятися автоматично по мірі набору:
Функція Explain буде роз’яснювати що саме ваш запит робить:
А Inspector відобразить деталі про ваш запит – скільки часу і ресурсів було використано для формування відповіді – корисно для оптимізації запитів:
Крім того, завжди можна відкрити Loki Cheat Sheet, натиснувши (?) з правої сторони від поля для запиту:
LogQL: overview
В цілому, робота з Loki та її LogQL майже аналогічна роботі з Prometheus та його PromQL – майже всі тіж самі функції та загальний підхід, це навіть відображено в опису Loki: “Like Prometheus, but for logs”.
Отже, основна вибірка базується на проіндексованих лейблах (або тегах, кому як більше до вподоби), за допомогою яких ми робимо основний пошук в логах – вибираємо стрім.
Типи запитів в Loki залежать від фінального результату:
Log queries: формують строки з лог-файлів
Metric queries: включають в себе Log queries, але в результаті формують числові значення, які можна використовувати для формування графіків в Grafana або для алертів в Ruler
В цілому, будь-який запит складається із трьох основних частин:
{app="nginxdemo"} – це Log Stream Selector, в якому ми вибираємо конкретний стрім из Loki, |= – початок Log Pipeline, який включає в себе Log Filter Expression – "172.17.0.1".
Окрім Log filter, пайплайн може включати в себе Log або Tag formatting expression, який міняє отримані в пайплайн дані.
Обов’язковим є Log Stream Selector, тоді як Log Pipeline з його expressions являється опціональним, і використовуюється для уточненя або форматування результатів.
Log queries
Log Stream Selectors
Для селекторів викристовуються лейбли, які задаються агентом, який збирає логи – promtail, fluentd або іншими.
Log Stream Selector визначає скільки індексів та блоків данних будуть завантажені для повернення результату, тобто напряму впливає на швидкість роботи і ресурси CPU/RAM, задіяні для формування відповіді.
В прикладі вище в селекторі {app="nginxdemo"} ми використовуємо оператор “=“, який може бути:
= : дорівнює
!= : не дорівнює
=~ : regex
!~ : негативний regex
Отже, за запитом {app="nginxdemo"} ми отримаємо логи всіх подів, у яких є тег app зі значенням nginxdemo:
Можемо комбінувати декілька селекторів, наприклад отримати всі логи з logging=test, але без app=nginxdemo:
{logging="test", app!="nginxdemo"}
Або використати regex:
{app=~"nginx.+"}
Або просто вибрати взагалі всі логи (стріми), в яких є тег app:
Log Pipeline
Данні, отримані зі стриму можна передати в пайплайн для подальшого фільтрування або форматування. При цьому результат роботи одного пайплайну можна передати в наступний, і так далі.
Pipeline може включати в себе:
Log line filtering expressions – для фільтрування попередніх результатів
Parser expressions – для отримання тегів з логів, які можна передати в Tag filtering
Tag filtering expressions – для фільтрування даних по тегам
Log line formatting expressions – використовується для редагування отриманних результатів
Tag formatting expressions – редагування тегів/лейбл
Log Line filtering
Фільтри використовуються для… фільтрування)
Тобто, коли ми отримали дані із стриму, і хочемо з нього вибрати окремі строки – то використовуємо log filter.
Фільтр складається з оператора та строкового запиту, за яким робиться вибірка данних.
Операторами можуть буди:
|=: строка містить строковий запит
! =: строка НЕ містить строковий запит
|~: строка дорівнює регулярному виразу
! ~: строка НЕ дорівнює регулярному виразу
При використанні regex майте на увазі, что використовується синтаксис Golang RE2, і за замочуванням він є case-sensitive. Щоб переключити його на незалежний від регистру режим – додаємо (i?).
Окрім того, Log Line filtering краще використовувати на початку запиту, бо вони працють швидко, і позбавлять наступні пайплайни він зайвої роботи.
Прикладом log filter може бути вибірка за строкою:
{job=~".+"} |= "promtail"
Або декілька виразів, використовуючи регулярку:
Parser expressions
Парсери… парсять) (гвинтокрили гвинтять) вхідні дані, та отримують з них лейбли, які потім можна використати в подальших фільтрах або для формування Metric queries.
Наразі, LogQL підтримує json, logfmt, pattern, regexp та unpack для роботи з тегами.
json
Наприклад, json формує всі json-ключі в лейбли, тобто запит {app="fake-logger"} | json замість:
Сформує новий набір тегів:
Отримані через json теги можна далі використати для додаткових фільтрів, наприклад – вибрати тільки строки з level=debug:
logfmt
Для формування тегів з логів не в форматі JSON можно використати logfmt, який всі знайдені поля перетворить на лейбли.
Наприклад, job="monitoring/loki-read" має поля ключ=значення:
Як видно з назви, дозволяє створювати нові фільтри з тегів які вже є в запису, або які були створені за допомогою попереднього парсеру, наприклад logfmt.
За допомогою label_format можемо перейменувати, змінити чи додати нові лейбли.
Для цього, аргументом передаємо ім’я лейбли з оператором =, за яким йде потрібне значення.
Наприклад, маємо лейблу app:
Яку хочемо перейменувати в application – використовуємо label_format application=app:
Або можемо використати значення існуючого тегу для створення нового, для цього використовуємо шаблонізатор у вигляді {{.field_name}}, де можемо комбінувати декілька полів.
Тобто, якщо хочемо створити тег error_message в якому будуть значення полів level та msg – формуємо такий запит:
Задаємо в nodeSelector ім’я ноди, щоб було простіше шукати в Локі.
При старті цього поду Kubernetes його вбиватиме через перевищення лімітів, а journald на WorkerNode записуватиме подію в системний журнал, який збирається promtail:
[simterm]
$ kk -n monitoring get cm logs-promtail -o yaml
...
- job_name: journal
journal:
labels:
job: systemd-journal
max_age: 12h
path: /var/log/journal
relabel_configs:
- source_labels:
- __journal__systemd_unit
target_label: unit
- source_labels:
- __journal__hostname
target_label: hostname
[/simterm]
Запускаємо наш под:
[simterm]
$ kk apply -f test-oom.yaml
pod/oom-test created
[/simterm]
Перевіряємо:
[simterm]
$ kk describe pod oom-test
...
Events:
Type Reason Age From Message
---- ------ ---- ---- -------
Normal Scheduled 91s default-scheduler Successfully assigned default/oom-test to ip-10-0-0-27.us-west-2.compute.internal
Normal SandboxChanged 79s (x12 over 90s) kubelet Pod sandbox changed, it will be killed and re-created.
Warning FailedCreatePodSandBox 78s (x13 over 90s) kubelet Failed to create pod sandbox: rpc error: code = Unknown desc = failed to start sandbox container for pod "oom-test": Error response from daemon: failed to create shim task: OCI runtime create failed: runc create failed: unable to start container process: container init was OOM-killed (memory limit too low?): unknown
[/simterm]
І перевіряємо логі Loki:
Окей, тепер у нас є oom-killed под для тестів – давайте формувати запит для майбутнього алерту.
Формування запиту в Loki
В логах ми дивилися по запиту {hostname="eks-node-dev_data_services-i-081719890438d467f"} |~ ".*OOM-killed.*" – використовуємо його ж для тестового алерту.
Спочатку перевіримо що нам намалює сама Локі – використовуємо rate()та sum(), див.Log range aggregations:
sum(rate({hostname="eks-node-dev_data_services-i-081719890438d467f"} |~ ".*OOM-killed.*" [5m])) by (hostname)
Гуд!
З цим вже можна працювати – створювати тестовий алерт.
В subPath вказуємо key з ConfigMap, щоб підключити саме як файл.
Налаштування Ruler alerting
Знаходимо Alertmanager URL:
[simterm]
$ kk -n monitoring get svc
NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE
...
prometheus-kube-prometheus-alertmanager ClusterIP 172.20.240.159 <none> 9093/TCP 110d
...
Перевіряємо Алерти в Алертменеджері – http://localhost:9093:
Loki та додаткові labels
В алертах хочеться виводити трохи більше інформації, ніж просто повідомлення “Test Loki OOM Killer Alert”, наприклад – відобразити ім’я пода, який був вбитий.
Тут я для тестів створював нові лейбли, які підключалися до логів – sourceі level.
Інший варіант із Promtail – використовуючи static_labels.
Але тут є проблема: оскільки Loki на кожний набір лейбл створює окремий лог-стрім, для якого створюються окремі індекси та блоки даних, то в результаті отримаємо по-перше проблеми з продуктивністю, по-друге – з вартістю, т.к. на кожен індекс і блок даних будуть виконуватися запити читання-запису в shared store, у нашому випадку це AWS S3, де за кожен запит доводиться платити гроші.
Натомість, ми можемо створювати нові лейбли прямо із запиту за допомогою самої Loki.
Візьмемо запис із лога, в якому йдеться про спрацювання OOM Killer:
E1213 16:52:25.879626 3382 pod_workers.go:951] “Error syncing pod, skipping” err=”failed to \”CreatePodSandbox\” for \”oom-test_default(f02523a9-43a7-4370-85dd-1da7554496e6)\” with CreatePodSandboxError: \”Failed to create sandbox for pod \\\”oom-test_default(f02523a9-43a7-4370-85dd-1da7554496e6)\\\”: rpc error: code = Unknown desc = failed to start sandbox container for pod \\\”oom-test\\\”: Error response from daemon: failed to create shim task: OCI runtime create failed: runc create failed: unable to start container process: container init was OOM-killed (memory limit too low?): unknown\”” pod=”default/oom-test” podUID=f02523a9-43a7-4370-85dd-1da7554496e6
Тут ми маємо поле pod з ім’ям пода, який був вбитий – pod="default/oom-test".
Останній раз працював з Loki коли вона була ще в Beta, і виглядала вона тоді набагато простіше, ніж зараз.
У новому проекті системи логування немає взагалі, а так як у нас усі люблять Grafana-стек – то вирішили і для логів підняти Loki.
Правда мені думалося, що все буде набагато простіше. Виявилося – ні. Багато змінилося, і довелося знайомитися з нею по суті з нуля.
Що залишилося, як і раніше, – це така собі документація. Якщо опис архітектури та компонентів ще більш-менш нормально описаний, то коли справа доходить до налаштування – то натикаєшся на купу проблем, особливо те, що стосується сторейджа та зберігання в AWS S3 (хоча поки писав цей пост викотили реліз 2.7, і документацію теж оновили – можливо, тепер вона краща). Довелося збирати по шматочках, але в результаті все-таки завелося.
Розглянемо загальну архітектуру та компоненти, потім встановимо в AWS Kubernetes із Helm-чарта.
Архітектура Grafana Loki
Loki створена за мікросервісною архітектурою, при цьому всі мікросервіси зібрані в один бінарник.
Для запуску компонентів використовується опція --target, в якій можна визначити яку частину Loki запустити.
Вхідні дані діляться на стріми – це потік даних, логів, що мають загальний tenant_id (“відправник”), і загальний набір тегів/labels. Докладніше про стріми поговоримо в Storage.
Компоненти Loki
Робота системи ділиться на два основні потоки: Read path– читання (обробка запитів на вибірку даних) та Write path – запис цих даних в сторейдж.
Загальна схема всіх компонентів:
Тут:
distributor (write path): займається обробкою вхідних даних від клієнтів – одержує від них дані, валідує їх, ділить дані на блоки, і відправляє в ingester. Бажано мати LoadBalancer перед дистриб’юторами, щоб вхідні стріми розподілялися по інстансах дистриб’юторів. Є stateless компонентом – не зберігає в собі жодних даних. Також відповідає за рейт-ліміти та препроцесинг тегів.
ingester (write, read path): відповідає за запис даних у довгострокове сховище та за передачу даних для обробки запитів на їх читання клієнтами. Для запобігання втратам даних у разі рестарту інжестера, їх зазвичай запускають у вигляді декількох інстансів (див. replication_factor)
querier (read path): обробляє LogQL запити, завантажуючи для відповіді дані з інжестерів та/або довгострокового сховища – спочатку запитує інжестер, якщо в пам’яті інжестера даних немає – то querier йде в сховище даних
query frontend (read path): опціональний сервіс, що надає доступ до querier API для прискорення операцій читання. При його використанні він зберігає запити, що надходять, а querier звертається до нього, щоб взяти з черги запит на обробку
compactor: зменшення розміру індексів та управління часом зберіганням логів у сховищі даних (retention)
Data flow
Коротко про сам процес обробки даних – запити читання (read), і запис (write).
Loki отримує дані з promtail (або інших агентів, наприклад fluentd)
створює блоки даних (chunks), індекс, завантажує їх у довгострокове сховище
користувач використовує LogQL для вибірки логів у Grafana
ruler перевіряє данні, і при необхідності відправляє алерт у Prometheus Alertmanager
Read Path
При отриманні запиту на вибірку даних:
querier отримує HTTP запит
передає запит до ingesters для пошуку даних в пам’яті
якщо ingesters знаходитт дані у себе – то повертає їх querier
якщо в ingesters даних нема – querier йде в сховище даних, і отримує їх звідти
querier повертає відповідь через теж HTTP-з’єднання
Write Path
При отриманні нових даних:
distributor отримує HTTP/1 запрос на додавання даних в конкретний стрім
distributor передає кожний стрім в ingester
ingester створює новий chunk (“блок даних”, див. Loki Storage) або доповнює існуючий
distributor відповідає ОК на HTTP/1 запит
Режими запуску
Запускати Loki можна в трьох режимах, кожен з яких визначає, як будуть запущені компоненти – у вигляді одного або декількох подів Kubernetes.
Monolithic mode
Дефолтний тип при використанні локальної filesystem для зберігання даних.
Підходить для швидкого запуску та невеликих об’ємів даних, до 100GB на день
Балансування запитів виконується по round robin.
Паралелізація запитів обмежена кількістю інстансів та налаштуванням кожної інстансу.
Основне обмеження – не можна використовувати object store, такі як AWS S3.
Simple scalable deployment mode
Дефолтний тип під час використання object store.
Якщо у вас логів більше кількох сотень гігабайт, але менше кількох терабайт на день, або ви хочете ізолювати читання та запис, то можна задеплоїти Loki в режимі simple scalable deployment:
У такому режимі Loki запускається з двома таргетами – read & write.
Потребує наявності лоад-балансера, який буде роутити запити до інстансів з компонентами Loki.
Microservices mode
І для найскладніших випадків, коли у вас логи йдуть терабайтами на день, має сенс деплоїти кожен сервіс окремо:
ingester
distributor
query-frontend
query-scheduler
querier
index-gateway
ruler
compactor
Дозволяє моніторити та скейлити кожен компонент незалежно.
Loki для зберігання логів використовує два типи даних – chunks та індекси. Не придумав корректного перекладу для chunk, тому нехай буде “блок даних”.
Loki отримує дані від кількох стримів, де кожен стрім – це tenant_id та набір тегів. При отриманні нових записів від стриму вони упаковуються в блоки і відправляються в довгострокове сховище, в ролі якого можуть бути AWS S3, локальна файлова система, або бази даних типу AWS DynamoDB чи Apache Cassandra.
В індексах зберігається інформація про набір тегів кожного стриму і є посилання на пов’язані з цим стримом блоки даних.
Раніше Loki використовувала два окремі сховища – одне під індекси (наприклад, таблиці DynamoDB), і друге – безпосередньо під самі дані (AWS S3).
Десь з версії 2.0 у Loki з’явилася можливість зберігати індекси у вигляді BotlDB файлів та використовувати Single Store – єдине сховище і для блоків даних, і для індексів. Див. Single Store Loki (boltdb-shipper index type).
Ми будемо використовувати boltdb-shipper – він буде формувати індекси локально, а потім пушити їх у shared object store. Там же будуть зберігатися і самі chunks.
Важливий момент, який необхідно враховувати під час роботи з тегами в Loki це те, як формуються індекси та блоки даних: кожен окремий набір тегів формує окремий стрім, а для кожного окремого стріму формуються свої індекси та блоки даних.
Тобто, якщо ви динамічно створюєте теги/лейбли, наприклад client_ip – то у вас буде формуватися окремий набір файлів на кожен клієнтський IP, що призведе до того, що на кожен такий файл будуть виконуватися окремі запити GET/POST/DELETE, що може привести по-перше до зростання вартості сховища (як у випадку з AWS S3, де оплачується кожен виклик), так і до проблем з швидкістю обробки запитів.
Окрім документації у Loki ще й із чартами трохи складнощів, оскільки переносили між репозиторіями, об’єднували, і тепер деякі стали deprecated (хоча посилання на них у документації зустрічаються).
Нижче – не про установку, а просто деякі особливості чартів Loki, з якими довелося повозитися.
Ще один момент, який трохи поламав мозок: окей, ми бачили, що Loki можна запустити з різними Deployment modes – але як це визначити у чарті? Якогось values типу -targetтам немає.
Нижче – трохи копання в чарті, можна пропустити, якщо вам підходить дефолтна установка.
Отже, якщо встановити з дефолтними values, то отримуємо наступні компоненти:
$ kk get pod
NAME READY STATUS RESTARTS AGE
loki-canary-7vrj2 0/1 ContainerCreating 0 12s
loki-gateway-5868b68c68-lwtfj 0/1 ContainerCreating 0 12s
loki-grafana-agent-operator-684b478b77-zmw5t 1/1 Running 0 12s
loki-logs-kwxcx 0/2 ContainerCreating 0 3s
loki-read-0 0/1 ContainerCreating 0 12s
loki-read-1 0/1 Pending 0 12s
loki-read-2 0/1 Pending 0 12s
loki-write-0 0/1 ContainerCreating 0 12s
loki-write-1 0/1 Pending 0 12s
loki-write-2 0/1 Pending 0 12s
[/simterm]
Тобто, по дефолту воно встановлюється в simple-scalable, при цьому в документації самих чартів про це нічого не сказано, як ні слова про те, як задати деплоймент-режим взагалі.
А якщо я хочу Single Binary?
Зносимо:
[simterm]
$ helm uninstall loki
release "loki" uninstalled
[/simterm]
Спробуємо повірити документації, і створюємо свої values:
Тобто, тупо перевизначивши сторейдж – ми змінюємо режим деплойменту?!?
Ах#*$ть – дайте два!
Як воно працює?
Відкриваємо файл templates/_helpers.tpl, в якому є два шаблони – loki.deployment.isScalable і loki.deployment.isSingleBinary, в яких одна і та ж умова, тільки з різними значеннями:
Якщо true – то це isScalable, а якщо false – то isSingleBinary.
Окей, а що за isUsingObjectStorage?
Знаходимо його в тому же хелпері:
...
{{/* Determine if deployment is using object storage */}}
{{- define "loki.isUsingObjectStorage" -}}
{{- or (eq .Values.loki.storage.type "gcs") (eq .Values.loki.storage.type "s3") (eq .Values.loki.storage.type "azure") -}}
{{- end -}}
...
Тобто, якщо ми використовуємо .Values.loki.storage.type із значенням gcs, s3 або azure – то loki.isUsingObjectStorage прийме значення true, і Loki буде встановлено в режимі Simple Scale.
Зовсім не очевидно і не описано в документації до чарту.
Запуск Grafana Loki
А тепер, нарешті, перейдемо до запуску і налаштування Loki.
Для зберігання даних будемо використовувати AWS S3, для роботи з індексами – bottledb-shipper, для налаштування терміну зберігання логів – compactor.
Аутентифікацію Loki реалізуємо через підключення ServiceAccount із AWS IAM Role, але покажу приклад і зі звичайними ACCESS/SECRET keys.
Створення AWS S3 корзини
Почнемо зі створення корзини. Можна через AWS CLI та create-bucket, або через Terraform:
Для корзини буде потрібна політика, яка дозволяє до неї доступ, і роль, яку потім підключимо до Kubernetes Pod.
Повертаючись до проблем документації Loki – на сторінці Grafana Loki Storage є приклад політики для AWS S3, яка… не проходить валідацію в AWS IAM :faceplam:
Взагалі часто виникали асоціації з Miscrosoft Azure – там документації теж вірити не можна від слова зовсім, і все треба перевіряти і збирати по шматочках.
Переходимо до конфігу Loki – тут теж вистачило болю та страждань із документацією та чартом.
Запуск Grafana Loki в Kubernetes
Ну і тепер, коли мені стало ясно і з чартами, і з тим, як же через Helm-чарт Loki задати Deployment Mode, і взагалі який чарт використовувати – спробуємо її запустити.
Готуємо мінімальний конфіг, в якому для початку відключимо весь її внутрішній моніторинг щоб зменшити кількість подів – буде простіше розбиратися з тим, як воно працює, і для початку використовуємо сховище filesystem, щоб зберігати дані та індекси локально в подах:
$ kk -n test-loki-0 get pod
NAME READY STATUS RESTARTS AGE
loki-0 1/1 Running 0 118s
[/simterm]
Окей – є один под, нічого зайвого.
Чарт створює StatefulSet, в якому описується створення цього поду і через який підключаються різні volumes:
[simterm]
$ kk -n test-loki-0 get sts
NAME READY AGE
loki 1/1 3m
[/simterm]
І ConfigMap, в якій зберігається конфіг, доповнений нашим loki-minimal-values.yaml:
[simterm]
$ kk -n test-loki-0 get cm loki -o yaml
apiVersion: v1
data:
config.yaml: |
auth_enabled: false
common:
path_prefix: /var/loki
replication_factor: 1
storage:
filesystem:
chunks_directory: /var/loki/chunks
rules_directory: /var/loki/rules
...
[/simterm]
Grafana Loki S3 config
Дуже багато віддав би, щоб десь знайти повний конфіг для Grafana Loki з AWS S3 як у прикладі нижче, та ще й з авторизацією через ServiceAccount і AWS IAM – витратив багато часу, щоб змусити все це працювати.
Власне, сам конфіг, потім трохи про опції та підводні камені, з якими зіткнувся:
auth_enabled: false – відключаємо авторизацію в самій Loki (в результаті отримаємо tenant_idfake в корзині – це ок, нормально, хоча могли б придумати щось красивіше ніж “фейк”)
storage.bucketNames.chunks – потрібно вказати ім’я корзини для блоків, інакше намагатиметься використовувати локальне сховище; у документації не вказано;
schema_config.configs.store:
boltdb-shipper – вказуємо на використання boltdb-shipper для роботи з індексами, оскільки він вміє в Single Store, тобто і блоки даних і їх індекси будуть в одній корзині
object_store: s3 – вказуємо тип сховища, яке налаштовується в storage_config.aws.s3 (але тут вказуємо саме як schema_config.configs.store.s3, а не schema_config.configs.store.aws.s3)
storage_config – найбільший біль:
aws.s3: вказуємо саме у вигляді s3://<S3_BUCKET_REGION>/<S3_BUCKET_NAME>, інакше при підключенні ServiceAccount Loki починає намагатися ходити для авторизації на https://sts.dummy.amazonaws.com – я так і не знайшов чому, але при використанні ServiceAccount потрібен саме такий формат
boltdb_shipper – вказуємо йому локальний шлях, де він створює індекси – active_index_directory, та shared_store – куди потім їх відправляти; візьме конфіг із тієї ж storage_config.aws.s3
rulerConfig.storage.type: local – для ruler поки вкажемо локальний каталог, з алертами розберемося в інший раз; якщо не вказати – буде постійно писати в лог помилку, що не може отримати доступ до своєї корзини, яка десь прописана у дефолтах, не пам’ятаю вже де саме
write.replicas: 2 – мінімальна кількість подів write, щоб Promatil міг писати дані
$ aws --profile development s3 ls test-loki-0
2022-12-25 11:53:13 251 loki_cluster_seed.json
[/simterm]
І ще за кілька хвилин повинні повитися каталоги fake та index:
[simterm]
$ aws --profile development s3 ls test-loki-0
PRE fake/
PRE index/
2022-12-25 11:53:13 251 loki_cluster_seed.json
[/simterm]
У fake – chunks, в index – індекси.
Окей – начебто завелося.
Дивно, насправді, що цього разу все з першої спроби завелося… Поки сетапив Loki на проекті, де працюю – реально вже подумував взяти ELK та не гаяти час.
Тепер після додавання promatil, який писатиме дані – Loki Write через ingester писатиме блоки даних, а bottledb-shipper почне створювати індекси, і пушити їх у корзину.
Запуск Promtail
Знаходимо Service для Loki Gateway:
[simterm]
$ kk -n test-loki-0 get svc
NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE
loki-gateway ClusterIP 10.109.225.168 <none> 80/TCP 22m
...
Цього разу трохи детальніше про його сетап і можливості.
Отже, blackbox-exporter – це експортер, який вміє моніторити різноманітні ендпоінти – це можуть бути або якісь URL-и в інтернеті, ваші LoadBalancer-и в Амазоні, або Services в Кубернетесі, такі як MySQL або PostgreSQL бази данних.
Вміє виводити статистику по швидкості відповіді HTTP, коди відповідей, інформацію по SSL-сертіфікатах тощо.
$ kk -n monitoring get pod
NAME READY STATUS RESTARTS AGE
prometheus-blackbox-prometheus-blackbox-exporter-6865d9b44h546j 1/1 Running 0 27s
...
[/simterm]
Blackbox тримає свій конфіг в ConfigMap-і, яка підключається до поду і передає дефолтні параметри. Див. тут>>>.
[simterm]
$ kk -n monitoring get cm prometheus-blackbox-prometheus-blackbox-exporter -o yaml
apiVersion: v1
data:
blackbox.yaml: |
modules:
http_2xx:
http:
follow_redirects: true
preferred_ip_protocol: ip4
valid_http_versions:
- HTTP/1.1
- HTTP/2.0
prober: http
timeout: 5s
[/simterm]
Власне, тут ми і бачимо модулі, точніше поки що один, який використвує prober http, який виконує HTTP-запроси до targets, які ще треба додати.
Blackbox та ServiceMonitor
Для того, щоб додати ендпоінти, котрі ми хочемо моніторити, можна використовувати ServiceMonitor, див. конфіг тут>>>.
Чомусь ніде в нагуглених гайдах цей момент толком не описаний, хоча він дуже зручний: в конфіг Блекбоксу додаємо список таргетів, а Блекбокс створює ServiceMonitor для кожного з них, і Prometheus починає їх моніторити.
Створюємо файл blackbox-exporter-values.yaml, в якому додаємо поки що один ендпоінт – просто перевірити, чи воно взагалі працює:
Якщо не вказано інше, то Блекбокс використвує дефолтні значення із values.yaml чарту, в данному випадку це буде модуль http_2xx, який виконує GET запрос, та перевіряє код відповіді: якщо отримано 200 – то перевірка пройдена, якщо інший – то фейл.
$ kk apply -f testpod-with-svc.yaml
pod/nginx created
service/nginx-service created
[/simterm]
Перевіряємо:
[simterm]
$ kk -n test-ns get all
NAME READY STATUS RESTARTS AGE
pod/nginx 1/1 Running 0 23s
NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE
service/nginx-service ClusterIP 10.106.58.247 <none> 80/TCP 23s
$ kk -n monitoring get servicemonitor
NAME AGE
prometheus-blackbox-prometheus-blackbox-exporter-google.com 12m
prometheus-blackbox-prometheus-blackbox-exporter-nginx-test 5s
[/simterm]
І за хвилину – можемо перевіряти probe_success:
Взагалі, не обов’язково вказувати повний URL у вигляді nginx-service.test-ns.svc.cluster.local – достатньо буде servicename.namespace, тобто nginx-service.test-ns, але повний URL як на мене виглядає більш наочно в лейблах та алертах.
Модулі Blackbox Exporter
Все виглядає чудово, поки ми опитуємо звичайний HTTP-ендпоінт, який завжди віддає код 200.
Що як треба перевірити інші коди?
Створимо власний модуль, використвуючи probes Блекбоксу:
Тут в modules задаємо ім’я нового модуля – http_4xx, який пробер він має викорисовувати – http, і параметри для цього пробера – яким саме запитом перевіряємо, і які коди відповіді будемо вважати правильними.
Далі, в Таргетах для nginx-test-404 явно вказуємо використання модулю http_4xx.
Тестування модулів
Окремо подивимось як саме можемо перевірити – чи буде модуль працювати так, як ми розраховуємо.
Все просто – запускаємо тестовий под, і curl-ом з опцією -I дивимось на відповідь ендпоінта.
Якшо перевіряємо TCP-коннект – то telnet.
Отже, створюємо под з Убунтою, підключаємось до нього – запускаємо всередені bash:
[simterm]
$ kk -n monitoring run pod --rm -i --tty --image ubuntu -- bash
probe_success{target="nginx-test-404"} == 1 – все робить.
TCP Connect і моніторинг баз данних
Ще один модуль, котрий дуже часто використовуємо – TCP, який просто намагається відкрити TCP-сессію на вказанний URL та порт. Підходить для перевірок баз данних та будь-яких інших не-HTTP-ресурсів.
$ kk get svc
NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE
kubernetes ClusterIP 10.96.0.1 <none> 443/TCP 20h
mysql ClusterIP 10.99.71.124 <none> 3306/TCP 40s
mysql-headless ClusterIP None <none> 3306/TCP 40s