[de] Microservices, Performance, RPC und PHP,

Ich hatte neulich ein interessantes Gespräch mit @andygrunwald und @matthiasendler. Wir hatten darüber debattiert, wie hunderte von Microservices in einen Request involviert sein könnten und dennoch die Antwortzeiten akzeptabel bleiben.

Mit klassischen Webservices (REST, SOAP, …) geht dies nur bedingt, ist ein Serializer wie z.b. JMS noch involviert, dürften die Antwortzeiten wohl mehr als gruselig sein. Ganz davon ab ist ein sinnvolles Fehlerhandling / Monitoring wohl extrem schwierig.

RPC

Schaut man sich an, was z.b. durch das Google / Facebook / Twitter / Netflix Netzwerk fließen muss, so wird man schnell über GRPC, Thrift, dubbo und finangle stolpern. RPC scheint dort seit vielen Jahren gut zu funktionieren, in klassischen Webanwendungen ist es aber kaum anzutreffen.

Sucht man im PHP Umfeld nach RPC Lösungen, findet man einige JSON-RPC Bibliotheken - diese erfordern jedoch i.d.R. pro Request ein eigenen HTTP Request. Der Benefit zu den “klassischen Rest Webservices” (s.o.) müsste in unserem Fall somit gegen 0 gehen.

Nachdem ich kurzerhand nichts passendes gefunden habe, habe ich einen Prototyp für einen RPC Server geschrieben.

Der Prototyp ist rein zum Benchmarken von Möglichkeiten, ich beschloss diesen in PHP zu schreiben. Sollte es so weit kommen, würde ich jeweils Server, Worker und Client nochmal in Rust implementieren.

Für den Moment möchte ich nur meine Eindrücke sammeln um generell den Ansatz zu beschreiben. Ich gehe derzeit stark davon aus, dass sich der Durchsatz noch drastisch erhöhen lässt.

Grundaufbau

Der derzeitige Prototyp nutzt 4 Bestandteile.

  • Client
    • Baut Verbindung zu einem RPC Server auf.
  • Angel
    • Startet Server und Worker und stellt sicher, dass diese gestartet bleiben.
  • Server
    • Verarbeitet die Anfragen vom Client.
  • Worker
    • Baut eine Verbindung zum Server auf und beantwortet RPC Requests.

Der RPC Request findet in einem klassischen Webrequest statt. Bis dato habe ich nur einen Beispielclient implementiert. Letztlich wird eine TCP Verbindung zu einem RPC Server aufgemacht und die entsprechende Nachricht übermittelt. An dem RPC Server sind Worker registriert, welche die Nachricht entgegennehmen, verarbeiten und eine Antwort senden.

Jeder RPC Request muss vom Worker beantwortet werden:

Als Kommunikationsformat bietet sich z.b. Protobuf an.

Lernings bis Dato

Mein derzeitiger Prototyp ist komplett in PHP geschrieben. Startet man einige Worker und einen Client, kann ich auf meinem lokalen System (MacBook Pro) rund 2000+ RPC Requests pro Sekunde beantworten.

Ich habe Zuhause drei Rechner direkt mit einem Gigabit Switch verbunden, mit denen ich primär bastel. Die Rechner haben jeweils einen AMD Phenom(tm) II X4 955 Processor und 16GB DDR3 Ram.

Lässt man die Skripte dort unter PHP 7.0.15-1~dotdeb+8.1 (cli) ( NTS ) laufen, ergibt sich:

| Rechner       |  Dienst     | CPU Last        | RPC Request / s              |
| ------------- |-------------| --------------- | ---------------------------- |
| 1             | 4x Client   | 12%,12%,12%,12% | 4,33k, 4,33k, 4,33k, 4,33k   |
| 2             | 4x Worker   | 19%,19%,19%,19% |                              |
| 3             | 1x Server   | 0%,0%,0%,65%    |                              |

Nach ca. 3 Stunden Laufzeit ergibt sich bei den Clients dieses Bild:

| Client       |  RPC Calls     | Laufzeit        |
| ------------- |---------------| --------------- |
| 1             | 44,5M         | 2:53:22         |
| 2             | 47,6M         | 3:00:33         |
| 3             | 44,4M         | 2:53:40         |
| 4             | 44,4M         | 2:53:42         |

und nach einer Nacht:

| Client       |  RPC Calls     | Laufzeit        |
| ------------- |---------------| --------------- |
| 1             | 213M          | 13:50:26        |
| 2             | 210M          | 13:43:59        |
| 3             | 210M          | 13:43:24        |
| 4             | 210M          | 13:43:05        |

