Das Mysterium des Container-Image-Digests eb37f58646a901dc7727cf448cae36daaefaba79de33b5058dab79aa4c04aefb

Wir benutzen Renovate um alle Stellen, an denen wir Container-Images verwenden (z.B. Kubernetes-Manifeste, Puppet-Code und Gitlab-Pipelines), aktuell zu halten. Renovate sammelt Image-Referenzen aus allen unseren Repos, prüft dann die entsprechende Container-Registry auf neue Versionen der jeweiligen Images und ändert die Angabe im Repo.

no matching manifest for linux/amd64

Dabei haben wir eine sehr seltsame Feststellung gemacht. Renovate hat z.B. diese Änderung gemacht:

Screenshot eines Diff.

    - docker:27.0.3-dind@sha256:8dc34457f4b09038cf8990b4503fea4a65d879fbba9eceb788f1342217e7c848

wird ersetzt durch

    - docker:27.1.0-dind@sha256:eb37f58646a901dc7727cf448cae36daaefaba79de33b5058dab79aa4c04aefb

Aber bei der Ausführung gab es einen Fehler:

ERROR: Job failed: failed to pull image "docker:27.1.0-dind@sha256:eb37f58646a901dc7727cf448cae36daaefaba79de33b5058dab79aa4c04aefb" with specified policies [if-not-present]: no matching manifest for linux/amd64 in the manifest list entries (manager.go:250:0s)

OK, es ist kein Image für linux/amd64 mehr enthalten? Wir haben ja gehört, dass ARM populärer wird für Server, aber dass Docker plötzlich ihre Images nicht mehr für AMD64 anbietet, wäre dann doch eine Überraschung. Allerdings: linux/arm64 wird auch nicht unterstützt:

docker: no matching manifest for linux/arm64/v8 in the manifest list entries.

Es wurde noch seltsamer: Der Digest des Image, eb37f58646a901dc7727cf448cae36daaefaba79de33b5058dab79aa4c04aefb, tauchte auch bei anderen Image-Versionen und völlig anderen Images auf:

  • docker:27.1.1
  • docker:27.1.1-dind
  • wordpress:6.6.1
  • rabbitmq:3.13.1-management

Was war hier los?

Aufbau von Multi-Arch-Images

Es ist wichtig zu verstehen, wie sog. Multi-Arch-Images bei Docker funktionieren: sie sind einfach nur eine Liste der konkreten Images für die verschiedenen Architekturen. Mit dem Tool crane können wir das abfragen für unser problematisches Image:

$ crane manifest docker:27.1.0-dind@sha256:eb37f58646a901dc7727cf448cae36daaefaba79de33b5058dab79aa4c04aefb | jq '[.manifests[].platform]' 

[]

OK, es wird einfach gar nichts unterstützt?! Zum Vergleich ein funktionierendes Image:

$ crane manifest docker:27.0.3-dind@sha256:75f620cbf8e87543ec1fb0bf98fa2cfde8f684308dafb6c50cc75f3a235fa1fc | jq '[.manifests[].platform]'

[
  {
    "architecture": "amd64",
    "os": "linux"
  },
[…]
  {
    "architecture": "arm64",
    "os": "linux",
    "variant": "v8"
  },
[…]
]

D.h. unser Verständnis des Formats ist richtig – im Manifests sollten die Architekturen aufgelistet sein.

Die Funktionsweise der Multi-Arch-Images erklärt auch, wieso wir den Digest an mehreren Stellen gesehen haben: der Digest ist nur der SHA256-Hash eines leeren Multi-Arch-Image, komplett unabhängig vom Image-Namen/Tag:

$ crane manifest docker:27.1.0-dind@sha256:eb37f58646a901dc7727cf448cae36daaefaba79de33b5058dab79aa4c04aefb | sha256sum

eb37f58646a901dc7727cf448cae36daaefaba79de33b5058dab79aa4c04aefb  -

$ crane manifest rabbitmq:3.13.1-management@sha256:eb37f58646a901dc7727cf448cae36daaefaba79de33b5058dab79aa4c04aefb | sha256sum

eb37f58646a901dc7727cf448cae36daaefaba79de33b5058dab79aa4c04aefb  -

Aber wieso veröffentlicht Docker Inc leere Images?

Unsere Recherche führte uns zu verwandten Problemen — Fälle, wo neue Image-Versionen nicht für alle Architekturen verfügbar waren. Wir selbst hatten schon mal das Problem, dass bei einem Update aus einem amd64-Image ein i386-Image wurde. Wie kommt das?

