Am 27. Mai im Hauptsaal der Konferenz DevOpsConf 2019, die im Rahmen des Festivals stattfindet , im Rahmen der Sektion "Continuous Delivery", wurde der Vortrag "werf – unser Werkzeug für CI/CD in Kubernetes" gehalten. Darin wird erläutert, welche Probleme und Herausforderungen jeder beim Deployment in Kubernetes hat, sowie die Nuancen, die nicht sofort offensichtlich sind. Bei der Betrachtung möglicher Lösungsansätze zeigen wir, wie dies in unserem Open Source-Tool umgesetzt ist. .
Seit dem Vortrag hat unser Tool (früher bekannt als dapp) einen historischen Meilenstein von 1000 Sternen auf GitHub erreicht – wir hoffen, dass die wachsende Nutzergemeinschaft vielen DevOps-Ingenieuren das Leben erleichtert.

Hiermit präsentieren wir (~47 Minuten, deutlich informativer als der Artikel) und eine Zusammenfassung in Textform. Los geht's!
Bereitstellung von Code in Kubernetes
Im Vortrag geht es nicht mehr um werf, sondern um CI/CD in Kubernetes, wobei angenommen wird, dass unsere Software in Docker-Container verpackt ist (darüber habe ich in meinem ), und K8s wird verwendet, um sie in der Produktion auszuführen (darüber – in ).
Wie sieht die Bereitstellung in Kubernetes aus?
- Es gibt ein Git-Repository mit dem Code und Anweisungen zu dessen Erstellung. Die Anwendung wird in ein Docker-Image gebaut und im Docker-Registry veröffentlicht.
- Im selben Repository gibt es Anweisungen, wie die Anwendung bereitgestellt und gestartet wird. In der Bereitstellungsphase werden diese Anweisungen an Kubernetes gesendet, das das benötigte Image aus dem Registry abruft und es startet.
- Zusätzlich gibt es normalerweise Tests. Einige davon können beim Veröffentlichen des Images durchgeführt werden. Ebenso kann (nach denselben Anweisungen) eine Kopie der Anwendung bereitgestellt werden (in einem separaten K8s-Namespace oder in einem separaten Cluster), und die Tests können dort durchgeführt werden.
- Schließlich wird ein CI-System benötigt, das Ereignisse aus Git (oder Button-Klicks) erhält und alle festgelegten Phasen aufruft: Build, Publish, Deploy, Test.