Der Ramverbrauch war dabei konstant niedrig (htop sprach von 0,3%).

Selbst ohne jegliche Optimierung mit einem in PHP geschriebenen RPC Server lassen sich auf den Rechnern somit über 20.000 RPC Reqests pro Sekunde beantworten.

Die aktuellste Version ist etwas langsamer, letztlich geht es für den Moment aber auch mehr darum, mit der Implementation zu spielen um zu erarbeiten, wie ein RPC-Server für die PHP-Welt aussehen müsste.

Next Steps

20.000 RPC Requests pro Sekunde sind schon ganz nett, es ging mir jedoch in erster Linie darum einen ersten Proof zu haben, inwieweit es realistisch sein könnte, mit wirklichen vielen kleinen Microservices agieren zu können.

Für mich ergibt sich nun das Bild, dass dies selbst bei einer performancetechnisch schwachen PHP Implementation möglich sein könnte.

Nachdem wir nun hinter Performance vorerst ein Haken setzen können, kann man sich ein wenig damit beschäftigen, was für Attribute eine Implementation haben müsste.

Sprachunabhängigkeit

Client / Server(+Worker) / Angel können in beliebigen Sprachen implementiert werden. Das Protokoll ist denkbar einfach.

Derzeit werden schlicht Bytes ausgetauscht, in der Paxis möchte ich es möglichst einfach machen, Protobuf Messages zu verschicken. Um die Hürde so gering wie möglich zu halten bietet sich zudem JSON als Austauschformat an.

Beispielsweise wäre es möglich, Server und Worker in Rust / Go / … zu schreiben und die Daten via Protobuf an den Client zu übermitteln.

Performance

Es hört sich paradox an, aber durch das Auslagern der Calls lässt sich durchaus Performance gewinnen. Der wohl wichtigste Grund dafür ist, dass mehrere RPC Abfragen parallel abgearbeitet werden können. Die Worker können zudem in performanteren Sprachen geschrieben sein oder einen eigenen Cache halten.

Es wäre auch durchaus möglich, dass man einen Server implementiert, der als Load Balancer fungiert und die RPC Messages an passende Server/Worker weiterleitet um Cache-Hits zu erhöhen.

Auf jedem Webserver könnte ein eigener RPC-Server laufen, welcher Requests nur an weitere RPC-Server leitet, jedoch die entsprechende TCP-Connectionen offen hält. Dies würde dazu führen, dass Nachrichten extrem schnell und ressourcenschonend weitergeleitet werden könnten. Die Connection zu dem lokalen RPC Server könnten sogar über UDP / SharedMemory / IPC gehändelt werden.

Resilience

Timeouts

In PHP ist es teilweise schon schwierig Timeouts für Operationen zu definieren. In RPC Calls ist dies recht einfach möglich. Der Client definiert schlicht bis wann er eine Antwort gerne hätte, Worker verarbeiten diese nur so lange, bis der Zeitpunkt überschritten wurde.

Monitoring

Die Worker laufen in einem festdefinierten Scope, demnach lassen sich diese gut überwachen. Ein einfaches Monitoring vom Server und somit auch den Workern kann dadurch einfach mitgeliefert werden. Beispielsweise könnte jeder Server Statusseiten für Tools wie Prometheus zurückliefern oder aktiv loggen.

Dabei hört es aber noch nicht auf, die Worker können jederzeit mit dem Server kommunizieren, somit auch Commandos an diesen senden. Beispielsweise könnten Statistiken über eine standardisierte API (RPC Calls :)) geschickt werden, der Server kann diese verarbeiten und an das entsprechende Monitoring Tooling weiterleiten. Selbst komplexere Statistiken sind so völlig unabhängig von der Umgebung aufzuzeichnen.

Queuing

Die RPC Nachrichten liegen bis sie verarbeitet wurden im Server, stirbt ein Worker bei der Verarbeitung so wird die RPC Nachricht einfach neu eingereiht und vom nächsten Worker verarbeitet. Sicherheitsmaßnahmen wie Timeouts und Grenzwerte wie oft eine RPC Nachricht einen Worker sterben lassen darf, ließen sich bestimmen. Verdächtige Nachrichten könnte man isolieren, loggen und den Entwicklern bereitstellen.

Versioning

Als Austauschformat ist Protobuf geplant, Protobuf unterstützt Versioning.

Scaling

Sprachen und Teams

