True IT Stories

App Volumes hinter HAProxy: die Konfiguration, die läuft

Zwei App Volumes Manager, ein Name, eine IP. Mehr wollte der Kunde nicht. Fällt ein Manager aus, soll der andere übernehmen, und die Agents auf den virtuellen Desktops sollen davon nichts merken.

Omnissa (vormals VMware) beschreibt dafür einen Load Balancer vor den Managern. Beim Kunden stand kein NetScaler zur Verfügung, also haben wir zwei kleine Ubuntu-VMs mit HAProxy und keepalived davorgestellt. Das ist schnell gebaut. Bis es wirklich lief, hat es trotzdem drei Anläufe gebraucht. Hier steht die Konfiguration, die am Ende funktioniert, und die Stellen, an denen sie es vorher nicht tat.

Der Aufbau

  • Zwei Manager, im Beispiel avm01 (10.0.0.38) und avm02 (10.0.0.39). Beide sprechen HTTPS auf Port 443.
  • Zwei Load-Balancer-Knoten, lb01 (10.0.0.41) und lb02 (10.0.0.40), Ubuntu 24.04 mit HAProxy 2.8 und keepalived.
  • Eine virtuelle IP, 10.0.0.22, auf die der DNS-Name appvolumes.example.local zeigt. Sie liegt immer auf genau einem der beiden Knoten.

HAProxy terminiert TLS nicht. Er reicht die Verbindung durch (TLS-Passthrough), das Zertifikat liegt auf den Managern. Das hat zwei Gründe: Der Load Balancer braucht dann keinen privaten Schlüssel, und die Agents sehen genau das Zertifikat, das sie sonst auch sähen.

haproxy.cfg

global
    log /dev/log local0
    log /dev/log local1 notice
    chroot /var/lib/haproxy
    stats socket /run/haproxy/admin.sock mode 660 level admin
    stats timeout 30s
    user haproxy
    group haproxy
    daemon

defaults
    log     global
    option  dontlognull
    timeout connect 5s
    timeout client  1m
    timeout server  1m

# Statistik nur lokal erreichbar
listen stats
    bind 127.0.0.1:8404
    mode http
    stats enable
    stats uri /stats
    stats refresh 10s

# Port 80 leitet nur auf HTTPS um
frontend fe_avm_http
    bind 10.0.0.22:80
    mode http
    option httplog
    http-request redirect scheme https code 301

# TLS-Passthrough: das Zertifikat liegt auf den Managern
frontend fe_avm_https
    bind 10.0.0.22:443
    mode tcp
    option tcplog
    default_backend be_avm_https

backend be_avm_https
    mode tcp
    balance leastconn
    # Ein Client bleibt beim selben Manager
    stick-table type ip size 1m expire 200m
    stick on src
    # Health-Check über TLS: /health_check liefert nur 200, wenn der Manager gesund ist
    option httpchk
    http-check send meth HEAD uri /health_check
    http-check expect status 200
    timeout check 10s
    server avm01 10.0.0.38:443 check check-ssl verify none inter 30s fastinter 5s rise 5 fall 2
    server avm02 10.0.0.39:443 check check-ssl verify none inter 30s fastinter 5s rise 5 fall 2

Was davon wichtig ist:

  • mode tcp im HTTPS-Frontend und -Backend. Sonst versucht HAProxy, HTTP zu lesen, wo verschlüsselte Daten kommen.
  • stick on src. Ohne Sticky-Sessions springt ein Client zwischen den Managern hin und her. Für die Weboberfläche heißt das: Man wird abgemeldet. Die Persistenz läuft über die Client-IP, weil HAProxy bei Passthrough kein Cookie sehen kann.
  • Der Health-Check. Ein reiner TCP-Check meldet einen Manager als gesund, sobald Port 443 offen ist, auch wenn der Dienst dahinter hängt. /health_check ist der Endpunkt, den Omnissa für App Volumes 4.x empfiehlt. Er antwortet nur mit 200, wenn der Manager arbeitet. check-ssl schickt den Check über TLS, verify none spart uns die CA auf dem Load Balancer, denn hier geht es um Erreichbarkeit, nicht um Vertrauen.
  • rise 5 fall 2. Ein Manager fliegt nach zwei Fehlschlägen raus und kommt erst nach fünf erfolgreichen Checks zurück. Ein Manager, der beim Hochfahren kurz zuckt, bekommt so keinen Verkehr, bevor er wirklich bereit ist.

keepalived.conf

Die virtuelle IP wandert per VRRP zwischen den beiden Knoten. Wir nutzen Unicast statt Multicast, weil Multicast in virtualisierten Netzen gern irgendwo verloren geht.

global_defs {
    router_id lb01
    script_user root
    enable_script_security
}