Hier sind einige wichtige Anmerkungen:
- Da wir eine unveränderliche Infrastruktur haben (immutable infrastructure), sollte das Image der Anwendung, das in allen Phasen (Staging, Produktion usw.) verwendet wird, eins sein.. Dazu habe ich ausführlich mit Beispielen erklärt .
- Da wir den Ansatz der Infrastruktur als Code verfolgen (IaC), müssen der Code der Anwendung, die Anweisungen zu deren Erstellung und Ausführung genau in einem Repository liegen.. Mehr dazu finden Sie in .
- Die Lieferkette (delivery) Wir sehen normalerweise so: Die Anwendung wurde zusammengestellt, getestet, veröffentlicht. (Release-Phase) Und das war's — die Lieferung fand statt. Aber in Wirklichkeit erhält der Benutzer das, was Sie bereitgestellt haben, nicht wenn Sie es in die Produktion geliefert haben, und wenn er in der Lage war, darauf zuzugreifen und diese Produktion lief. Daher denke ich, dass die Lieferkette endet nur in der Betriebsphase (Betrieb), oder genauer gesagt, sogar in dem Moment, wenn der Code von der Produktion entfernt wurde (indem er durch einen neuen ersetzt wurde).
Lassen Sie uns zu dem oben genannten Liefermodell in Kubernetes zurückkehren: Es wurde nicht nur von uns erfunden, sondern auch von praktisch jedem, der sich mit diesem Problem beschäftigt hat. Im Grunde wird dieses Muster jetzt GitOps genannt (mehr über den Begriff und die dahinterstehenden Ideen können Sie lesen ). Werfen wir einen Blick auf die Phasen des Modells.
Build-Phase
Es schien, als könnte man 2019 etwas über das Erstellen von Docker-Images erzählen, wenn alle wissen, wie man Dockerfiles schreibt und sie ausführt. docker build?.. Вот нюансы, на которые хотелось бы обратить внимание:
- Das Gewicht des Images ist wichtig, also nutzen Sie , um nur das wirklich nötige für die Ausführung der Anwendung im Image zu belassen.
- Die Anzahl der Schichten sollte minimiert werden, indem man logische Ketten von
RUN-Befehlen zusammenfasst. - Das fügt jedoch Probleme hinzu bei der Fehlersuche, da man bei einem Build-Fehler den nötigen Befehl aus der Kette finden muss, der das Problem verursacht hat.
- Die Build-Geschwindigkeit ist wichtig, weil wir schnell Änderungen ausrollen und das Ergebnis sehen möchten. Zum Beispiel möchte man nicht bei jedem Build die Abhängigkeiten in den Programmiersprachen neu erstellen.
- Oft werden aus einem Git-Repository viele Images benötigt,was man mit einer Sammlung von Dockerfiles (oder benannten Phasen in einer Datei) und einem Bash-Skript zur sequenziellen Erstellung lösen kann.
Das war nur die Spitze des Eisbergs, mit der jeder konfrontiert wird. Aber es gibt auch andere Probleme, insbesondere:
- Oft müssen wir in der Build-Phase etwas mounten (zum Beispiel die Ergebnisse eines apt-Befehls in ein externes Verzeichnis zwischenspeichern).
- Wir wollen Ansible stattdessen nicht auf Shell zurückgreifen.
- Wir wollen ohne Docker bauen (warum brauchen wir eine zusätzliche virtuelle Maschine, in der wir alles dafür einrichten müssen, wenn es bereits ein Kubernetes-Cluster gibt, in dem man Container ausführen kann?).
- Paralleles Bauen, die unterschiedlich verstanden werden kann: verschiedene Kommandos aus der Dockerfile (wenn Multi-Stage verwendet wird), mehrere Commits eines Repositories, mehrere Dockerfiles.
- Verteilte Builds: wir wollen etwas in Pods bauen, die „ephemer“ sind, da sie ihren Cache verlieren, was bedeutet, dass er irgendwo separat gespeichert werden muss.
- Schließlich nannte ich die Spitze meiner Wünsche Automagie: es wäre perfekt, in ein Repository zu gehen, einen Befehl einzugeben und ein fertiges Image zu erhalten, das mit dem Verständnis, wie und was richtig gemacht werden muss, erstellt wurde. Persönlich bin ich mir jedoch nicht sicher, ob alle Nuancen so vorhergesehen werden können.
Und da gibt es Projekte:
- – ein Builder von Docker Inc (bereits in den aktuellen Docker-Versionen integriert), der versucht, all diese Probleme zu lösen;
- – ein Builder von Google, der das Bauen ohne Docker ermöglicht;
- – ein Versuch des CNCF, Automagie zu erstellen, insbesondere eine interessante Lösung mit Rebase für Schichten;
- und noch viele andere Tools wie , …
… und schauen Sie sich an, wie viele Sterne sie auf GitHub haben. Das bedeutet, einerseits docker build gibt es etwas, das erkannt werden kann, aber in Wirklichkeit ist die Frage nicht endgültig gelöst – ein Beweis dafür ist die parallele Entwicklung alternativer Builder, von denen jeder einen Teil der Probleme löst.
Builden in werf
So sind wir zu (früher – einem Open Source-Tool von Flant, das wir seit vielen Jahren entwickeln. Alles begann vor etwa 5 Jahren mit Bash-Skripten, die das Bauen von Dockerfiles optimieren, und in den letzten 3 Jahren wird innerhalb eines Projekts mit einem eigenen Git-Repository voll entwickelt (zuerst in Ruby, dann neu geschrieben . Welche Buildfragen werden in werf gelöst?Die blau markierten Probleme wurden bereits umgesetzt, paralleles Bauen wurde auf einem einzelnen Host realisiert, und die gelb markierten Fragen planen wir, bis Ende des Sommers abzuschließen.

Stadium der Veröffentlichung im Registry (publish)
Wir haben
docker push docker pushGitflow (oder eine andere Git-Strategie) und Kubernetes haben, und die Industrie tendiert dazu, dass das, was in Kubernetes passiert, dem entspricht, was in Git gemacht wird. Schließlich ist Git unsere einzige Wahrheitsquelle. Was ist daran schwierig?
Die Gewährleistung der Reproduzierbarkeit Die Wiederholbarkeit zu garantieren: von einem Commit in Git, der von Natur aus unveränderlich ist (unveränderlich), bis hin zu einem Docker-Image, das gleich bleiben soll.
Es ist uns auch wichtig, die Herkunft zu bestimmen, denn wir möchten verstehen, aus welchem Commit die Anwendung stammt, die in Kubernetes ausgeführt wird (dann können wir Diffs und ähnliche Dinge durchführen).
Tagging-Strategien
Die erste ist einfach git tag. Wir haben ein Registry mit einem Image, das als 1.0getaggt ist. In Kubernetes gibt es Stage und Production, wohin dieses Image hochgeladen wurde. In Git erstellen wir Commits und setzen irgendwann einen Tag 2.0. Wir bauen es gemäß den Anweisungen im Repository und legen es im Registry mit dem Tag 2.0. Wir rollen es auf Stage aus und, wenn alles gut ist, dann auf Production.

Das Problem mit diesem Ansatz ist, dass wir zuerst den Tag gesetzt haben und erst danach getestet und ausgeholt haben. Warum? Erstens ist es einfach unlogisch: wir geben eine Version der Software heraus, die wir noch nicht getestet haben (wir können es nicht anders machen, denn um zu testen, ist es erforderlich, einen Tag zu setzen). Zweitens ist dieser Weg nicht mit Gitflow vereinbar.
Die zweite Option ist git commit + tag.In dem Master-Branch gibt es einen Tag 1.0; dafür gibt es in der Registry ein Bild, das auf Production bereitgestellt wurde. Außerdem gibt es im Kubernetes-Cluster die Preview- und Staging-Umgebungen. Dann folgen wir Gitflow: In dem Hauptbranch für die Entwicklung (develop) erstellen wir neue Features, was zu einem Commit mit der Identifikation #c1führt. Wir bauen es und veröffentlichen es in der Registry mit dieser Identifikation (#c1). Mit dieser Identifikation rollen wir es auf Preview aus. Das gleiche machen wir mit den Commits. #c2 und #c3.
Wenn wir feststellen, dass die Features ausreichend sind, beginnen wir, alles zu stabilisieren. In Git erstellen wir einen Branch release_1.1 (basierend auf #c3 aus develop). Für dieses Release wird kein Build benötigt, da dies in der vorherigen Phase erledigt wurde. Daher können wir es einfach auf Staging ausrollen. Wir beheben Bugs in #c4 und rollen es ebenfalls auf Staging aus. Parallel dazu läuft die Entwicklung in develop, aus dem regelmäßig Änderungen übernommen werden aus release_1.1. Irgendwann erhalten wir einen gebauten und auf Staging ausgerollten Commit, mit dem wir zufrieden sind (#c25).
Dann machen wir einen Merge (mit Fast-Forward) des Release-Branchs (release_1.1) in den Master. Wir setzen auf diesen Commit einen Tag mit der neuen Version (1.1). Aber dieses Image wurde bereits in der Registry gebaut, daher fügen wir einfach einen zweiten Tag für das vorhandene Bild hinzu (jetzt hat es in der Registry die Tags #c25 und 1.1). Danach rollen wir es auf Production aus.
Es gibt den Nachteil, dass auf Staging ein Bild ausgerollt wurde (#c25), während auf Production sozusagen ein anderes ist (1.1), aber wir wissen, dass es „physisch“ dasselbe Image aus dem Registry ist.

Der tatsächliche Nachteil ist, dass es keine Unterstützung für Merge-Commits gibt, man muss ein Fast-Forward machen.
Man kann weiter gehen und einen Trick machen… Lassen Sie uns ein einfaches Dockerfile betrachten:
FROM ruby:2.3 as assets
RUN mkdir -p /app
WORKDIR /app
COPY . ./
RUN gem install bundler && bundle install
RUN bundle exec rake assets:precompile
CMD bundle exec puma -C config/puma.rb
FROM nginx:alpine
COPY --from=assets /app/public /usr/share/nginx/www/publicWir werden aus ihm eine Datei nach folgendem Prinzip erstellen, dass wir:
- SHA256 der verwendeten Image-IDs (
ruby:2.3undnginx:alpine), die die Prüfziffern ihres Inhalts sind; - alle Befehle (
RUN,CMDusw.); - SHA256 der Dateien, die hinzugefügt wurden.
… und wir nehmen die Prüfziffer (wieder SHA256) dieser Datei. Das ist die Signatur von allem, was den Inhalt des Docker-Images definiert.

Kehren wir zum Schema zurück und werden anstelle von Commits solche Signaturen verwenden, d.h. Images mit Signaturen taggen.

Jetzt, wenn wir beispielsweise Änderungen aus dem Release in das Master-Merge benötigen, können wir einen echten Merge-Commit durchführen: Er wird eine andere ID haben, aber dieselbe Signatur. Mit dieser ID können wir das Image auch in der Produktion ausrollen.
Der Nachteil ist, dass wir jetzt nicht bestimmen können, welcher Commit in die Produktion gezogen wurde – die Prüfziffern funktionieren nur in eine Richtung. Dieses Problem wird durch eine zusätzliche Schicht mit Metadaten gelöst – dazu werde ich später mehr erzählen.
Tagging in werf
In werf sind wir sogar noch weiter gegangen und bereiten uns darauf vor, einen verteilten Build mit einem Cache zu machen, der nicht auf einem einzigen Rechner gespeichert ist… Also, wir bauen Docker-Images zweier Typen, wir nennen sie stage und Image.
Im Git-Repository von werf werden spezifische Anweisungen für den Build gespeichert, die verschiedene Bauphasen beschreiben (beforeInstall, install, beforeSetup, setup). Das erste Stage-Image bauen wir mit der Signatur, die als Prüfziffer der ersten Schritte definiert ist. Dann fügen wir den Quellcode hinzu, für das neue Stage-Image berechnen wir dessen Prüfziffer… Diese Operationen werden für alle Phasen wiederholt, wodurch wir einen Satz von Stage-Images erhalten. Dann erstellen wir das finale Image, das auch Metadaten über seinen Ursprung enthält. Dieses Image taggen wir dann auf verschiedene Weisen (Details später).

Nach dieser Änderung wird ein neuer Commit erstellt, in dem nur der Code der Anwendung geändert wurde. Was passiert dann? Für die Codeänderungen wird ein Patch erstellt, ein neues Stage-Image vorbereitet. Seine Signatur wird als Checksumme des alten Stage-Images und des neuen Patches definiert. Aus diesem Image wird dann ein neues finales Image erstellt. Ein ähnliches Verhalten tritt bei Änderungen in anderen Stufen auf.
So sind Stage-Images ein Cache, der verteilt gespeichert werden kann, während die daraus erstellten Image-Images im Docker Registry hochgeladen werden.

Registry bereinigen
Es geht hier nicht um das Löschen von Schichten, die nach gelöschten Tags hängen geblieben sind – dies ist eine Standardfunktion des Docker Registry. Es geht um die Situation, in der viele Docker-Tags angesammelt werden und wir erkennen, dass ein Teil davon nicht mehr benötigt wird, aber Platz beansprucht (und/oder wir dafür zahlen).
Welche Bereinigungsstrategien gibt es?
- Man kann einfach nichts bereinigen. Manchmal ist es wirklich einfacher, etwas für zusätzlichen Platz zu bezahlen, als einen riesigen Knäuel von Tags zu entwirren. Aber das funktioniert nur bis zu einem bestimmten Punkt.
- Vollständiger Reset. Wenn alle Images gelöscht und nur die aktuellen in der CI-System neu aufgebaut werden, kann ein Problem auftreten. Wenn im Production ein Container neu gestartet wird, wird ein neues Image geladen – eines, das noch von niemandem getestet wurde. Das zerstört die Idee der unveränderlichen Infrastruktur.
- Blue-green. Wenn ein Registry überläuft – laden wir die Images in einen anderen hoch. Das gleiche Problem wie beim vorherigen Ansatz: Wann kann der Registry, der überläuft, bereinigt werden?
- Nach Zeit. Löschen wir alle Images, die älter als 1 Monat sind? Aber es wird mit Sicherheit einen Dienst geben, der einen ganzen Monat lang nicht aktualisiert wurde…
- Manuell bestimmen, was bereits gelöscht werden kann.
Es gibt wirklich zwei tragfähige Optionen: nichts bereinigen oder eine Kombination aus blue-green und manuell. Im letzteren Fall geht es folgendermaßen: Wenn Sie erkennen, dass es Zeit ist, den Registry zu bereinigen, erstellen Sie einen neuen und fügen alle neuen Images für einen Zeitraum von beispielsweise einem Monat hinzu. Nach einem Monat schauen Sie, welche Pods in Kubernetes weiterhin den alten Registry verwenden, und verschieben Sie sie ebenfalls in den neuen Registry.
Worauf kommen wir in werf? Мы собираем:
- Git head: alle Tags, alle Zweige, wobei angenommen wird, dass alles, was in Git getaggt ist, auch in den Images benötigt wird (und falls nicht, muss es im Git gelöscht werden);
- alle Pods, die derzeit in Kubernetes ausgeführt werden;
- alte ReplicaSets (was kürzlich erstellt wurde), sowie planen wir, die Helm-Releases zu scannen und die letzten Images dort auszuwählen.
… und wir erstellen aus diesem Set eine Whitelist – eine Liste von Images, die wir nicht löschen werden. Alles andere reinigen wir, nach dem wir die verwaisten Stage-Images finden und sie ebenfalls löschen.
Deploy-Phase
Zuverlässige Deklarativität
Der erste Punkt, auf den ich beim Deploying hinweisen möchte, ist die Bereitstellung der aktualisierten Konfiguration von Ressourcen, die deklarativ erklärt wurde. Das ursprüngliche YAML-Dokument mit der Beschreibung von Kubernetes-Ressourcen unterscheidet sich immer erheblich von dem Ergebnis, das tatsächlich im Cluster arbeitet. Das liegt daran, dass Kubernetes zur Konfiguration hinzufügt:
- Identifikatoren;
- Betriebsinformationen;
- viele Standardwerte;
- einen Abschnitt mit dem aktuellen Status;
- Änderungen, die im Rahmen der Funktionsweise eines Admission Webhooks vorgenommen wurden;
- das Ergebnis der Arbeit verschiedener Controller (und des Scheduler).
Deshalb, wenn eine neue Ressourcenkonfiguration erscheint (neu), können wir nicht einfach die aktuelle, "lebende" Konfiguration (live) überschreiben. Dafür müssen wir sie mit der vorherigen angewendeten Konfiguration ( neu last-applied) vergleichen und den erhaltenen Patch anwenden.Dieser Ansatz wird genannt live 2-way merge
. Er wird zum Beispiel in Helm verwendet. Es gibt auch ein3-way merge
, das sich dadurch unterscheidet, dass: beim Vergleichen, schauen wir, was gelöscht wurde;
- , schauen wir, was hinzugefügt oder geändert wurde; ) vergleichen und den erhaltenen Patch anwenden. und neuden kumulierten Patch wenden wir an
- , schauen wir, was hinzugefügt oder geändert wurde; neu und liveWir deployen über 1000 Anwendungen mit Helm, deshalb leben wir praktisch mit dem 2-way merge. Allerdings gibt es eine Reihe von Problemen, die wir mit unseren Patches gelöst haben, die Helm helfen, normal zu arbeiten.
- Der tatsächliche Status des Deployments live.
Nachdem unser CI-System bei der nächsten Veranstaltung eine neue Konfiguration für Kubernetes generiert hat, überträgt es sie zur Anwendung
(apply)
im Cluster – entweder mittels Helm oder . Darüber hinaus geschieht der bereits beschriebene N-way merge, auf den die Kubernetes API positiv auf das CI-System reagiert, und dieses wiederum – auf seinen Benutzer. Es gibt jedoch ein riesiges Problem: denn kubectl applyerfolgreiche Anwendung bedeutet nicht erfolgreiche Bereitstellung

. Wenn Kubernetes verstanden hat, welche Änderungen angewendet werden müssen, und sie anwendet – wissen wir noch nicht, was dabei herauskommt. Zum Beispiel kann das Update und der Neustart von Pods im Frontend erfolgreich verlaufen, während es im Backend nicht so ist, und wir erhalten verschiedene Versionen der laufenden Anwendungs-Images. . Wenn Kubernetes versteht, welche Änderungen angewendet werden müssen, wendet es diese an — wissen wir noch nicht, was das Ergebnis sein wird. Zum Beispiel kann das Update und der Neustart von Pods im Frontend erfolgreich sein, während es im Backend nicht der Fall ist, und wir unterschiedliche Versionen der laufenden Anwendungsbilder erhalten.. Wenn Kubernetes verstanden hat, welche Änderungen angewendet werden müssen, wendet es diese an – das Ergebnis wissen wir noch nicht. Zum Beispiel kann das Aktualisieren und Neustarten von Pods im Frontend erfolgreich sein, während es im Backend möglicherweise nicht funktioniert, und wir erhalten unterschiedliche Versionen der laufenden Anwendungsbilder.
Um alles korrekt zu gestalten, bietet sich in diesem Schema ein zusätzliches Glied an – ein spezieller Tracker, der Informationen über den Status von der Kubernetes API erhalten und für die weitere Analyse der tatsächlichen Gegebenheiten weitergeben wird. Wir haben eine Open Source-Bibliothek in Go geschaffen – (siehe ihre Ankündigung ), – die dieses Problem löst und in werf integriert ist.
Das Verhalten dieses Trackers auf der Ebene von werf wird über Annotations konfiguriert, die auf Deployments oder StatefulSets gesetzt werden. Die Hauptannotation ist fail-mode — versteht folgende Werte:
-
IgnoreAndContinueDeployProcess— wir ignorieren Probleme mit dem Ausrollen dieses Komponenten und setzen das Deployment fort; -
FailWholeDeployProcessImmediately— ein Fehler in diesem Komponenten stoppt den Ausrollprozess; -
HopeUntilEndOfDeployProcess— wir hoffen, dass dieser Komponenten bis zum Ende des Deployments funktioniert.
Zum Beispiel, eine solche Kombination aus Ressourcen und Werten der Annotation fail-mode:

Wenn wir das erste Mal deployen, kann die Datenbank (MongoDB) noch nicht bereit sein – die Deployments werden fehlschlagen. Aber man kann warten, bis sie hochgefahren ist, und das Deployment wird trotzdem durchgehen.
Es gibt noch zwei Annotations für kubedog in werf:
-
failures-allowed-per-replica— die Anzahl der erlaubten Fehler pro Replik; -
show-logs-until— regelt den Zeitpunkt, bis zu dem werf (im stdout) Logs von allen auszurollenden Pods zeigt. Standardmäßig ist dasPodIsReady(um Nachrichten zu ignorieren, die uns wahrscheinlich nicht interessieren, wenn der Pod beginnt, Traffic zu empfangen), jedoch sind auch die WerteControllerIsReadyundEndOfDeploy.
Was möchten wir noch vom Deployment?
Neben den bereits beschriebenen zwei Punkten möchten wir:
- sehen Logs — und zwar nur die notwendigen, nicht alle zusammen;
- den Fortschritt, denn wenn ein Job „still“ einige Minuten hängt, ist es wichtig zu verstehen, was dort passiert;
- ein automatisches Rollback zu haben, falls etwas schiefgeht (deshalb ist es entscheidend, den tatsächlichen Status des Deployments zu kennen). Der Rollout muss atomar sein: er geht entweder bis zum Ende durch oder alles kehrt in den vorherigen Zustand zurück.
Ergebnisse
Uns als Unternehmen reicht es, um alle beschriebenen Nuancen in den verschiedenen Phasen der Lieferung (build, publish, deploy) umzusetzen, eine CI-Plattform und das Tool .
Abschließend:

Mit werf sind wir gut darin vorangekommen, eine Vielzahl von Problemen von DevOps-Ingenieuren zu lösen, und wir würden uns freuen, wenn eine breitere Gemeinschaft zumindest versucht, dieses Tool in der Praxis zu verwenden. Zusammen gute Ergebnisse zu erzielen, wird einfacher sein.
Videos und Folien
Video von der Präsentation (~47 Minuten):

Präsentation des Berichts:
P.S.
Weitere Berichte über Kubernetes in unserem Blog:
- «» (Dmitri Stolyarov; 27. April 2019 auf der „Stachka“);
- «» (Andrey Polovov; 8. April 2019 auf Saint HighLoad++);
- «» (Dmitri Stolyarov; 8. November 2018 auf HighLoad++);
- «» (Dmitri Stolyarov; 28. Mai 2018 auf RootConf);
- «» (Dmitri Stolyarov; 7. November 2017 auf HighLoad++);
- «» (Dmitri Stolyarov; 6. Juni 2017 auf RootConf).
Quelle: habr.com
