ELK und Service Discovery mit Consul

Dieser Artikel wurde im Februar 2020 geschrieben


Da Elasticsearch und der ELK-Stack sowie Service Discovery  jetzt mehrfach meinen Weg gekreuzt haben, wollte ich mir das Thema nochmal genauer ansehen. Und da die PlusCloud noch nicht soweit ist, habe ich mir ein Beispiel aus dem Triton-Umfeld ausgesucht.

Was ist das Problem?

  • Generell: Wie kann man (dynamisch) Informationen zwischen Diensten übermitteln, damit diese verknüpft werden können? 
  • Am Beispiel ELK: Wie kann Kibana davon erfahren, welche IP-Adresse der Elastisearch-Server hat? Wie kann Logstash davon erfahren? Wie können die IP-Adressen dynamisch angepasst werden, wenn sich die Adresse des Elasticsearch-Servers ändert?

Dasselbe Problem stellt sich natürlich auch bei anderen Applikationen, die aus mehreren Komponenten bestehen, welche z. B. dynamisch skalieren oder bei einem Re-Deployment ggfs. automatisch eine neue IP-Adresse erhalten.

Für Triton habe ich ein Beispiel gefunden, in dem Consul für das Service Discovery verwendet wird:

Das Setup ist dabei unabhängig von Triton und kann überall per Docker ausgerollt werden. Dabei wird folgende docker-compose.yml verwendet: docker-compose.yml

Nach dem Deployment mit docker-compose sieht der Stack zunächst so aus:

root@e3b75fc5-3621-cdd8-f9dd-c9acbf4a37e9:~/autopilotpattern/elk# triton-compose -p elk ps
           Name                         Command               State                                                     Ports                                                  
-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
elk_consul_1                 /bin/start -server -bootst ...   Up      53/tcp, 53/udp, 8300/tcp, 8301/tcp, 8301/udp, 8302/tcp, 8302/udp, 8400/tcp, 0.0.0.0:8500->8500/tcp
elk_elasticsearch_1          /usr/local/bin/containerpi ...   Up      9200/tcp, 9300/tcp
elk_elasticsearch_data_1     /usr/local/bin/containerpi ...   Up      0.0.0.0:9200->9200/tcp, 0.0.0.0:9300->9300/tcp
elk_elasticsearch_master_1   /usr/local/bin/containerpi ...   Up      0.0.0.0:9200->9200/tcp, 0.0.0.0:9300->9300/tcp
elk_kibana_1                 /usr/local/bin/containerpi ...   Up      0.0.0.0:5601->5601/tcp
elk_logstash_1               /usr/local/bin/containerpi ...   Up      0.0.0.0:12201->12201/tcp, 0.0.0.0:12201->12201/udp, 24224/tcp, 0.0.0.0:514->514/tcp, 0.0.0.0:514->514/udp

Bei Consul haben sich folgende Dienste registriert:

Elasticsearch müsste sich so sehen:

root@e3b75fc5-3621-cdd8-f9dd-c9acbf4a37e9:~/autopilotpattern/elk# curl -s http://10.65.69.139:9200/_cluster/health | jq
{
  "cluster_name": "elasticsearch",
  "status": "green",
  "timed_out": false,
  "number_of_nodes": 3,
  "number_of_data_nodes": 2,
  "active_primary_shards": 5,
  "active_shards": 10,
  "relocating_shards": 0,
  "initializing_shards": 0,
  "unassigned_shards": 0,
  "delayed_unassigned_shards": 0,
  "number_of_pending_tasks": 0,
  "number_of_in_flight_fetch": 0,
  "task_max_waiting_in_queue_millis": 0,
  "active_shards_percent_as_number": 100
}

Das sollte sich ändern, wenn wir Nodes nachschieben:

root@e3b75fc5-3621-cdd8-f9dd-c9acbf4a37e9:~/autopilotpattern/elk# triton-compose -p elk scale elasticsearch_master=3
Creating and starting elk_elasticsearch_master_2 ... done
Creating and starting elk_elasticsearch_master_3 ... done
root@e3b75fc5-3621-cdd8-f9dd-c9acbf4a37e9:~/autopilotpattern/elk# triton-compose -p elk scale elasticsearch_data=3
Creating and starting elk_elasticsearch_data_2 ... done
Creating and starting elk_elasticsearch_data_3 ... done

Danach sieht Elasticsearch sich so:

root@e3b75fc5-3621-cdd8-f9dd-c9acbf4a37e9:~/autopilotpattern/elk# curl -s http://10.65.69.139:9200/_cluster/health | jq
{
  "cluster_name": "elasticsearch",
  "status": "green",
  "timed_out": false,
  "number_of_nodes": 7,
  "number_of_data_nodes": 4,
  "active_primary_shards": 5,
  "active_shards": 10,
  "relocating_shards": 0,
  "initializing_shards": 0,
  "unassigned_shards": 0,
  "delayed_unassigned_shards": 0,
  "number_of_pending_tasks": 0,
  "number_of_in_flight_fetch": 0,
  "task_max_waiting_in_queue_millis": 0,
  "active_shards_percent_as_number": 100
}

Dann brauchen wir noch eine Quelle, die über Logstash Logdaten ins Elasticsearch spült:

root@e3b75fc5-3621-cdd8-f9dd-c9acbf4a37e9:~/autopilotpattern/elk# triton-docker run -d -m 128m -p 80 --name elk_nginx_syslog_1 --label "triton.cns.services=nginx-syslog" --restart always --log-driver syslog --log-opt syslog-address=tcp://10.65.69.142 --link elk_consul_1:consul -e CONSUL=consul -e BACKEND=kibana autopilotpattern/elk-nginx-demo:latest
8c562e733851ca6cc6b6b21317f9feba6b1fd48e5a9aea99b1ada1ab1a65e8c0

Und im Kibana kann man dann auch Logs sehen:

Damit ist das geplante Setup aufgebaut. Wie funktioniert es?

Consul

Consul ist ein key/value-Store ähnlich etcd, der von Hashicorp inzwischen mit weiteren Funktionen (z. B. Connect) ausgestattet worden ist. Im Rahmen des Aufkommens von Service-Meshes wird er inzwischen als "service networking solution" beschrieben, was eigentlich sehr gut paßt. Die Architektur kann man dem folgenden Diagramm entnehmen:

Für den Produktionseinsatz sollte Consul als Cluster (aus mindestens drei Nodes) sinnvollerweise in verschiedenen availability zones (AZ) aufgebaut werden. In unserem Setup, wird die Registrierung eines Dienstes in Consul durch ein kleines Hilfsprogramm (heute würde man vermutlich Sidecar sagen) durchgeführt, welches wir in jedem Container als erstes Starten (und welches dann die eigentliche Applikation im Container startet): Containerpilot. Die Konfigurationsdatei für Containerpilot (z. B. im Fall der Elasticsearch-Container) sieht so aus:

{
  "consul": "{{ .CONSUL }}:8500",
  "preStart": "/usr/local/bin/manage.sh preStart",
  "services": [
    {
      "name": "{{ .ES_SERVICE_NAME }}",
      "port": 9300,
      "health": "/usr/local/bin/manage.sh health",
      "poll": 10,
      "ttl": 25
    }
  ]
}

Wenn Containerpilot mit dieser Konfiguration startet, registriert er den Container mit dem Namen aus der docker-compose Datei und der ID des Containers in Consul:

root@e3b75fc5-3621-cdd8-f9dd-c9acbf4a37e9:~/autopilotpattern/elasticsearch# curl -Ls --fail http://10.65.69.146:8500/v1/catalog/service/elasticsearch-master | jq '.'
[
  {
    "Node": "d23389673e46",
    "Address": "10.65.69.146",
    "ServiceID": "elasticsearch-master-7aa7624c823e",
    "ServiceName": "elasticsearch-master",
    "ServiceTags": null,
    "ServiceAddress": "10.65.69.140",
    "ServicePort": 9300
  },
  {
    "Node": "d23389673e46",
    "Address": "10.65.69.146",
    "ServiceID": "elasticsearch-master-a8442af13984",
    "ServiceName": "elasticsearch-master",
    "ServiceTags": null,
    "ServiceAddress": "10.65.69.139",
    "ServicePort": 9300
  },
  {
    "Node": "d23389673e46",
    "Address": "10.65.69.146",
    "ServiceID": "elasticsearch-master-e004d9a4aece",
    "ServiceName": "elasticsearch-master",
    "ServiceTags": null,
    "ServiceAddress": "10.65.69.144",
    "ServicePort": 9300
  },
  {
    "Node": "d23389673e46",
    "Address": "10.65.69.146",
    "ServiceID": "elasticsearch-master-d37b507e7b0e",
    "ServiceName": "elasticsearch-master",
    "ServiceTags": null,
    "ServiceAddress": "10.65.69.156",
    "ServicePort": 9300
  }
]

Für Applikationen wie Kibana, die in ihrer Konfigurationsdatei die IP-Adresse eines Eleasticsearch-Masters benötigen, wird in der Containerpilot-Konfiguration ein "backend" definiert (in diesem Fall Elasticsearch):