Der Grund ist, dass Docker (die Firma) neue Images nicht gemeinsam für alle Architekturen pusht, sondern dass die Liste schrittweise auf dem Docker Hub aufgebaut wird. Als erstes pushen sie eine leere Liste, und diese leere Liste wird auch bereits getaggt, so dass Renovate sie findet.

Erst dann laufen die Builds für die verschiedenen Architekturen, wodurch die Liste jeweils erweitert wird (und es jedes Mal einen neuen Digest gibt). Je nachdem in welcher Reihenfolge die Builds fertig werden, und wann man das Image abruft, bekommt man andere Architekturen. amd64-Maschinen können auch i386-Images ausführen – d.h. wenn man pullt, wenn es i386 schon gibt, aber noch nicht amd64, dann bekommt man die i386-Variante.

Als Workaround haben wir Renovate konfiguriert, dass es für Docker Hub einige Stunden wartet, bevor es ein neues Image verwendet:

packageRules: [
        {
            matchDatasources: ["docker"],
            minimumReleaseAge: "4 hours",

            // nur Docker Hub (Images ohne Punkt = Images ohne Hostname)
            // wobei aktuell sowieso nur Docker Hub Release-Timestamps liefert.
            excludePackagePatterns: ["\\."],
        },
]

Eine Garantie ist das leider auch nicht — es gibt keine Möglichkeit zu erkennen, dass ein Multi-Arch-Image vollständig gepusht wurde. Alternativ könnte Renovate ein Feature bekommen, wo man seine verwendeten Architekturen konfiguriert, und es dann nur Images berücksichtigt, die diese Architekturen beinhalten. Aber die saubere Lösung wäre, dass Docker Inc ihre Images atomic updatet – d.h. alle Architekturen baut, und erst dann das Gesamtergebnis taggt. Wir hatten dieses Problem bisher mit keinen Images anderer Quellen oder auf anderen Registries.

Wenn diese Analyse dein Interesse geweckt hat, interessieren dich vielleicht auch unsere Stellenangebote. 😉

MySQL-Deadlock finden — Flamegraph ohne Profiling

Ein Flamegraph aus dem sich kein klares Bild ergibt

Wir hatten ein seltsames Problem in einem unserer MySQL-Cluster: Die primäre Node konnte keine neuen Verbindungen mehr annehmen. Die Verbindungen wurden nicht abgelehnt, aber sie wurden auch nicht vollständig aufgebaut.

Ein Restart von mysqld hat das Problem gelöst – aber wir haben uns gefragt, was da passiert ist. Daher war eine der Maßnahmen im Post-Mortem, dass wir für die Zukunft dokumentieren, wie man Stacktrace aller MySQL-Threads dumpen kann.

Als das Problem kurz später noch mal auftrat, waren wir vorbereitet: Wir haben einen Coredump erstellt und uns dann mit gdb die Threads angesehen:

„MySQL-Deadlock finden — Flamegraph ohne Profiling“ weiterlesen

mtail testen mit Golden Files

Wie wir bereits beschrieben haben, benutzen wir mtail um Prometheus-Metrics aus Logs zu erzeugen. 

mtail-Programme enthalten Patterns für konkrete Logzeilen, aus denen die Metrics entstehen.

Wie alle Programme können solche für mtail natürlich auch Fehler enthalten. Deswegen wollen wir dafür gerne, wie für andere Programme auch, automatische Tests haben. Dann können wir mindestens bekannte Fehler ausschließen. Tests schaffen auch Sicherheit bei Refactorings, dass man keine Funktionalität kaputt gemacht hat durch Änderungen.

„mtail testen mit Golden Files“ weiterlesen

Structural diff for HAProxy configuration

(This is an English translation of a previous post)

We use HAProxy for all our incoming HTTP and other TCP connections.

Its configuration is the most complex we have. That’s why we do not write it by hand, but rather generate it from metadata and includes. Such a generator makes the configuration easier — but it also makes it harder to see what the actual changes will be in the end.

For situations like that, we like tooling that will show us a diff of the generated configuration. Because the HAProxy configuration is a single text file, we could use the diff program to compare two versions.

The downside of a textual diff is that it shows meaningless differences, for example if only order or comments change.

„Structural diff for HAProxy configuration“ weiterlesen

HAProxy-Konfiguration strukturell diffen

(This post is also available in English)

Wir benutzen HAProxy für alle unsere eingehenden HTTP- und sonstigen TCP-Verbindungen.

Die Konfiguration ist die komplexeste, die wir haben. Daher wird die Konfiguration nicht von Hand erstellt, sondern aus Metadaten und Includes generiert. Ein solcher Generator vereinfacht die Konfiguration — aber kann es erschweren zu sehen, was man nun wirklich ändert.

