7 Punkte von GN⁺ 2023-07-17 | 1 Kommentare | Auf WhatsApp teilen
  • Persönliche Services betreibe ich direkt auf einer DigitalOcean-VM für 5 $/Monat und Debian 10; für kleine Services ist es völlig einfach und sinnvoll, Server-Software direkt auf einer VM laufen zu lassen
  • Server-Apps werden als statische Rust-Binärdateien gebaut, wobei HTML, CSS, Konfiguration und sogar Geheimnisse eingebettet werden, sodass das Deployment-Artefakt aus einer einzigen Datei besteht
  • Das Prozessmanagement übernimmt systemd, HTTPS wird über einen nginx-Reverse-Proxy und Let’s Encrypt/certbot abgewickelt, sodass die App TLS nicht selbst behandeln muss
  • Für Services mit Datenbedarf verwende ich eine einzelne SQLite-Datei und kombiniere tägliche Tarsnap-Backups mit Streaming-Backups über Litestream nach DigitalOcean Spaces
  • Wenn mehrere Services auf einer VM laufen, isoliere ich sie über separate Unix-Benutzer pro Service; bei höherem Sicherheitsbedarf nutze ich separate VMs oder systemd-nspawn bzw. firejail

Grundaufbau von Servern und Deployments

  • Mehrere Services wie thoughts.page, hanabi.site, cgmserver und phonebridge laufen ungefähr in dieser Konfiguration
  • Die Apps laufen auf einer DigitalOcean-VM im Tarif für 5 $/Monat mit Debian 10 als Betriebssystem
    • Einige Services teilen sich dieselbe VM, andere sind auf getrennte VMs verteilt
  • Die Server-Software ist in Rust geschrieben
    • statisch gelinkt
    • HTML, CSS, Konfiguration und Geheimnisse werden in die Binärdatei kompiliert
    • Verwendet werden rust-musl-builder und rust-embed
  • Dadurch wird ein Deployment im Wesentlichen darauf reduziert, eine einzige Datei auf den Server zu kopieren
    • Ein ähnlicher Ansatz ist auch mit Go, C++ usw. möglich
    • Wenn eine Sprache Single-Binary-Deployments nicht gut unterstützt, kann alternativ ein Docker-Container als Build-Artefakt dienen

Ausführung, Proxy und Datensicherung

  • Das Starten der Services übernimmt systemd
    • Beim Serverstart werden die Binärdateien automatisch mitgestartet
    • Die meisten systemd-Unit-Dateien sind einfache Dateien mit 9 Zeilen
    • systemd selbst ist zwar komplex, aber für den Zweck, Server beim Booten zu starten, bekommt man von dieser Komplexität meist wenig mit
  • Deployments erfolgen über ein einfaches Deploy-Skript
    • Es kopiert die Binärdatei auf den Server und startet den Server neu
    • Rollbacks sind möglich, und selbst wenn während des Deployments die Verbindung abbricht, läuft immer eine gültige Version
  • Für Programme, die eine Datenbank brauchen, verwende ich SQLite
    • Der komplette App-Zustand liegt in einer einzigen Datei
    • Täglich wird mit dem SQLite-Befehl .backup ein Backup erstellt und in Tarsnap gespeichert
    • Das Backup-Skript läuft per cron
    • Mit Litestream wird eine Datenbankkopie im Sekundentakt in den DigitalOcean-Spaces-Storage gestreamt; Snapshots werden alle 6 Stunden erzeugt
  • Alle Server laufen hinter einem nginx-Reverse-Proxy
    • nginx übernimmt die TLS-Terminierung, sodass sich die App nicht um HTTPS kümmern muss
    • HTTPS-Zertifikate werden mit Let’s Encrypt und certbot bezogen und automatisch erneuert
    • Ein Beispiel für die nginx-Konfiguration von hanabi.site gibt es in einem separaten Gist
    • Statische Dateien können von nginx ausgeliefert und per scp oder rsync auf den Server kopiert werden