# Die VIP nur auf einem Knoten halten, auf dem HAProxy läuft
vrrp_script chk_haproxy {
    script "/usr/bin/systemctl is-active --quiet haproxy"
    interval 2
    fall 2
    rise 2
}

vrrp_instance VI_HAPROXY {
    state BACKUP
    interface ens192
    virtual_router_id 22
    priority 150              # lb02: 100
    advert_int 1
    unicast_src_ip 10.0.0.41  # lb02: 10.0.0.40
    unicast_peer {
        10.0.0.40             # lb02: 10.0.0.41
    }
    authentication {
        auth_type PASS
        auth_pass geheim12    # höchstens 8 Zeichen
    }
    virtual_ipaddress {
        10.0.0.22/24 dev ens192
    }
    track_script {
        chk_haproxy
    }
}

Beide Knoten starten als BACKUP, die höhere Priorität gewinnt. Die virtual_router_id muss im Netzsegment eindeutig sein. Das prüft niemand für einen, und zwei Cluster mit derselben ID streiten sich um die Adresse.

Dazu eine Zeile, ohne die HAProxy auf dem passiven Knoten gar nicht startet: Er soll an die VIP binden, die dort gerade nicht liegt.

# /etc/sysctl.d/60-haproxy.conf
net.ipv4.ip_nonlocal_bind = 1

Erste Bruchstelle: alles grün, und niemand kommt an

Nach dem Ausrollen haben wir geprüft. Die VIP lag auf lb01, auf beiden Knoten waren die Frontends offen, avm01 war im Backend UP. Den Failover haben wir auch getestet: HAProxy auf lb01 gestoppt, die VIP sprang nach lb02, zurück ebenso. Alles grün.

Eine Woche später rief jemand die VIP im Browser auf. Nichts.

Die Statistikseite verriet es, wenn man genau hinsah: Die Frontends hatten seit dem Start null Verbindungen gezählt. HAProxy lauschte korrekt auf 10.0.0.22:443, das zeigte ss -tlnp. Die Pakete kamen nur nie bei ihm an.

$ sudo ufw status verbose
Status: active
Default: deny (incoming), allow (outgoing), disabled (routed)

To                         Action      From
22/tcp (OpenSSH)           ALLOW IN    Anywhere

Die Grundhärtung unserer Linux-Server schaltet ufw ein, Policy deny, nur SSH offen. Das ist richtig so. Nur hatte niemand daran gedacht, dass ein Load Balancer Ports braucht. Unser Test hatte das nicht gezeigt, weil er ausschließlich auf den Knoten lief: ip a, die Statistik über 127.0.0.1, der Failover. Von außen hatte niemand gefragt.

Die Regeln, die fehlten:

sudo ufw allow 80/tcp comment 'HAProxy HTTP'
sudo ufw allow 443/tcp comment 'HAProxy HTTPS'
# VRRP vom jeweils anderen Knoten (IP-Protokoll 112)
sudo ufw allow from 10.0.0.40 comment 'keepalived Partner'   # auf lb01
sudo ufw allow from 10.0.0.41 comment 'keepalived Partner'   # auf lb02

Die letzten beiden sind die gefährlicheren. Blockt die Firewall VRRP, hört keiner der beiden Knoten den anderen. Jeder hält sich dann für den einzigen und nimmt die VIP. Zwei Rechner mit derselben IP im selben Netz: Welcher antwortet, hängt davon ab, wessen ARP-Antwort der Switch zuletzt gesehen hat.

Eigentlich würde man nur VRRP freigeben. ufw kann das auch (proto vrrp), das Ansible-Modul community.general.ufw in der Version, die wir einsetzen, kennt den Wert aber nicht. Wir erlauben deshalb alles vom Partnerknoten. Die beiden vertrauen sich ohnehin.

Seitdem gehört zum Test ein Aufruf von einem anderen Rechner:

curl -sk -o /dev/null -w '%{http_code}\n' https://10.0.0.22/health_check

Kommt 200 zurück und steigt der Zähler im Frontend, kommt der Verkehr wirklich durch.

Zweite Bruchstelle: das Zertifikat liegt im falschen Speicher

Über die VIP kam jetzt etwas an, aber mit dem falschen Zertifikat. Die Manager lieferten ihr selbstsigniertes Standardzertifikat aus, ausgestellt auf <HOSTNAME>.WORKGROUP. Zum DNS-Namen der VIP passte das nicht.

Ein Zertifikat der internen CA hatten wir längst: Common Name appvolumes.example.local, im SAN zusätzlich beide Manager-Hostnamen, damit es auch beim direkten Zugriff passt. Es lag im Windows-Zertifikatsspeicher des Managers. Und genau dort nutzt es nichts.