Für solche Fälle mögen wir Tooling, dass uns einen Diff der generierten Konfiguration anzeigen kann. Da die HAProxy-Konfiguration eine einzelne Textdatei ist, könnten wir das diff-Programm benutzen um zwei Versionen zu vergleichen.

Das Problem mit einem rein textuellen Diff ist, dass dort auch Unterschiede auftauchen, die gar keine Auswirkung auf HAProxy haben, z.B. wenn sich nur die Reihenfolge oder Kommentare ändern.

„HAProxy-Konfiguration strukturell diffen“ weiterlesen

Kubernetes Local Persistent Volumes mit virtio-Disks

In Kubernetes ermöglichen Persistent Volumes (PVs) den Betrieb von Workloads mit persistentem State. Der Inhalt dieser Volumes existiert unabhängig von der Lebensdauer der Pods, die sie verwenden.

Es gibt viele verschiedene Implementierungen/Provider für PVs. Wenn man Kubernetes bei einem Cloud-Anbieter betreibt, kann man deren Blockdevice- oder Netzwerkdateisystem-Service verwenden, z.B. Elastic Block Store (EBS) bei AWS

Auch in einer on-prem-Umgebung wie unserer gibt es Möglichkeiten: die meisten unserer Persistent Volumes liegen auf NetApps. Mit dem offiziellen NetApp-Provisioner Trident werden diese bei Bedarf automatisch angelegt.

In manchen Fällen möchte man aber nicht Volumes per Netzwerk einbinden, sondern diese direkt auf der Node haben, d.h. Local Storage nutzen. Das kann z.B. schneller oder kostengünstiger sein, und hat weniger Abhängigkeiten. Es passt insbesondere dann, wenn die Redundanz der Daten auf Anwendungsebene sichergestellt ist, so dass ein Verlust eines einzelnen PVs keinen Datenverlust bedeutet.

Pods verwenden die PVs nicht direkt, sondern sie referenzieren einen Persistent Volume Claim (PVC). Ein PVC ist ein „Wunsch“ nach einem Volume einer bestimmter Art und Größe. Eine Controller-Loop in Kubernetes sorgt dafür, dass ein PVC mit einem passenden PV verknüpft wird. Erst dann kann das PVC von Pods verwendet werden.

Für die meisten Arten von Volumes gibt es dynamisches Provisioning: wenn jemand ein PVC erstellt, wird automatisch vom Hersteller-spezifischen Provisioner (wie NetApp Trident) ein passendes PV erstellt. Da das neue PV genau auf das PVC passt, werden diese dann von Kubernetes verknüpft. 

Bei den local Volumes gibt es stattdessen ein statisches Provisioning: der local-static-provisioner scannt nur auf der jeweiligen Node, wo er läuft, nach schon bestehenden Mountpoints oder Blockdevices, und erstellt dafür die PV-Objekte. Wenn man also ein PVC anlegt, und es gar kein passendes PV gibt, dann wird das PVC pending bleiben, bis man selbst ein Volume erstellt.

„Kubernetes Local Persistent Volumes mit virtio-Disks“ weiterlesen

Linux-Packages erstellen aus fertigen Binaries – mit automatischen Updates

Man kennt das Problem: in der Linux-Distribution der Wahl ist eine gewünschte Software nicht als Paket verfügbar, oder nicht in der Version, die man braucht/möchte. 

Früher™ hätte man häufig selbst kompilieren müssen. Heutzutage, und insbesondere mit Programmiersprachen wie Go oder Java, aus denen (fast) statische Binaries/JARs fallen, stellen viele Upstreams eigene Binaries für ihre Programme bereit. Mit etwas Glück auch in Form von DEBs oder RPMs – aber leider nicht immer.

„Linux-Packages erstellen aus fertigen Binaries – mit automatischen Updates“ weiterlesen

Health-Check-Cache für HTTP-Backends

Was ist das, wozu brauchen wir das und was ist daran so kompliziert?

Ein Health-Check-Cache soll, wie der Name schon vermuten lässt, Health-Checks cachen. In Wirklichkeit passiert aber noch mehr, was für uns auch sehr wichtig ist.

Unsere Loadbalancer laufen mit HAProxy. Dieser macht regelmäßig Health-Checks gegen die Backends. Das ist auch gut und richtig, denn er muss ja immer wissen, welches Backend tatsächlich up oder down ist, um den Traffic für den User möglichst ohne Unterbrechung oder Fehler zu lenken. Wenn man jetzt, so wie wir, sehr viele Loadbalancer hat und diese auch noch innerhalb sehr kurzer Zeit vervielfachen kann, wird das jedoch ab einem gewissen Punkt zu einem Problem und skaliert einfach nicht mehr, was die Health-Checks angeht.