Wartung und Service-Isolierung

  • Diese Konfiguration zielt auf einen einfachen und robusten Betriebsweg ab
    • Mit Ausnahme der App selbst besteht der Auslieferungspfad aus bewährten Komponenten, die seit Jahrzehnten eingesetzt werden
    • Solange man die DigitalOcean-Rechnung bezahlt, gibt es für den Betrieb der Sites im Wesentlichen kaum Wartungsaufwand
    • Die einzigen von Monitoring erkannten Probleme waren vorübergehende Netzwerkprobleme bei DigitalOcean
  • Betriebssystem- und Sicherheitsupdates sind gelegentlich nötig
    • Debian-Releases erhalten 5 Jahre Support, daher ist in etwa zweieinhalb Jahren ein Upgrade auf Debian 11 nötig
    • Wenn noch einmal ein Vorfall wie Heartbleed passiert, muss gepatcht werden
    • Solche Vorfälle sind selten
  • Für jeden Service eine eigene VM für 5 $/Monat zu nutzen, kann teuer werden, aber mehrere Services können auf derselben VM laufen
    • Die Isolation erfolgt über separate Unix-Benutzerkonten pro Service
    • Diese Form der Isolation gibt es seit den Anfängen von Unix und sie wirkt entsprechend robust
    • Wenn stärkere Isolation nötig ist, können systemd-nspawn oder firejail verwendet werden
    • Wenn es wirklich sicherheitskritisch ist, zahle ich weitere 5 $/Monat und lasse es auf einer separaten VM laufen
  • Die Einrichtung eines neuen Projekts ist kurz
    • neuen Benutzer anlegen
    • nginx-Virtual-Host hinzufügen und mit certbot ein HTTPS-Zertifikat ausstellen
    • systemd-Unit hinzufügen
    • das Deploy-Skript ins Repository committen und ausführen
  • Am Anfang gibt es viel zu lernen, aber diese Infrastruktur verändert sich deutlich langsamer als Cloud-Infrastruktur
    • Das Format der nginx-Konfiguration ist in den letzten 10 Jahren im Wesentlichen fast gleich geblieben
    • Die letzte große Veränderung in der Debian-Systemverwaltung war der Wechsel zu systemd vor fast 10 Jahren
    • Man ist weniger Situationen ausgesetzt, in denen ein Cloud-Anbieter Services einstellt oder Verhalten stillschweigend ändert
    • Die einzige Abhängigkeit ist der VPS-Anbieter; Server sind jedoch ein austauschbares Standardprodukt, sodass man zu einem anderen Anbieter wechseln kann

