diff --git a/docker-compose.yml b/docker-compose.yml index 7447609..ea69c68 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -91,7 +91,11 @@ services: # renders no mail, so it is not the one that was writing compiled views — # and update.sh normalises ownership at the end of every deployment, which # heals it if it ever does. - command: sh -c 'wg-quick up wg0 2>/dev/null || true; exec php artisan queue:work provisioning --queue=provisioning --tries=1 --timeout=2100 --sleep=3' + # Der Rumpf steht in einer Datei, nicht hier: er haelt den Arbeiter in einer + # Schleife, damit ein Zeitueberlauf oder ein fataler Fehler nicht mehr den + # ganzen Container — und mit ihm wg0 und jede WireGuard-Sitzung — mitnimmt. + # Die Begruendung steht ausfuehrlich in der Datei. + command: sh /var/www/html/docker/provisioning-worker.sh cap_add: - NET_ADMIN devices: @@ -100,21 +104,6 @@ services: - net.ipv4.ip_forward=1 ports: - "${WG_HUB_PORT:-51820}:51820/udp" - networks: - default: - aliases: - # Der Name, unter dem nginx die Terminal-Bruecke erreicht. Sie teilt - # sich den Netz-Namensraum DIESES Containers (`network_mode` unten bei - # `terminal`) und hat deshalb keine eigene Adresse im Compose-Netz und - # keinen eigenen DNS-Eintrag — ein Container ohne eigenes Netz bekommt - # keinen. Der Alias gibt der Adresse den Namen des Dienstes, der dort - # antwortet, statt in docker/nginx/default.conf den Namen des - # Provisionierungs-Workers stehen zu haben. - # - # Faellt `network_mode` bei `terminal` je weg, muss dieser Alias mit - # weg: sonst zeigen zwei DNS-Eintraege auf denselben Namen und nur - # einer davon hat einen Zuhoerer. - - terminal volumes: - .:/var/www/html - wireguard:/etc/wireguard diff --git a/docker/nginx/default.conf b/docker/nginx/default.conf index 3fbcb1c..cfa8bc2 100644 --- a/docker/nginx/default.conf +++ b/docker/nginx/default.conf @@ -77,7 +77,20 @@ server { # eingebauter DNS) wird daraus ein 502 an genau dieser Stelle, waehrend # alles andere weiterlaeuft. resolver 127.0.0.11 valid=10s ipv6=off; - set $terminal_upstream terminal:8082; + # Der Name des Provisionierungs-Workers, nicht ein huebscherer Alias. + # + # Hier stand `terminal:8082`, und dieser Alias war ein Eintrag unter + # `networks:` von queue-provisioning. Genau das hat den Tunnel-Container + # beim ersten Ausrollen NEU BAUEN lassen — Compose baut einen Container + # neu, sobald sich seine Netzkonfiguration aendert. Ein neu gebauter + # Container bekommt eine neue Adresse im Compose-Netz, und damit werden + # die veroeffentlichten Regeln fuer UDP 51820 neu geschrieben, auf denen + # JEDER bestehende WireGuard-Fluss liegt. + # + # Der Preis war also: ein Wort in dieser Zeile gegen einen Abriss aller + # Tunnel. Die Bruecke teilt sich ohnehin den Namensraum, `:8082` ist ihr + # Port darin — der Name daneben darf ruhig der des Nachbarn sein. + set $terminal_upstream queue-provisioning:8082; proxy_pass http://$terminal_upstream; proxy_http_version 1.1; diff --git a/docker/provisioning-worker.sh b/docker/provisioning-worker.sh new file mode 100755 index 0000000..75ada9f --- /dev/null +++ b/docker/provisioning-worker.sh @@ -0,0 +1,51 @@ +#!/bin/sh +# Der Tunnel und der Arbeiter, die sich einen Container teilen — aber nicht +# mehr ein Schicksal. +# +# Bis hierher war `php artisan queue:work` der Prozess 1 dieses Containers. +# Damit hing wg0 an seinem Leben: jedes Mal, wenn der Arbeiter endete, endete +# der Container, und mit ihm der Netz-Namensraum, in dem der Tunnel steht. +# +# Ein Arbeiter endet oefter, als man denkt. `--timeout=2100` beendet ihn, wenn +# ein Provisionierungs-Schritt zu lange braucht. Ein fataler Fehler beendet ihn. +# Ein ueberschrittenes Speicherlimit beendet ihn. `queue:restart` beendet ihn +# absichtlich. In allen vier Faellen fielen bisher saemtliche WireGuard-Sitzungen +# mit — die des Betreibers am Telefon genauso wie die jedes Hosts. Von aussen sah +# das aus wie „das VPN ist unzuverlaessig", und die Ursache stand in einer Zeile +# docker-compose.yml, die von Warteschlangen handelt. +# +# Deshalb: wg0 einmal hochziehen, und danach den Arbeiter in einer Schleife +# halten. Faellt er, kommt er wieder — der Tunnel merkt nichts davon. +# +# Der Container endet dann nur noch, wenn Docker ihn beendet. Das ist Absicht: +# ein Arbeiter, der nicht startet, ist ein Fall fuers Protokoll, kein Grund, +# jedem Host die Verbindung abzuschneiden. +set -u + +wg-quick up wg0 2>/dev/null || true + +# SIGTERM sauber weiterreichen: sonst wartet `docker compose stop` zehn Sekunden +# und schlaegt dann mit SIGKILL zu — mitten in einem Provisionierungs-Schritt. +child='' +stop() { + [ -n "$child" ] && kill -TERM "$child" 2>/dev/null + exit 0 +} +trap stop TERM INT + +while true; do + php artisan queue:work provisioning \ + --queue=provisioning \ + --tries=1 \ + --timeout=2100 \ + --sleep=3 & + child=$! + wait "$child" + code=$? + + # Ins Protokoll, nicht ins Nichts: ein Arbeiter, der staendig neu startet, + # ist ein eigenes Problem — es soll nur nicht mehr das Problem des Tunnels + # sein. + echo "[provisioning] Arbeiter beendet (Code ${code}) — wg0 bleibt oben, Neustart in 2 s" >&2 + sleep 2 +done diff --git a/tests/Feature/DeploymentRunsAsTheAppUserTest.php b/tests/Feature/DeploymentRunsAsTheAppUserTest.php index b012e73..871e1b4 100644 --- a/tests/Feature/DeploymentRunsAsTheAppUserTest.php +++ b/tests/Feature/DeploymentRunsAsTheAppUserTest.php @@ -197,8 +197,56 @@ it('laesst die Terminal-Bruecke dort stehen, wo ein Host ueberhaupt erreichbar i // — Docker lehnt das Compose-File dann komplett ab. ->and($block)->not->toContain('ports:'); - // Und der Name, unter dem nginx ihn anspricht, muss der Alias des - // Namensraum-Eigentümers sein: ein Container ohne eigenes Netz hat keinen + // Und nginx muss ihn unter dem Namen des Namensraum-Eigentümers ansprechen, + // nicht unter einem eigenen: ein Container ohne eigenes Netz hat keinen // eigenen DNS-Eintrag. - expect($compose)->toContain(' - terminal'); + // + // Hier stand einmal ein Netz-Alias `terminal` auf queue-provisioning, damit + // in der nginx-Konfiguration ein sprechender Name steht. Der Preis dafür war + // hoch und fiel erst im Betrieb auf: ein Eintrag unter `networks:` ist Teil + // der Netzkonfiguration eines Dienstes, und Compose BAUT einen Container NEU, + // sobald die sich ändert. Neu gebaut heißt neue Adresse im Compose-Netz, + // heißt neu geschriebene Weiterleitungsregeln für den veröffentlichten + // UDP-Port 51820 — auf dem jede bestehende WireGuard-Sitzung liegt. Ein + // hübscherer Name in einer Konfigurationszeile hat sämtliche Tunnel gekostet. + // + // Deshalb: kein Alias. Der Dienstname des Nachbarn tut es auch. + expect($compose)->not->toContain('aliases:'); + + $hub = preg_split('/^ queue-provisioning:$/m', $compose)[1] ?? ''; + $hub = preg_split('/^ \S/m', $hub)[0] ?? ''; + expect($hub)->not->toContain('networks:'); + + expect(File::get(base_path('docker/nginx/default.conf'))) + ->toContain('set $terminal_upstream queue-provisioning:8082;'); +}); + +it('haengt den Tunnel nicht an das Leben eines Warteschlangen-Arbeiters', function () { + // Der Fall, der den Betreiber unterwegs erwischt hat: wg0 stand im + // Namensraum von queue-provisioning, und dessen Prozess 1 war + // `php artisan queue:work`. Endete der Arbeiter — Zeitüberlauf nach 2100 + // Sekunden, fataler Fehler, Speicherlimit, `queue:restart` —, endete der + // Container, und mit ihm fielen ALLE WireGuard-Sitzungen: die des Betreibers + // am Telefon wie die jedes Hosts. Von außen las sich das als „das VPN ist + // unzuverlässig"; die Ursache stand in einer Zeile, die von Warteschlangen + // handelt. + $compose = File::get(base_path('docker-compose.yml')); + + $hub = preg_split('/^ queue-provisioning:$/m', $compose)[1] ?? ''; + $hub = preg_split('/^ \S/m', $hub)[0] ?? ''; + + // Kein `exec` auf den Arbeiter — das ist genau das Ersetzen von Prozess 1, + // das den Container am Arbeiter sterben ließ. + expect($hub)->not->toContain('exec php artisan queue:work') + ->and($hub)->toContain('docker/provisioning-worker.sh'); + + $runner = File::get(base_path('docker/provisioning-worker.sh')); + + expect($runner)->toContain('wg-quick up wg0') + // Die Schleife ist der ganze Punkt: fällt der Arbeiter, kommt er wieder, + // ohne dass der Namensraum stirbt. + ->and($runner)->toContain('while true') + // Und SIGTERM wird weitergereicht, sonst wartet `docker compose stop` + // zehn Sekunden und schlägt dann mitten in einem Schritt mit SIGKILL zu. + ->and($runner)->toContain('trap stop TERM INT'); });