Der App Volumes Manager liefert HTTPS nicht über IIS oder den Windows-Speicher aus, sondern über ein mitgeliefertes nginx. Und nginx liest PEM-Dateien aus seinem eigenen Verzeichnis:

C:\Program Files (x86)\CloudVolumes\Manager\nginx\conf\nginx.conf

        ssl_certificate      app_volumes_self.crt;
        ssl_certificate_key  app_volumes_self.key;

Ältere Anleitungen nennen hier https.crt und https.key. In der Version, die wir vor uns hatten, heißen die Dateien anders. Erst nachsehen, dann tauschen.

Aus dem exportierten PFX werden zwei PEM-Dateien, am einfachsten auf einem Linux-Rechner:

openssl pkcs12 -in avm.pfx -clcerts -nokeys | sed -n '/BEGIN/,/END/p' >  appvolumes.crt
openssl pkcs12 -in avm.pfx -cacerts -nokeys | sed -n '/BEGIN/,/END/p' >> appvolumes.crt
openssl pkcs12 -in avm.pfx -nocerts -nodes  | sed -n '/BEGIN/,/END/p' >  appvolumes.key

# Passen Schlüssel und Zertifikat zusammen? Beide Werte müssen gleich sein.
openssl x509 -in appvolumes.crt -noout -pubkey | sha256sum
openssl pkey -in appvolumes.key -pubout | sha256sum

In der .crt steht zuerst das Serverzertifikat, danach die CA. Der Schlüssel muss unverschlüsselt sein, nginx fragt beim Start nach keinem Passwort. Die erste Zeile lautet dann -----BEGIN PRIVATE KEY----- und nicht ENCRYPTED.

Beide Dateien kommen neben die vorhandenen. In der nginx.conf werden nur die zwei Zeilen umgestellt, eine Sicherung der alten Datei bleibt liegen. Danach den Dienst „App Volumes Manager“ neu starten.

Die Originaldateien zu überschreiben wäre kürzer gewesen. Wir wollten aber nicht darauf wetten, dass der Manager seine eigenen Dateien nicht irgendwann neu erzeugt. Ein Risiko bleibt trotzdem: Ein Update von App Volumes kann die nginx.conf zurücksetzen. Deshalb steht in der Dokumentation des Kunden, dass nach jedem Update genau diese zwei Zeilen zu prüfen sind.

Und hinterher aufräumen: Das PFX und der unverschlüsselte Schlüssel lagen während der Arbeit in zwei temporären Verzeichnissen. Dort haben sie nichts verloren.

Dritte Bruchstelle: der Test, der zu früh kam

Direkt nach dem Neustart des Dienstes haben wir getestet:

$ echo | openssl s_client -connect 10.0.0.22:443 -servername appvolumes.example.local 2>/dev/null \
    | openssl x509 -noout -subject -issuer
Could not read certificate from <stdin>

Kurz Panik. Dabei war nichts kaputt. nginx war nach dem Neustart noch nicht wieder oben, HAProxy hatte den Manager für ein paar Sekunden aus dem Backend genommen, und die VIP hatte niemanden, an den sie weiterreichen konnte. Ein paar Minuten später:

subject=CN = appvolumes.example.local
issuer=DC = local, DC = example, CN = EXAMPLE-CA

Gekostet hat uns das 2>/dev/null. Es hatte die eigentliche Fehlermeldung verschluckt, und ohne sie sah ein Timing-Problem aus wie ein kaputtes Zertifikat. Beim Diagnostizieren bleibt stderr seitdem stehen. Und neben jeden Test über die VIP gehört ein Blick auf den Zustand der Backends:

curl -s "http://127.0.0.1:8404/stats;csv" | cut -d, -f1,2,18 | grep avm

Was wir jetzt immer prüfen

  1. Von außen testen, nicht nur auf dem Load Balancer. Ein Frontend, das null Verbindungen zählt, bekommt keine Pakete.
  2. Die Firewall der Knoten gehört zur Konfiguration des Load Balancers: 80/443 und VRRP zum Partner.
  3. Feste IP-Adressen für die Knoten. keepalived im Unicast-Betrieb kennt seinen Partner nur über dessen Adresse. Vergibt DHCP eine neue, ist der Cluster gespalten, ohne dass es jemand merkt. Bei uns ist genau das passiert, bevor wir beide Knoten per netplan fest eingestellt haben.
  4. Das Zertifikat dort hinterlegen, wo der Dienst es liest. Bei App Volumes ist das die nginx.conf, nicht der Windows-Speicher.
  5. Erst die Backends ansehen, dann die VIP. Ist das Backend DOWN, kann die VIP nichts ausliefern, egal wie gut sie konfiguriert ist.