{
  "consul": "{{ .CONSUL }}:8500",
  "onStart": ["/usr/local/bin/manage.sh", "onStart"],
  "services": [
    {
      "name": "kibana",
      "port": 5601,
      "health": ["/usr/local/bin/manage.sh", "health"],
      "poll": 10,
      "ttl": 25
    }
  ],
  "backends": [
    {
      "name": "elasticsearch",
      "poll": 5,
      "onChange": ["/usr/local/bin/manage.sh", "reload"]
    }
  ]
}

In der zugehörigen manage.sh wird bei einer Änderung im Backend, die geänderte IP-Adresse des Elasticsearch-Masters eingetragen: manage.sh

Consul-Template

Um längere und komplexere Konfigurationsdateien mit Werten aus Consul auszufüllen, gib es Consul-Template. Dabei liest Consul-Template beim Start eine Template-Datei oder mehrere Template-Dateien ein und fragt bei Consul die Werte an, um sie auszufüllen. Normalerweise läßt man Consul-Template als Daemon laufen, der beim Start die initialen Werte einsetzt und danach auf Updates in Consul wartet, um damit ggfs. die Templates erneut auszufüllen, wenn sie eine Änderung ergeben hat. Zusätzlich kann Consul-Template nach der Änderung weitere Programme aufrufen, um z. B. einen Prozess dazu zu bringen, seine Konfigurationsdatei(en) neu zu laden.

Im vorliegenden Setup wird Consul-Template jedoch anders verwendet. Die Konfigurationsdatei von Containerpilot sieht im nginx-Container so aus:

{
  "consul": "{{ .CONSUL }}:8500",
  "preStart": "/usr/local/bin/reload.sh preStart",
  "services": [
    {
      "name": "nginx",
      "port": 80,
      "publicIp": true,
      "health": "/usr/bin/curl --fail -s http://localhost/health",
      "poll": 10,
      "ttl": 25
    }
  ],
  "backends": [
    {
      "name": "{{ .BACKEND }}",
      "poll": 7,
      "onChange": "/usr/local/bin/reload.sh"
    }
  ],
  "telemetry": {
    "port": 9090,
    "sensors": [
      {
        "name": "tb_nginx_connections_unhandled_total",
        "help": "Number of accepted connnections that were not handled",
        "type": "gauge",
        "poll": 5,
        "check": ["/usr/local/bin/sensor.sh", "unhandled"]
      },
      {
        "name": "tb_nginx_connections_load",
        "help": "Ratio of active connections (less waiting) to the maximum worker connections",
        "type": "gauge",
        "poll": 5,
        "check": ["/usr/local/bin/sensor.sh", "connections_load"]
      }
    ]
  }
}

Consul-Template wird in der reload.sh aufgerufen:

#!/bin/bash

SERVICE_NAME=${SERVICE_NAME:-nginx}
CONSUL=${CONSUL:-consul}

# Render Nginx configuration template using values from Consul,
# but do not reload because Nginx has't started yet
preStart() {
    consul-template \
        -once \
        -dedup \
        -consul ${CONSUL}:8500 \
        -template "/etc/nginx/nginx.conf.ctmpl:/etc/nginx/nginx.conf"
}

# Render Nginx configuration template using values from Consul,
# then gracefully reload Nginx
onChange() {
    consul-template \
        -once \
        -dedup \
        -consul ${CONSUL}:8500 \
        -template    "/etc/nginx/nginx.conf.ctmpl:/etc/nginx/nginx.conf:nginx -s reload"
}

help() {
    echo "Usage: ./reload.sh preStart  => first-run configuration for Nginx"
    echo "       ./reload.sh onChange  => [default] update Nginx config on upstream changes"
}

until
    cmd=$1
    if [ -z "$cmd" ]; then
        onChange
    fi
    shift 1
    $cmd "$@"
    [ "$?" -ne 127 ]
do
    onChange
    exit
done

und erzeugt dabei entweder nur eine nginx.conf oder erzeugt eine nginx.conf und lässt sie vom nginx neu einlesen, sofern dieser vorher schon gelaufen ist und es Änderungen gegeben hat. Das Beispiel zeigt auch noch eine weitere Funktion von Containerpilot: Telemetry. In diesem Fall werden sehr einfach mit Hilfe der sensor.sh:

#!/bin/bash
set -e

help() {
    echo 'Make requests to the Nginx stub_status endpoint and pull out metrics'
    echo 'for the telemetry service. Refer to the Nginx docs for details:'
echo 'http://nginx.org/en/docs/http/ngx_http_stub_status_module.html'
}

# Cummulative number of dropped connections
unhandled() {
    local accepts=$(curl -s --fail localhost/health | awk 'FNR == 3 {print $1}')
    local handled=$(curl -s --fail localhost/health | awk 'FNR == 3 {print $2}')
    echo $(expr ${accepts} - ${handled})
}