Ist ein Austauschformat und ein Servicename definiert (proto File), lässt sich der Server/Worker in jeder unterstützten Sprache implementieren. Dies sorgt dafür, dass Teams unterschiedliche Sprachen einsetzen können. Dies bindet Entwickler / Teams nicht an eine Sprache, sorgt für Weiterbildungsmöglichkeiten, Zufriedenheit und einen leichteren Recruitingprozess.

Kleinere Bausteine

Werden kleinere Services mit klaren Schnittstellen entwickelt, so lassen sich diese einfacher ersetzen / entfernen. Kleinere Services sind von Natur aus weniger komplex. Weniger komplex bedeutet i.d.R. einfacher zu warten. Zudem muss sich ein Entwickler nicht immer mit dem großen Ganzen beschäftigen.

Routing

Beispielsweise wäre es möglich neue Worker nach und nach zu deployen. Komplexere LoadBalancing Methoden wären denkbar. Auch würde sich Traffic klonen lassen, um diese gegen alternative Implementationen laufen zu lassen, oder noch besser, mehrere Implementationen miteinander automatisiert zu vergleichen.

Priorisierung

Nachrichten könnten sich auch priorisieren lassen. Clients könnten definieren, wie wichtig eine Nachricht ist. Theoretisch könnten Proxies jedoch auch lernen, wie lange gewisse Nachtichten brauchen und versuchen Messages so zu priorisieren, dass Clients möglichst alle Antworten zeitgleich bekommen.

Decoration

Letztlich ist eigentlich nur wichtig, dass es einen Client gibt, der einen RPC Request irgendwo hinschickt, schließlich ist seitens des Clients der Server in der Verantwortung eine Antwort zu schicken.

Es wäre also durchaus möglich, auf den eigentlichen Worker zu verzichten bzw. Worker und Server zusammenzulegen:

Es sind aber auch komplexere Szenarien möglich:

Im Falle einer Programmiersprache wie PHP ergibt es vermutlich Sinn Server und Worker zu trennen, um mehr als ein CPU Kern zu belasten. Wichtig ist letztlich nur die Erkenntnis, dass sich Server/Worker beliebig schachteln könnten.

Dabei spielt es keine Rolle, in welcher Sprache welcher Server/Worker implementiert ist.

Services

Discovery

Derzeit habe ich nur mit einem RPC Service experimentiert. Werden es mehr, stellt sich primär die Frage, wie der passende Service gefunden wird.

Hier gibt es unterschiedliche Strategien. Ich wäge hier noch ab.

Dev Mode

PHP Entwickler sind es gewohnt, ein paar Zeilen zu verändern und direkt das Resultat testen zu können. Ich suche hier derzeit noch nach einer schlauen Lösung, derzeit schwebt mir vor einen Entwicklungsmodus zu realisieren, welcher Worker erst spawnt wenn diese gebraucht werden und diese nur Anfragen von einem Clientrequest abarbeiten lässt. Somit wäre es möglich pro Webseitenaufruf ein neuen Worker zu spwanen. PHP Entwickler könnten wie gewohnt Code im Worker verändern, die Seite neu laden und mit dem neuen Worker agieren.

Warum nicht Thrift / Grpc?

Beide Lösungen würden sich gut eigenen. Grpc ermöglicht unter PHP das schreiben von RPC-Servern nicht. Die Argumentation ist plausibel, PHP ist nicht die optimale Sprache für solche Arbeiten. Jedoch darf man nicht vergessen, dass sehr viele bestehende Anwendungen in PHP geschrieben sind. Ich gehe davon aus aus, dass diese sich deutlich besser refactorieren lassen, wenn man vorerst eine PHP Implementation zur Verfügung stellen könnte und später bei Bedarf diese gegen eine Implementation in einer anderen Sprache tauschen könnte. Zumal PHP für viele Anwendungsfälle mit Sicherheit auch ausreichen könnte.

Dazu kommt die Problematik, dass es noch keine stabile Implementation von GRPC in Rust gibt.

Thrift fühlt sich unter PHP wirklich gut an, dies liegt vermutlich daran, dass es von Facebook stammt. Leider scheitert es es hier auch an einer stabilen Rust Implementation, zudem (korrigiert mich wenn ich falsch liege) benötigt der Thrift Server ein HTTP Server und somit pro RPC Call ein eigenen Request. Mehrere Tausend Requests pro Sekunde pro Worker sind so kaum realisierbar.

Letztlich habe ich leider keine brauchbare RPC-Lösung unter PHP gefunden.