1 Kommentare

 
GN⁺ 2023-07-17
Hacker-News-Kommentare
  • Wenn man jeden Dienst zur Isolation unter einem eigenen Unix-Benutzer laufen lässt, kann systemds DynamicUser-Funktion Zeit sparen
    Sie weist eine UID zu und legt Log-/Statusverzeichnisse mit den richtigen Berechtigungen an
    https://0pointer.net/blog/dynamic-users-with-systemd.html

    • Wenn die UID dauerhaft sein muss, ist es mit systemd-sysusers ebenfalls sehr einfach, einen neuen Systembenutzer anzulegen
      Man legt eine kleine Textdatei mit Informationen wie Benutzername und Home-Verzeichnis in /etc/sysusers.d/ ab und führt dann den sysusers-Befehl oder -Dienst aus
    • Pro Dienst/App ein eigenes Service-Konto zu verwenden, ist ein ziemlich standardmäßiger Ansatz, und auch Server sollte man besser trennen
  • HTTP-getriggerte Cloud Functions gefallen mir derzeit am besten
    Wenn man die anbieterspezifischen Fallstricke sorgfältig umgeht, reduzieren sie die Komplexität stark und wirken unter den Cloud-nativen Abstraktionen als einzige wie eine „Endform“
    Ich habe allein in einer App über 2000 Deployments gemacht und musste nie das Supportteam kontaktieren oder obskure Konsolenbefehle eingeben, weil die Laufzeitumgebung kaputtgegangen wäre
    Auch die Performance auf Basis von Azures isoliertem App-Service-Plan ist hervorragend, und meine ideologische Abneigung gegen Abrechnung pro Request ist inzwischen verschwunden
    Man könnte es billiger machen, wenn man sogar die Immobilie besitzt, in der die eigenen Server stehen, aber um Eigenschaften wie Compliance und Auditierbarkeit, die eine einfache HTTP Function bietet, selbst zu implementieren, müsste man faktisch ein riesiges Unternehmen aufbauen und jede Menge Leute einstellen
    Auch das Argument des Vendor-Lock-ins überzeugt mich nicht mehr wirklich. Eine Schnittstelle, die einen HTTP-Request entgegennimmt und eine HTTP-Response zurückgibt, ist natürlich, und die anbieterspezifischen Unterschiede beschränken sich auf zusätzlichen Kontext wie die Signatur der Trigger-Methode oder OIDC-/SAML-Claims. Man müsste sich also eher anstrengen, eine Struktur zu bauen, die sich nicht innerhalb einer Woche auf einen anderen Anbieter refaktorisieren ließe
    Bei einem persönlichen Blog verstehe ich, dass es mehr Spaß macht, eine Hetzner-VM zu kaufen und handwerklich feinzujustieren. In einer völlig unregulierten Branche ließe sich auch stärker dafür argumentieren, Margen und Komplexität genauer abzuwägen, statt Server vollständig auszulagern

    • Bei der Stelle „Um es selbst zu implementieren, müsste man ein 1-Milliarden-Dollar-Unternehmen aufbauen und 1000 weitere Leute einstellen“ habe ich mit den Augen gerollt
      Ich weiß nicht, wie sich die Leute vorstellen, dass das vor der Cloud-Migration gemacht wurde
      Ich betreue derzeit in einem großen Industrieunternehmen mehrere Teams und skaliere und deploye Innovations-Apps auf Azure, und die Cloud-Kosten sind im Verhältnis zur Nutzerzahl absurd hoch
      Früher haben wir Apps mit der zehnfachen Nutzerzahl zu einem Hundertstel der Kosten und mit geringerer Komplexität betrieben
      Da diese Kosten an eine andere Abteilung weiterverrechnet werden, die diese fragwürdige Entscheidung getroffen hat, ist es mir persönlich egal, aber die blinde Gläubigkeit gegenüber der Cloud kann ich schwer nachvollziehen
    • Ich arbeite in einer extrem stark regulierten Branche, aber den letzten Satz verstehe ich nicht
      Für uns ist eigene Hardware zu betreiben die beste Lösung, und da man nicht einmal im Büro Fotos machen darf, liegt die Wahrscheinlichkeit, einem Cloud-Anbieter irgendetwas anzuvertrauen, bei 0 %
    • Functions sind ein brauchbarer Ort, um einfache APIs zu hosten
      Aber damit eine App nützlich wird, braucht sie auch andere Komponenten wie eine Datenbank, und entstehen nicht genau dort zusätzliche Kosten?
    • Ich weiß nicht genau, was hier mit HTTP-getriggerten Cloud Functions gemeint ist
      Ich bin unsicher, ob es um Request-Handler eines HTTP-Servers geht, um Function-as-a-Service-Cloud-Computing oder einfach um altes RPC
    • Entscheidend ist, was die Function tatsächlich tut
      Wenn irgendein Scriptkiddie anfängt, zehnmal pro Minute Requests zu schicken, wie lange dauert es dann, bis die Function-Kosten teurer sind als ein VPS?
  • Für persönliche Websites und Projektseiten verwende ich eine ähnliche Konfiguration
    Ich nutze eine Linode-VM für 5 Dollar im Monat, setze Debian GNU/Linux ein, und die Software ist in Common Lisp geschrieben
    Für persönliche Websites oder Blogs generiert ein Common-Lisp-Programm statische Seiten, und Online-Dienste oder Web-Apps baue ich als Common-Lisp-Programme, die mit Hunchentoot HTTP-Requests verarbeiten und beantworten
    Damit Sites/Dienste beim Start oder Neustart der VM automatisch hochkommen, verwende ich systemd-Unit-Dateien, die meist etwa 10 bis 15 Zeilen lang sind
    Die Ersteinrichtung der VM ist als Shell-Skript codiert: https://github.com/susam/dotfiles/blob/main/linode.sh
    Projekt- und dienstspezifische Konfigurationen verwalte ich jeweils per Makefile: https://github.com/susam/susam.net/blob/main/Makefile, https://github.com/susam/mathb/blob/main/Makefile
    Container verwende ich nicht. Diese Sites laufen schon seit Jahren, seit einer Zeit vor dem Container-Hype, und bisher haben Initialisierungsskripte und Makefiles ausgereicht
    Nginx nutze ich ebenfalls. Es liefert statische Dateien aus, fungiert bei Backend-Diensten als Reverse Proxy und bietet neben TLS-Terminierung weitere Vorteile wie Request-Rate-Limiting oder Allowlist-Konfigurationen für HTTP-Header zum Schutz des Backends
    In meinem kleinen persönlichen Playbook stehen ein paar Befehle wie curl LINK -o linode.sh && sh linode.sh und git clone LINK && cd PROJECT && sudo make setup https
    Die make-Targets erledigen die für den Betrieb der Website nötigen Aufgaben, etwa die Installation von Tools wie Nginx, certbot und sbcl, die Nginx-Konfiguration und die Zertifikatseinrichtung; sobald der Befehl beendet ist, ist die Website direkt öffentlich erreichbar

    • Mich würde interessieren, welche Common-Lisp-Implementierung verwendet wird
      Dem Makefile nach scheint es SBCL zu sein; ich frage mich, ob der Speicherverbrauch je ein Problem war
      Ich nutze einen VPS mit 512 MB RAM, und eine SBCL-Instanz verbraucht ungefähr 100 MB, sodass nur ein paar Dienste gleichzeitig laufen können
      Ich habe auch darüber nachgedacht, den Dienst mit dem geringsten Traffic auf CLISP umzuziehen, aber dort fehlt ein von mir verwendetes Feature, nämlich package-lokale Aliasse
  • Nachdem ich die Konfiguration über Jahre hinweg immer weiter verfeinert habe, bin ich inzwischen dabei gelandet, alles als Docker-Container laufen zu lassen
    Als Orchestrator verwende ich docker-compose statt systemd, als Proxy Caddy statt nginx
    Wie im ursprünglichen Artikel schreibe ich für jedes Projekt, das laufen soll, ein Deployment-Skript, daher ist es insgesamt ziemlich ähnlich
    Einer der vielen Vorteile von Docker ist, dass man damit auch Third-Party-Software mit derselben Konfiguration ausführen kann
    Ich nutze dieses Setup seit einigen Jahren, und es ist sehr gut; wie im Originalartikel ist es robust, bietet aber bei Bedarf auch die Flexibilität, Dinge nach Wunsch anzupassen
    Der einzige aktuelle Schmerzpunkt sind Rolling Deployments. Da die Software größer wird, werden ein paar Sekunden Downtime bei jedem Deployment zum Problem, und ich habe noch keine einfache Lösung, aber docker swarm könnte der richtige Weg sein

    • Ich betreibe es ähnlich mit Caddy
      Um Downtime zu reduzieren, kann man health_uri /health und lb_try_duration 30s ausprobieren
      Wenn man es zum Beispiel wie reverse_proxy api:8089 { health_uri /health lb_try_duration 30s } konfiguriert, puffert Caddy während des Deployments der neuen Version die Requests und gibt dem neuen Service 30 Sekunden Zeit, hochzukommen
      Idealerweise sollte Caddy die neue Version erst verwenden, nachdem sie healthy ist, und dann den alten Container beenden
      Ich habe mir https://github.com/Wowu/docker-rollout und https://github.com/lucaslorentz/caddy-docker-proxy angesehen, aber noch keine Priorität dafür gefunden
    • Wenn ein Load Balancer wie Caddy vor den Pods steht, kann man ihn so konfigurieren, dass er Requests zurückhält, während ein neuer Pod hochkommt: https://twitter.com/bradleyjkemp/status/1486756361845329927
      Perfekt ist das nicht, aber statt dass der Browser einen Verbindungsfehler bekommt, lädt er einfach ein paar Sekunden lang
      https://mrsk.dev/ nutzt dieselbe Technik
    • Als ich den Software-Stack des Startups, bei dem ich arbeite, von Grund auf aufgebaut habe, habe ich die Anwendung direkt mit Docker paketiert
      In Produktion sind wir mit compose gestartet und haben das später zu einer Continuous-Deployment-Pipeline ausgebaut, die den Stack automatisch aktualisiert
      Als das Unternehmen und die Nutzerbasis wuchsen, traten Probleme mit Downtime bei Neustarts oder Deployments auf, und jedes Mal, wenn wir zusätzliche Apps betreiben wollten, mussten wir eine neue Deployment-Umgebung vorbereiten
      Ich fürchtete den Tag, an dem wir Kubernetes einsetzen müssten; ich hatte dessen Komplexität aus nächster Nähe gesehen und wollte nicht den Großteil des Tages damit verbringen, einen Cluster zu besänftigen
      Deshalb sind wir zu Swarm mode gegangen, und die Reise ist mal Jekyll, mal Hyde
      Es gibt Bugs, die niemand beheben will, Teile der Docker-Spezifikation, die nicht implementiert sind, ohne dass einem das gesagt wird, Implementierungsentscheidungen, bei denen man sich die Haare rauft, und das Gefühl, dass die Mitarbeiter von Docker Inc nicht miteinander reden oder Dinge nicht bis zum Ende durchziehen
      Trotzdem gibt es auch viele schöne Seiten. compose-Stacks funktionieren unverändert und können genau dort wachsen, wo es nötig ist; wenn es richtig eingerichtet ist, funktionieren Zero-Downtime-Deployments, Upgrades, Load Balancing und Rollbacks gut
      Raft ist wie anderswo zuverlässig für den Cluster-Erhalt, und mit etwas Aufwand kann man mit einem Bruchteil des K8s-Wartungsbudgets eine flexible, sichere und automatisch verteilte Self-Service-Plattform bauen
      Allerdings muss man bereit sein, die Deployment-Skripte ordentlich zu bauen. Ich habe ziemlich viel Zeit damit verbracht, ein Python-Tool zu erstellen, das gültige Docker-Spec-compose-Dateien in die Swarm-Spezifikation umwandelt, Secrets erneuert und aufräumt und Umgebungsvariablen erweitert
      Je nach VPS-Anbieter muss man außerdem die Netzwerk-MTU unbedingt korrekt setzen. Das dürfte meine Lebenserwartung deutlich verkürzt haben
    • Wenn man bereits compose-Dateien hat, ist Swarm der richtige Weg, aber persönlich bin ich zu dem Schluss gekommen, dass es den Aufwand nicht wert ist
      Zumindest nicht, bevor durch viele Kunden oder Nutzer tatsächlich Skalierungsprobleme entstehen
    • Ich habe ein ähnliches Setup gebaut, mag aber den Ansatz nicht, Images mit docker save und docker import per SSH hineinzuschieben
      Ich frage mich, ob du eine eigene Registry betreibst
  • Auf einem physischen Server packe ich die PostgreSQL-Datenbank und die App in einen Podman-Pod, lasse alles auf localhost-Ports oberhalb von 5000 laufen, und Caddy agiert auf 443 als Reverse Proxy
    Mit einem systemd Timer dumpe ich täglich um 16:55 Uhr alle Datenbanken in ein Verzeichnis
    Danach sichert DejaDup [1] jeden Tag um 17:00 Uhr automatisch $HOME auf eine externe HDD; Cache-Dateien werden ausgeschlossen, die Datenbank-Dumps aber eingeschlossen
    Als OS läuft Debian mit GNOME Core [2], und die firewalld-Regeln erlauben nur 80, 443 und einen benutzerdefinierten SSH-Port
    SSH ist keybasiert, Passwortauthentifizierung ist deaktiviert
    Das ist die langweiligste Methode, aber sie funktioniert einfach gut
    1 - https://flathub.org/apps/org.gnome.DejaDup
    2 - https://packages.debian.org/bookworm/gnome-core

    • Ich frage mich, warum du GNOME auf einem Server laufen lässt
    • Ich frage mich, ob es einen Grund gibt, systemd timers statt cron-Jobs zu verwenden
  • Heutzutage nutze ich Dokku auf einem einzelnen Server
    Es ist leicht zu verwalten, Entwickler können wie bei Heroku einfach per git push deployen, und dank vieler Plugins dauert das Hinzufügen einer Datenbank oder das Einrichten von HTTPS höchstens 10 Sekunden

    • Ich nutze denselben Ansatz, und es ist wirklich bequem, per git push deployen zu können
      App hinzufügen, Datenbank hinzufügen, Umgebungsvariablen verwalten und Domains verwalten ist alles sehr intuitiv
  • Ich mag einfache Setups, daher sieht diese Art zu arbeiten angenehm aus
    Für 99 % der Dienste dürfte das wahrscheinlich ausreichen
    Für Server-Konfiguration und Deployment-Skripte würde ich wohl Ansible verwenden
    Damit wäre der Server dokumentiert, und auch die Deployment-Skripte könnten einfacher werden
    Allerdings würde ich nicht darauf verzichten, mich zum Debuggen oder Überprüfen direkt auf dem Server einzuloggen

    • Ich nutze zwar Ansible, denke aber gerade erneut darüber nach, ob es den Aufwand wert ist
      Meine Skripte haben sich in den letzten 7 Jahren kaum verändert, während Ansible langsam ist und dazu neigt, dass man mehrere Dateien ständig pflegen muss
      Was Debugging angeht, könnte man die App-Logs auch per nginx über einen passwortgeschützten Endpunkt zugänglich machen
  • Man vergisst offenbar manchmal, dass CI/CD und effektives Server-Management schon vor der Cloud gängige Praxis waren

  • Zur Methode, Serversoftware in Rust zu schreiben, statisch zu linken und HTML, CSS, Konfiguration, Secrets usw. alles in das Binary hineinzukompilieren: Ich mache das seit Kurzem in Go so und finde es großartig, weil das Schreiben und Ausliefern von Software, die auf statische Dateien angewiesen ist, dadurch wirklich einfach wird

    • Secrets in ein kompiliertes Binary aufzunehmen, finde ich weiterhin fragwürdig
      Für Secrets sind Umgebungsvariablen oder Konfiguration eher der Standard; zwar kommt dadurch ein Schritt vor dem Start hinzu, aber man vermeidet, ein Binary weiterzugeben und später zu vergessen, dass darin Secrets enthalten waren
      Ein Binary zu entpacken und nach Strings zu suchen, ist ziemlich einfach
      Statische Frontend-Assets und Default-Konfiguration mit hineinzupacken, ist dagegen ein großer Vorteil
    • Ich mag Go nicht besonders, verwende es aus diesem Grund aber immer wieder
      Cross-Compiling mit Rust, insbesondere von Mac nach Linux, ist vergleichsweise schmerzhaft, während es mit Go trivial und in das go-Tool eingebaut ist
      Es wird wirklich leicht, beim Abschließen und Deployen eines Nebenprojekts Reibung zu vermeiden
    • https://play.clickhouse.com/play?user=play macht es ebenfalls so
      Allerdings habe ich eine Frage. Im Artikel heißt es: „Let’s-Encrypt-Zertifikate werden mit certbot bezogen, und die automatische Erneuerung wird ebenfalls erledigt.“ Ich habe jedoch ein regionenübergreifendes Setup mit zwei Servern und Geo-DNS, sodass certbot nur auf dem US-Server läuft und ich das Zertifikat manuell auf den europäischen Server kopieren muss
      Gibt es eine Möglichkeit, das zu lösen?
      Die Beschreibung des ClickHouse Playground ist hier: https://ghe.clickhouse.tech/
    • Bei mir ist es genauso
      Dieselbe statisch kompilierte Service-Anwendung, die die Backend-API bereitstellt, liefert auch die Webanwendung aus
      Wenn ich in CI npm build ausführe und das Ergebnis einbette, wird es sehr einfach, lokale Tests oder eine Demo-Instanz zu starten