
Нередко при диагностике проблем в кластере Kubernetes мы замечаем, что иногда моросит* один из узлов кластера и, конечно же, происходит это редко и странно. Так мы пришли к необходимости в инструменте, который бы делал ping с каждого узла на каждый узел и отдавал результаты своей работы в виде метрик Prometheus. Нам бы оставалось лишь нарисовать графики в Grafana и быстро локализовать сбойный узел (и при необходимости убрать с него все pod'ы, после чего произвести соответствующие работы**)…
* Под «моросит» я понимаю, что узел может переходить в статус
NotReady и вдруг возвращаться назад в работу. Или же, например, часть трафика в pod'ах может не доходить до pod'ов на соседних узлах.** Почему вообще такие ситуации возникают? Одной из частых причин могут быть сетевые проблемы на коммутаторе в дата-центре. К примеру, однажды в Hetzner мы настраивали vswitch, но в чудесный момент один из узлов перестал быть доступным по данному vswitch-порту: из-за этого получалось, что по локальной сети узел был полностью недоступен.
К тому же, мы хотели бы запускать такой сервис прямо в Kubernetes, чтобы весь деплой происходил с помощью установки Helm-чарта. (Предвосхищая вопросы — в случае использования того же Ansible, нам бы пришлось писать роли под различные окружения: AWS, GCE, bare metal…) Немного поискав в интернете уже готовые инструменты для поставленной задачи, мы ничего подходящего не нашли. Поэтому сделали свой.
Скрипт и конфиги
Итак, главный компонент нашего решения — скрипт, который следит за изменением у любых узлов поля
.status.addresses и, если у какого-то узла изменилось данное поле (т.е. новый узел был добавлен), с помощью Helm values передаёт в чарт данный список узлов в виде ConfigMap:--- apiVersion: v1 kind: ConfigMap metadata: name: node-ping-config namespace: kube-prometheus data: nodes.json: > {{ .Values.nodePing.nodes | toJson }}
Сам скрипт на Python:
#!/usr/bin/env python3 import subprocess import prometheus_client import re import statistics import os import json import glob import better_exchook import datetime better_exchook.install() FPING_CMDLINE = "/usr/sbin/fping -p 1000 -A -C 30 -B 1 -q -r 1".split(" ") FPING_REGEX = re.compile(r"^(\S*)\s*: (.*)$", re.MULTILINE) CONFIG_PATH = "/config/nodes.json" registry = prometheus_client.CollectorRegistry() prometheus_exceptions_counter = \ prometheus_client.Counter('kube_node_ping_exceptions', 'Total number of exceptions', [], registry=registry) prom_metrics = {"sent": prometheus_client.Counter('kube_node_ping_packets_sent_total', 'ICMP packets sent', ['destination_node', 'destination_node_ip_address'], registry=registry), "received": prometheus_client.Counter( 'kube_node_ping_packets_received_total', 'ICMP packets received', ['destination_node', 'destination_node_ip_address'], registry=registry), "rtt": prometheus_client.Counter( 'kube_node_ping_rtt_milliseconds_total', 'round-trip time', ['destination_node', 'destination_node_ip_address'], registry=registry), "min": prometheus_client.Gauge('kube_node_ping_rtt_min', 'minimum round-trip time', ['destination_node', 'destination_node_ip_address'], registry=registry), "max": prometheus_client.Gauge('kube_node_ping_rtt_max', 'maximum round-trip time', ['destination_node', 'destination_node_ip_address'], registry=registry), "mdev": prometheus_client.Gauge('kube_node_ping_rtt_mdev', 'mean deviation of round-trip times', ['destination_node', 'destination_node_ip_address'], registry=registry)} def validate_envs(): envs = {"MY_NODE_NAME": os.getenv("MY_NODE_NAME"), "PROMETHEUS_TEXTFILE_DIR": os.getenv("PROMETHEUS_TEXTFILE_DIR"), "PROMETHEUS_TEXTFILE_PREFIX": os.getenv("PROMETHEUS_TEXTFILE_PREFIX")} for k, v in envs.items(): if not v: raise ValueError("{} environment variable is empty".format(k)) return envs @prometheus_exceptions_counter.count_exceptions() def compute_results(results): computed = {} matches = FPING_REGEX.finditer(results) for match in matches: ip = match.group(1) ping_results = match.group(2) if "duplicate" in ping_results: continue splitted = ping_results.split(" ") if len(splitted) != 30: raise ValueError("ping returned wrong number of results: \"{}\"".format(splitted)) positive_results = [float(x) for x in splitted if x != "-"] if len(positive_results) > 0: computed[ip] = {"sent": 30, "received": len(positive_results), "rtt": sum(positive_results), "max": max(positive_results), "min": min(positive_results), "mdev": statistics.pstdev(positive_results)} else: computed[ip] = {"sent": 30, "received": len(positive_results), "rtt": 0, "max": 0, "min": 0, "mdev": 0} if not len(computed): raise ValueError("regex match\"{}\" found nothing in fping output \"{}\"".format(FPING_REGEX, results)) return computed @prometheus_exceptions_counter.count_exceptions() def call_fping(ips): cmdline = FPING_CMDLINE + ips process = subprocess.run(cmdline, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, universal_newlines=True) if process.returncode == 3: raise ValueError("invalid arguments: {}".format(cmdline)) if process.returncode == 4: raise OSError("fping reported syscall error: {}".format(process.stderr)) return process.stdout envs = validate_envs() files = glob.glob(envs["PROMETHEUS_TEXTFILE_DIR"] + "*") for f in files: os.remove(f) labeled_prom_metrics = [] while True: with open("/config/nodes.json", "r") as f: config = json.loads(f.read()) if labeled_prom_metrics: for node in config: if (node["name"], node["ipAddress"]) not in [(metric["node_name"], metric["ip"]) for metric in labeled_prom_metrics]: for k, v in prom_metrics.items(): v.remove(node["name"], node["ipAddress"]) labeled_prom_metrics = [] for node in config: metrics = {"node_name": node["name"], "ip": node["ipAddress"], "prom_metrics": {}} for k, v in prom_metrics.items(): metrics["prom_metrics"][k] = v.labels(node["name"], node["ipAddress"]) labeled_prom_metrics.append(metrics) out = call_fping([prom_metric["ip"] for prom_metric in labeled_prom_metrics]) computed = compute_results(out) for dimension in labeled_prom_metrics: result = computed[dimension["ip"]] dimension["prom_metrics"]["sent"].inc(computed[dimension["ip"]]["sent"]) dimension["prom_metrics"]["received"].inc(computed[dimension["ip"]]["received"]) dimension["prom_metrics"]["rtt"].inc(computed[dimension["ip"]]["rtt"]) dimension["prom_metrics"]["min"].set(computed[dimension["ip"]]["min"]) dimension["prom_metrics"]["max"].set(computed[dimension["ip"]]["max"]) dimension["prom_metrics"]["mdev"].set(computed[dimension["ip"]]["mdev"]) prometheus_client.write_to_textfile( envs["PROMETHEUS_TEXTFILE_DIR"] + envs["PROMETHEUS_TEXTFILE_PREFIX"] + envs["MY_NODE_NAME"] + ".prom", registry)
Он запускается на каждом узле и 2 раза в секунду отправляет ICMP-пакеты на все остальные инстансы Kubernetes-кластера, а полученные результаты записывает результаты в текстовые файлы.
Скрипт включён в Docker-образ:
FROM python:3.6-alpine3.8 COPY rootfs / WORKDIR /app RUN pip3 install --upgrade pip && pip3 install -r requirements.txt && apk add --no-cache fping ENTRYPOINT ["python3", "/app/node-ping.py"]
Вдобавок, был создан ServiceAccount и роль к нему, что разрешают получать только список узлов (чтобы знать их адреса):
--- apiVersion: v1 kind: ServiceAccount metadata: name: node-ping namespace: kube-prometheus --- kind: ClusterRole apiVersion: rbac.authorization.k8s.io/v1 metadata: name: kube-prometheus:node-ping rules: - apiGroups: [""] resources: ["nodes"] verbs: ["list"] --- kind: ClusterRoleBinding apiVersion: rbac.authorization.k8s.io/v1 metadata: name: kube-prometheus:kube-node-ping subjects: - kind: ServiceAccount name: node-ping namespace: kube-prometheus roleRef: apiGroup: rbac.authorization.k8s.io kind: ClusterRole name: kube-prometheus:node-ping
Наконец, потребуется DaemonSet, который и запускается на всех инстансах кластера:
--- apiVersion: extensions/v1beta1 kind: DaemonSet metadata: name: node-ping namespace: kube-prometheus labels: tier: monitoring app: node-ping version: v1 spec: updateStrategy: type: RollingUpdate template: metadata: labels: name: node-ping spec: terminationGracePeriodSeconds: 0 tolerations: - operator: "Exists" serviceAccountName: node-ping priorityClassName: cluster-low containers: - resources: requests: cpu: 0.10 image: private-registry.flant.com/node-ping/node-ping-exporter:v1 imagePullPolicy: Always name: node-ping env: - name: MY_NODE_NAME valueFrom: fieldRef: fieldPath: spec.nodeName - name: PROMETHEUS_TEXTFILE_DIR value: /node-exporter-textfile/ - name: PROMETHEUS_TEXTFILE_PREFIX value: node-ping_ volumeMounts: - name: textfile mountPath: /node-exporter-textfile - name: config mountPath: /config volumes: - name: textfile hostPath: path: /var/run/node-exporter-textfile - name: config configMap: name: node-ping-config imagePullSecrets: - name: antiopa-registry
Итоговые штрихи в словах:
- Результаты работы Python-скрипта — т.е. текстовые файлы, размещаемые на хост-машине в каталоге
/var/run/node-exporter-textfile, — попадают в DaemonSet node-exporter. В аргументах его запуска указано--collector.textfile.directory /host/textfile, где/host/textfile— это hostPath на/var/run/node-exporter-textfile. (Про textfile collector в node-exporter можно прочитать здесь.) - В итоге, node-exporter считывает эти файлы, а Prometheus собирает все данные с node-exporter.
Что получилось?
Теперь — к долгожданному результату. Когда такие метрики были созданы, мы можем на них посмотреть и, конечно, нарисовать наглядные графики. Вот как всё выглядит.
Во-первых, есть общий блок с возможностью (с помощью селектора) выбрать список узлов, с которых и на которые выполняется ping. Так выглядит сводная таблица по пингу между выбранными узлами за период, указанный в Grafana dashboard:

А вот графики с общей информацией по выбранным узлам:

Также у нас имеется список строк, каждая из которых — графики по одному отдельному узлу из селектора Source node:

Если развернуть такую строку, то видно информацию по пингам с конкретного узла на все остальные, что были выбраны в селекторе Destination nodes:

Эта информация в графиках:

Наконец, как же будут выглядеть заветные графики с плохим пингом между узлами?


Если вы наблюдаете подобное в реальном окружении — самое время разобраться с причинами.
P.S.
Читайте также в нашем блоге:
