Закон Парето (принцип Парето, принцип 80/20) — «20 % усилий дают 80 % результата, а остальные 80 % усилий — лишь 20 % результата».
Wikipedia

Приветствую тебя, дорогой читатель!


Моя первая статья на Хабр посвящена простому и, надеюсь, полезному решению, сделавшим для меня сбор метрик в Prometheus с разнородных серверов удобным. Я затрону некоторые подробности, в которые многие могли не погружаться, эксплуатируя Prometheus, и поделюсь своим подходом по организации в нём легковесного service discovery.


Для этого понадобится: Prometheus, HashiCorp Consul, systemd, немного кода на Bash и осознание происходящего.


Если интересно узнать, как все это связано и как оно работает, добро пожаловать под кат.


Prometheus + Bash + Consul


Встречайте: Prometheus


Мое знакомство с Prometheus произошло, когда возникла необходимость собирать метрики с кластера Kubernetes. Пробежавшись по материалам в интернете стало понятно, что работать с Prometheus и его pull-моделью, очень удобно, когда он самостоятельно узнаёт о сервисах, с которых необходимо собирать метрики. Для настройки Prometheus под Kubernetes в конфигурационном файле prometheus.yml есть директива kubernetes_sd_configs. Она отвечает за коммуникацию с kube-apiserver с целью получения IP-адресов и метаинформации о pod'ах в кластере с которых нужно собирать метрики.


scrape_configs:
- job_name: kubernetes-pods
  kubernetes_sd_configs:
  - role: pod
  bearer_token_file: /var/run/secrets/kubernetes.io/serviceaccount/token
  tls_config:
    insecure_skip_verify: true
  relabel_configs:
  - source_labels: [__meta_kubernetes_pod_annotation_prometheus_io_scrape]
    action: keep
    regex: true
  - source_labels: [__meta_kubernetes_pod_ip, __meta_kubernetes_pod_annotation_prometheus_io_port]
    action: replace
    regex: (.+);(.+)
    replacement: $1:$2
    target_label: __address__
  - source_labels: [__meta_kubernetes_pod_annotation_prometheus_io_path]
    action: replace
    regex: (.+)
    target_label: __metrics_path__
  - action: labelmap
    regex: __meta_(kubernetes_namespace|kubernetes_pod_name|kubernetes_pod_label_manifest_sha1|kubernetes_pod_node_name)

С первого взгляда картинка не сильно понятная, но с документацией и в несколько экспериментов разобраться можно. Помимо role=pod есть и другие роли у kubernetes_sd_configs.


Наблюдая, как эффективно Prometheus узнает о сервисах в Kubernetes, например, о DaemonSet prometheus/node_exporter, сразу появилось желание реализовать аналогичный подход для сбора метрик и мониторинга доступности сервисов вне кластера Kubernetes: node_exporter, Zookeeper, Kafka, ClickHouse, CEPH, Elasticsearch, Tarantool ...


Писать каждый раз targets в static_configs при добавлении нового сервера в текущий кластер Kafka или OSD в CEPH ну совсем не про удобное управление инфраструктурой. А еще это нужно не забывать делать. Да, можно все автоматизировать, например через Ansible, но что делать, если на некоторое время отключить один CEPH OSD для обслуживания. Тогда нужно еще и автоматизировать временное выведение этого сервиса из конфига prometheus.yml. В итоге получается огромная куча слоев автоматизации, которые тоже нужно не забывать запускать.


Именно слоев, потому что одна такая автоматизация порождает необходимость в другой. Так еще ко всему этому сильно разрастается prometheus.yml, в котором нужно не просто перечислять сервисы, а еще делить их на разные job_name для удобного доступа к метрикам. Тут легко прикинуть сколько строк в prometheus.yml нам создаст один кластер Kafka из 6 брокеров. А если кластеров 3. И добавим к ним еще 3 кластера ClickHouse, в каждом по 4 ноды. А уж про CEPH и вообще говорить страшно. А еще на каждом сервере не по одному сервису, с которого нужно собирать метрики — про prometheus/node_exporter не надо забывать. Представив всю свою инфраструктуру в количественной мере можно прикинуть и размер prometheus.yml при использовании только static_configs.


Don’t specify default values unnecessarily: simple, minimal configuration will make errors less likely.

Kubernetes Documentation, Configuration Best Practices, General Configuration Tips.


*Админы почему-то очень любят хранить в конфигах строки по-умолчанию в виде комментариев, создавая из файла конфигурации настоящую свалку. Не нужно этого делать!


В файле конфигурации должны быть только важные строки, отвечающие за работу приложения. Файл, который шел в инсталляционном пакете, можно оставить рядом, добавив ему постфикс .default или .original. А можно вообще ничего не хранить рядом и в файл конфигурации, в шапке, положить 2-3 полезные ссылки на описание этого файла или его содержимое по-умолчанию во внешнем источнике: документация, GitHub репозиторий проекта. Совсем не сложно сделать файл конфигурации удобным и читаемым для коллег и себя самого, в будущем.*


Вспомним про HashiCorp Consul


Нужна была максимально простая и понятная автоматизация управления сервисами в prometheus.yml. Самое знакомое что сразу бросалось в глаза из документации Prometheus — consul_sd_configs. То есть, Prometheus, посредством HashiCorp Consul, может узнавать о сервисах с которых нужно собирать метрики и заодно мониторить их доступность. Например:


scrape_configs:
- job_name: SERVICE_NAME
  consul_sd_configs:
  - server: consul.example.com
    scheme: https
    tags: [test] # dev|test|stage|prod|...
    services: [prometheus-SERVICE_NAME-exporter]

Будучи уже знакомым с Consul, я знал, что сам он не узнает о сервисах даже на тех машинах, на которых запущен его agent. Через его простое HTTP API сервис нужно зарегистрировать в Consul, а еще его можно разрегестрировать. Ведь бывает, что сервис больше никому не нужен: попробовали, и не подошел. В полном объеме я никогда не использовал Consul. Возникали только задачи, с которыми Consul отлично справлялся частью своих функций: KV-хранилище, HashiCorp Vault, кластеризация Traefik. И, например, никогда не нужен был его DNS. Вот и сейчас задача стояла не усложнить себе жизнь, запуская на каждом сервере по дополнительном сервису в виде Consul agent. Достаточно запустить один инстанс Consul, который будет принимать запросы в HTTP API и к нему будет обращаться Prometheus за списком адресов, по которым расположены те или иное сервисы. Идеально, если это все будет по HTTPS, хотя сеть и так закрытая.


На такой волне стало понятно, что уже имеющийся в Kubernetes StatefulSet Consul, обеспечивающий кластерную работу Traefik, можно использовать для service discovery в Prometheus. И даже Ingress в локальную сеть у него уже был, так как было пару моментов использования Consul’s web UI. Бонусом Traefik обеспечивал ему HTTPS-транспорт с сертификатом от Let`s Encrypt через DNS Challenge.


consul.yml
# https://consul.io/docs/agent/options.html

---
apiVersion: v1
kind: Service
metadata:
  name: consul
  labels:
    app: consul
spec:
  selector:
    app: consul
  ports:
  - name: http
    port: 8500

---
apiVersion: apps/v1
kind: StatefulSet
metadata:
  name: consul
  labels:
    app: consul
spec:
  serviceName: consul
  selector:
    matchLabels:
      app: consul
  volumeClaimTemplates:
  - metadata:
      name: data
    spec:
      storageClassName: cephfs
      accessModes:
      - ReadWriteOnce
      resources:
        requests:
          storage: 1Gi
  template:
    metadata:
      labels:
        app: consul
    spec:
      automountServiceAccountToken: false
      terminationGracePeriodSeconds: 60
      containers:
      - name: consul
        image: consul:1.6
        volumeMounts:
        - name: data
          mountPath: /consul/data
        args:
        - agent
        - -server
        - -client=0.0.0.0
        - -bind=127.0.0.1
        - -bootstrap
        - -bootstrap-expect=1
        - -disable-host-node-id
        - -dns-port=0
        - -ui
        ports:
        - name: http
          containerPort: 8500
        readinessProbe:
          initialDelaySeconds: 10
          httpGet:
            port: http
            path: /v1/agent/members
        livenessProbe:
          initialDelaySeconds: 30
          httpGet:
            port: http
            path: /v1/agent/members
        resources:
          requests:
            cpu: 0.2
            memory: 256Mi

---
apiVersion: extensions/v1beta1
kind: Ingress
metadata:
  name: consul
  labels:
    app: consul
  annotations:
    traefik.ingress.kubernetes.io/frontend-entry-points: http,https
    traefik.ingress.kubernetes.io/redirect-entry-point: https
spec:
  rules:
  - host: consul.example.com
    http:
      paths:
      - backend:
          serviceName: consul
          servicePort: http

На этом этапе у меня имелся Prometheus, который готов забрать из Consul адреса сервисов и начать собирать с них метрики, и инстанс Consul, в котором можно регистрировать сервисы. Возникает задача автоматизировать децентрализованную регистрацию сервисов во время запуска на любом сервере инфраструктуры. И на помощь приходит простой Bash.


Время для Bash и systemd.service


В своей инфраструктуре я давно использую иммутабельную CoreOS Container Linux. Было даже дело рассказать об этой ОС на DevOpsConf Russia 2018. Я и по сей день использую эту ОС как основную для запуска сервисов в эфемерных Docker контейнерах, оборачивая в systemd.service. Но только теперь это Flatcar Linux, разработчики которой подхватили идею и не дали ей погибнуть, обеспечив полную совместимость с CoreOS Container Linux. А перейти с CoreOS на Flatcar можно простым обновлением системы!


Все сервисы на моём сервере — это systemd.service. А systemd — это очень мощный инструмент, который активно развивается и решает очень много задач в Linux. И есть в systemd.service, в секции [Service], такие параметры как ExecStartPost, ExecStop. Они-то и помогут мне с регистрацией и разрегистрацией сервиса в Consul.


Опишу сразу на примере юнита prometheus-node-exporter.service. Этот сервис примечателен тем, что запускается абсолютно на каждом сервере и ему одному в static_configs можно было бы выделить больше 100 строк.


[Unit]
After=docker.service
[Service]
Environment=CONSUL_URL=https://consul.example.com
ExecStartPre=-/usr/bin/docker rm --force %N
ExecStart=/usr/bin/docker run     --name=%N     --rm=true     --network=host     --pid=host     --volume=/:/rootfs:ro     --label=logger=json     --stop-timeout=30     prom/node-exporter:v0.18.1     --log.format=logger:stdout?json=true     --log.level=error
ExecStartPost=/opt/bin/consul-service register -e prod -n %N -p 9100 -t prometheus,node-exporter
ExecStop=/opt/bin/consul-service deregister -e prod -n %N
ExecStop=-/usr/bin/docker stop %N
Restart=always
StartLimitInterval=0
RestartSec=10
KillMode=process
[Install]
WantedBy=multi-user.target

Как раз вот тут появляется некий /opt/bin/consul-service. У него всего пара переменных системного окружения и несколько обязательных аргументов, которые я опишу поподробней.


Переменные окружения:


  • CONSUL_URL — адрес Consul в котором будут регистрироваться сервисы во время запуска.
  • CONSUL_TOKEN — он же HTTP-заголовок «X-Consul-Token», позволяющий ограничить доступ в HTTP API Consul. Его можно сформировать в Consul web UI, а в последних версиях на него еще навесили ACLs.

Обязательные аргументы:


  • register/deregister — всегда первый аргумент. Отвечает за регистрацию сервиса и его разрегистрацию.
  • -e — окружение в котором запускается сервис — [dev|test|prod|…].
  • -n — название сервиса, например prometheus-node-exporter. В примере выше используется переменная %N, которая в systemd.unit присваивает значение из названия самого юнита.
  • -p — порт на котором сервис будет отдавать в Prometheus метрики. Используется только во время регистрации сервиса в Consul.
  • -t — произвольные теги через запятую. С их помощью можно иначе фильтровать выборки сервисов в Consul. Используется только во время регистрации сервиса в Consul.

Использование consul-service в systemd.service оказалось очень практичным и преподнесло один очень приятный бонус. systemd.service во время старта, а именно ExecStartPost с consul-service register, регистрирует сервис в Consul, и Prometheus сразу же может забирать с него метрики, а также мониторить его доступность. Но самое удобное, когда нужно ненадолго оставить сервис или перезагрузить сервер. В этот момент выполняются все ExecStop, в том числе consul-service deregister, и сервис разрегистрируется. Consul и следом Prometheus о нем забывают, и тогда нет никакого лишнего оповещения о недоступности намеренно остановленного сервиса. Особенно это практично, когда работы проводятся в ночное время одним инженером, а другой в этот момент в ответе за Service Availability и получает пугающие его оповещения о недоступности сервиса. Но если сервис упал, и никто специально для проведения работ его не останавливал, и разрегистрации сервиса не было, то он будет под мониторингом со всеми вытекающими.


Очень важным моментом в работе consul-service является правильно установленные hostname сервера и search (домены поиска) в resolv.conf. Этот же адрес должен присутствовать в локальном DNS-сервере, что как минимум best practice при работе с серверами в локальной среде.


Относитесь к своим серверам так, как хотите, чтобы относились к вам!

Если вы до сих пор работаете со своими серверами в локальной сети по IP, то самое время присвоить себе тоже какой-нибудь идентификатор и обязать коллег обращаться к вам только по нему. И больше ни на какие обращения не реагировать. Так будет справедливо и быстро осознается ценность имени.


consul-service написан на чистом Bash в ~60 строчек. В зависимостях у него только хорошо известный всем cURL и Bash. Уверен, что админ без опыта разработки бегло разберется в нескольких строчках этого скрипта. Лицензия этого макро-решения MIT, поэтому если появится необходимость доработать его под свои нужды, не стесняйтесь.


Тот самый scrape_configs в prometheus.yml для prometheus/node_exporter, запущенном на всех серверах, лаконичен и не зависит никак от количества серверов.


scrape_configs:
- job_name: node-exporter
  consul_sd_configs:
  - server: consul.example.com
    scheme: https
    tags: [prod]
    services: [prometheus-node-exporter]

tags: [prod] тут отвечает за выборку инстансов только production среды. Это как раз тот самый аргумент -e в consul-service, который по факту превращается в один из тегов сервиса при попадании в Consul.


На мои сервера с Flatcar Linux доставка скрипта в /opt/bin осуществляется посредством Ansible. Playbook всего на пару tasks. Но это реализуется и любым другим инструментом, хоть строчкой в crontab.


tasks:
- name: Create directory "/opt/bin"
  with_items: [/opt/bin]
  file:
    state: directory
    path: "{{ item }}"
    owner: root
    group: root
    mode: 0755
- name: Download "consul-service.sh" to "/opt/bin/consul-service"
  get_url:
    url: https://raw.githubusercontent.com/devinotelecom/consul-service/master/consul-service.sh
    dest: /opt/bin/consul-service
    owner: root
    group: root
    mode: 0755
    force: yes

О том как Ansible, а точнее Python, попадает и работает на серверах Flatcar Linux думаю, можно рассказать в будущих статьях. В планах посвятить целый цикл статей про иммутабельную ОС Flatcar Linux.


В заключение


Наверное, можно считать всю эту конструкцию неким велосипедом. Но в эксплуатации и передачи коллегам такого велосипеда на поддержку стало сразу ясно, что он простой как «рама+колеса+педали+руль», и ездить на нем удобно. И почти в обслуживании не нуждается.
А лучшей похвалой этой статье станет мысль читающего — “Интересное решение! Надо попробовать!”.