Stellen wir uns 50 Loadbalancer vor, die alle N Sekunden gegen ein und das selbe Backend prüfen. Dazu kommt der normale Traffic, der nicht aus dem Cache ausgeliefert wird, oder der gerade im Cache expired ist, plus vielleicht noch eine „nicht optimale“ Backendsoftware, die einfach nicht so viele Requests pro Sekunde schafft. Das alles in Kombination ist denkbar ungünstig. Das Ziel eines Caches ist es ja, das Backend zu entlasten und die Auslieferung bestenfalls noch zu beschleunigen. Selbiges können wir auch für Health-Checks tun. Wieso sollen alle 50 Loadbalancer einzeln ihre Health-Checks gegen die Backends fahren? Auch hier kann man das super konsolidieren, indem wir nur für die Health-Checks einen Cache betreiben und der HAProxy gegen den Cache prüft.

Das Backend bzw. die Software können wir uns leider nicht in jedem Fall aussuchen oder so beeinflussen, dass sie für uns ausreichend performt. Also brauchen wir eine Lösung, die wir einfach überall anwenden können, unabhängig vom eigentlichen Backend. Da passt ein solcher Cache sehr gut ins Konzept.

„Health-Check-Cache für HTTP-Backends“ weiterlesen

Unsere interne API für SMS-Versand

Bei uns gibt es einige Anwendungen, die SMS verschicken:

  • Icinga verschickt Monitoring-Alerts
  • ein 2FA-Service verschickt Login-TANs
  • Jira verschickt Alerts für bestimmte Tickets

Bis vor kurzem haben wir dafür Hardware-Maschinen mit Mobilfunk-Modems betrieben.

Das hatte leider einige Nachteile:

  • Diese Maschinen brauchen spezialisierte Hardware und können nicht virtualisiert werden
  • Aufwand bei Updates; insbesondere hatten wir einen gepatchten gsmmuxd

Unser Ziel war es, diese Maschinen loszuwerden. Um weiterhin SMS versenden zu können, brauchten wir also einen externen Dienstleister mit einer API.

Die neue API

Wir wollten uns bei der neuen Lösung nicht fest an einen Anbieter binden. In den Begriffen der Softwareentwicklung würde man sagen: wir wollen Loose Coupling.

Deswegen haben wir eine interne API geschaffen, die zwischen unseren Anwendungen und dem Versender steht. Diese API ist ein stabiles Interface; worüber eine SMS versendet wird ist dann nur ein Implementierungsdetail.

„Unsere interne API für SMS-Versand“ weiterlesen

SYN/ACK-Retries unter Linux mit eBPF messen

Wir sind häufig DDoS-Angriffen ausgesetzt. Ein SYN-Flood ist eine der ältesten Techniken, die dabei verwendet werden: der Angreifer sendet zahlreiche SYN-Pakete mit gespooften IP-Absenderadressen.

Dabei wird ausgenutzt, dass der TCP-Handshake aus mehreren Schritten besteht:

  • Der Client sendet ein SYN-Paket
  • Der Server antwortet mit einem SYN/ACK-Paket
  • Der Client antwortet mit einem ACK-Paket

Der Empfänger muss nicht nur für jedes erhaltene Paket einen State in seinem TCP-Stack aufrecht erhalten, sondern sendet im Angriffsfall mehrmals das SYN/ACK-Antwortpaket auf das SYN. Bei einem SYN-Flood wird es aber natürlich niemals eine Antwort auf das SYN/ACK geben.

Bei Linux werden standardmäßig bis zu 6 SYN/ACK-Pakete (1 initial + 5 Retries) auf ein einzelnes SYN geantwortet. Währenddessen bleibt der TCP-State erhalten und Connection-Tracking-States in Paketfiltern werden durch jedes neue SYN/ACK aktuell gehalten, wodurch sie ebenfalls nicht ablaufen können. Zusätzlich erzeugen wir unnötigen ausgehenden Traffic, der dann bei den gespooften Source-Adressen ankommt.

Maßnahmen gegen SYN-Floods sind z.B. einfach mehr offene States zu erlauben oder gar keinen State zu tracken, indem man SYN-Cookies verwendet. Mehr States kosten natürlich mehr Ressourcen und SYN-Cookies funktionieren nicht mit allen Protokollen oder in allen Fällen.

Wir haben uns aber zusätzlich noch einen anderen Ansatz angeschaut: Die Menge der SYN/ACK-Retries zu reduzieren.

„SYN/ACK-Retries unter Linux mit eBPF messen“ weiterlesen