# ratio of connections-in-use to available workers
connections_load() {
    local scraped=$(curl -s --fail localhost/health)
    local active=$(echo ${scraped} | awk '/Active connections/{print $3}')
    local waiting=$(echo ${scraped} | awk '/Reading/{print $6}')
    local workers=$(echo $(cat /etc/nginx/nginx.conf | perl -n -e'/worker_connections *(\d+)/ && print $1')
)
    echo $(echo "scale=4; (${active} - ${waiting}) / ${workers}" | bc)
}

# -------------------------------------------------------
# Un-scraped metrics; these raw metrics are available but we're not going
# to include them in the telemetry configuration. They have been left here
# as an example.

# The current number of active client connections including Waiting connections.
connections_active() {
    curl -s localhost/health | awk '/Active connections/{print $3}'
}

# The current number of connections where nginx is reading the request header.
connections_reading() {
    curl -s localhost/health | awk '/Reading/{print $2}'
}

# The current number of connections where nginx is writing the response back
# to the client.
 connections_writing() {
    curl -s localhost/health | awk '/Reading/{print $4}'
}

# The current number of idle client connections waiting for a request.
connections_waiting() {
    curl -s localhost/health | awk '/Reading/{print $6}'
}

# The total number of accepted client connections.
accepts() {
    curl -s localhost/health | awk 'FNR == 3 {print $1}'
}

# The total number of handled connections. Generally, the parameter value is the
# same as accepts unless some resource limits have been reached (for example, the
# worker_connections limit).
handled() {
    curl -s localhost/health | awk 'FNR == 3 {print $2}'
}

# The total number of client requests.
requests() {
    curl -s localhost/health | awk 'FNR == 3 {print $3}'
}

# -------------------------------------------------------

cmd=$1
if [ ! -z "$cmd" ]; then
    shift 1
    $cmd "$@"
    exit
fi

help

Metriken generiert, die sofort von einer Prometheus-Instanz konsumiert werden könnten. Neben Consul-Template gibt es noch eine ganze Reihe weiterer Tools und Applikationen, die bei der Benutzung von Consul und seiner Nutzung mit anderen Applikationen helfen. Sogar ein Loadbalancer wie fabio.

Triton-Footprint

Zum Schluß noch kurz einen Blick auf den Footprint, den das Setup in einer Triton-Umgebung hat. Wenn das Setup per Docker-API ausgerollt wird, werden keine VMs sondern sogenannte "lx-branded" Zones (SmartOS-Zonen, in denen Linux-Binaries per Syscall-Mapping laufen) gestartet. Die 12 Zonen belegen dann etwa 3,2 GB Memory:

[root@hh24-gts2-de30 (de-gt-2) ~]# zonememstat -a
                             ZONE                      ALIAS  RSS(MB) CAP(MB)    NOVER  POUT(MB) SWAP%
                           global                          -     1065 16777215        0         0     -
 d2338967-3e46-423a-df63-f76831c43d0e               elk_consul_1       14    128        0         0  0.94
 bbd44e1e-b4fa-ef55-f756-a738c99f3dba             elk_logstash_1      242   1024        0         0  5.28
 d8d0a123-a2d5-cc87-a5d4-ef2b350b29a8               elk_kibana_1      265   1024        0         0  6.04
 a8442af1-3984-c35a-9748-f155929216db        elk_elasticsearch_1      416   4096        0         0  2.37
 7aa7624c-823e-4a13-8340-e70997731893 elk_elasticsearch_master_1      375   4096        0         0  2.12
 ec913608-e2b8-4b51-8a79-c7df42680160   elk_elasticsearch_data_1      447   4096        0         0  2.55
 5eb09ae6-7362-ef3f-b5bc-be5eb7126a92   elk_elasticsearch_data_3      372   4096        0         0  2.10
 b40414bc-f26b-45a3-a5dd-e6f1d99c4086   elk_elasticsearch_data_2      447   4096        0         0  2.57
 e004d9a4-aece-cc24-cdea-aa436657e045 elk_elasticsearch_master_3      287   4096        0         0  1.60
 d37b507e-7b0e-67ab-e579-9dcc6608c418 elk_elasticsearch_master_2      287   4096        0         0  1.59
 8c562e73-3851-ca6c-c6b6-b21317f9feba         elk_nginx_syslog_1       21    128        0         0  2.69
 d6c48b73-7575-48a4-ff6c-f6cf0752de2f         elk_nginx_syslog_2       18    128        0         0  2.31

Links:


Implementing the Autopilot Pattern

Consul Tools