Bandbreitenlimit für Interfaces mit systemd

WireGuard lässt sich gut verwenden um in fremden Netzwerken (zum Beispiel einem öffentlichen WLAN) eine sichere Verbindung zum heimischen Router herzustellen. Meist ist die eigene DSL-Anbindung jedoch nicht symmetrisch. Der bereite Downstream liefert für das VPN ständig Datenpakete nach, die der Upstream nach dem Verschlüsseln nicht mehr versendet bekommt. Für die zu Hause gebliebenen Benutzer bleibt dann nur noch wenig Upstream-Bandbreite übrig. Videokonferenzen Ruckeln oder das Home-Office macht keinen Spaß mehr.

Es muss also eine Lösung her, um das WireGuard-Interface auf einen Teil der möglichen Bandbreite zu begrenzen, damit alle friedlich koexistieren können.

Disziplin

Für die Priorisierung von ausgehenden Datenpakete auf einem Netzwerkinterface verwendet Linux eine “Queuing Discipline” oder kurz “QDisc”. Dies ist - vereinfacht ausgedrückt - ein Scheduler, welcher die Datenpakete priorisiert und dafür sorgt, dass bei mehrere Verbindungen alle die Bandbreite gleichmäßig zugeteilt bekommen. Bei einem modernen Linux System ist die Standard QDisc “FQ CoDel”. Dies ist die Abkürzung für Fair Queuing (FQ) with Controlled Delay (CoDel). Welche QDisc ein Netzwerkinterface gerade verwendet lässt sich über tc qdisc show dev <Interface>1 herausfinden.

Einschränkungen

FQ Codel ist eine klassenlose QDisc, sie weiß nichts von Paketprioritäten. Sie versucht die verschiedenen Datenströme fair auszubalancieren. Auf den Traffic Einfluss zu nehmen, ist nicht vorgesehen. Es gibt jedoch verschiedene QDiscs, welche dies können. Die einfachste ist der Token Bucket Filter (TBF). Diese QDisc ist ein reiner Traffic Shaper. Der TBF versucht nicht die Datenströme auszubalancieren oder umzusortieren. Generell sollte dieser Shaper mit einer anderen QDisc kombiniert werden. Im Fall des WireGuard-Interfaces genügt diese einfache QDisc, da die Datenpakete sowieso über ein anderes Netzwerkinterface versendet werden. Die dort vorhandene QDisc kann sich dann um die Priorisierung der Datenströme kümmern.

Der Token Bucket Filter ist einfach aufgebaut: Ausgehende Datenpakete werden in einen FIFO2-Puffer gesammelt. Damit ein Byte den FIFO verlassen kann, benötigt es ein Token. Diese Token werden in einem Token-Speicher (dem Bucket) gesammelt. Solange Token verfügbar sind, werden die Datenpakete aus dem FIFO an das Netzwerkinterface weitergegeben3. Sind die Token aufgebraucht, stauen sich die Datenpakete bis neue Token verfügbar sind. Der Kernel stellt in regelmäßigen Abständen neue Tokens in den Token-Speicher. Wie viele dies sind, hängt von der konfigurierten Bandbreite ab. Da die Menge der Token je Zeiteinheit konstant ist, wird die Bandbreite begrenzt.

Die Länge des FIFO und die Größe des Token-Speichers müssen konfiguriert werden, damit der TBF tut, was er soll.

Größe des FIFO

Die Länge des FIFO kann über zwei Parameter festgelegt werden limit oder latency. In der Regel ist es einfach, die maximale Latenz des Puffers festzulegen und den TBF seine Länge selbst bestimmten zu lassen.

Ist der FIFO-Puffer voll, werden weiter Datenpakete verworfen. Dies führt dazu, dass der sendende Host die Datenpakete erneut übermitteln muss und seine Geschwindigkeit drosselt. Hierüber realisiert der TBF die Bandbreitenbegrenzung.

Tick/Tack

Die eine Hälfte des TBF hätten wir. Jetzt ist nur noch die Frage: Wie entstehen neue Token?

Der Kernel stellt neue Token mit einer festen Frequenz bereit. Hierfür wird der Zeitgeber-Interrupt verwendet. Seine Frequenz wird beim Erstellen des Kernels über die Konfigurationsvariable CONFIG_HZ festgelegt. Mit zgrep 'CONFIG_HZ=' /proc/config.gz lässt sich der aktuelle Wert abrufen. Bei meinem Arch-Linux Setup sind es 300 Hz. Das bedeutet, dass dreihundertmal pro Sekunde neue Token zum Token-Speicher hinzugefügt werden.

Die Größe des Token-Speichers wird bei der Konfiguration des TBF festgelegt. Der Kernel kann nicht mehr Token in den Token-Speicher einfügen, wie dieser fassen kann. Überzählige Token werden verworfen. Da Frequenz feststeht, darf der Token-Speicher also nicht zu klein sein, damit die gewünschte Datenrate auch erreicht wird. Wäre der Token-Speicher 100 Tokens groß, könnten bei einem CONFIG_HZ-Wert von 300 maximal 30000 Token je Sekunden eingefügt werden. Verbraucht jedes Byte ein Token, lassen sich nicht mehr als $30000\,{{Token}/s}·{8\,{Bit}/{Token}}=240\,{{kBit}/s}$ erreichen.

Wie groß der Token-Speicher für eine bestimmte Bandbreite mindestens sein muss, lässt sich mit folgender Formel berechnen:

$${Token-Speicher_{min}}={Rate}_{max}/{CONFIG\_HZ}/{8\,{Bit}/{Token}}$$

Um beispielsweise eine Datenrate von 1 MBit/s zu erreichen, müssen je Takt ${1\,{Mbit}/s}/{300Hz}/{8\,{Bit}/{Token}}=417\,Tokens$ bereitgestellt werden. Damit muss der Token-Speicher mindestens 417 Tokens aufnehmen können, damit die gewünschte Datenrate erreicht wird.

Die Größe des Token-Speichers wird über die burst-Einstellung festgelegt.

Bigger is better?

Aber warum verwendet der TBF dann keinen riesigen Token-Speicher, der für alle Bandbreiten ausreicht?

Werden bis zur nächsten Füllung des Token-Speichers nicht alle Token verbraucht, werden die neuen Token zu den bereits vorhandenen hinzugefügt. Sind später wieder viele Datenpakete im FIFO, steht eine große Menge von Token bereit, um die Daten zu versenden. Hierdurch entsteht ein Schwall (Burst)4 an Datenpaketen. Wie groß dieser Schwall ist, lässt sich über die Größe des Token-Speichers beeinflussen. Ein riesiger Token-Speicher würde also dafür sorgen, dass die Bandbreite nach einer längeren Ruhephase den eingestellten Wert deutlich überschreitet und einige Zeit braucht bis sie sich einpendelt. Dies ist meistens kein erwünschter Effekt5.

Systemd

Nach all der Vorrede lassen sich nun die Einstellungen für ein einfaches Bandbreitenlimit von 1 MBit/s bei einer maximalen Verzögerung von 50 ms ausrechnen:

ParameterWert
latency50 ms
burst500 byte6
rate1 MBit/s

Über Systemd können diese Werte bequem für ein Interface konfiguriert werden. Dies erfolgt in der .network-Datei:

[TokenBucketFilter]
LatencySec=50ms
BurstBytes=500
Rate=1M

Der Abschnitt TokenBucketFilter aktiviert automatisch die TBF QDisc auf dem Interface und konfiguriert das Bandbreitenlimit entsprechend. Nachdem das Interface mit diesen Werten konfiguriert wurde, zeigt tc qdisc show dev wg0 an, das alles geklappt hat:

qdisc tbf 8001: root refcnt 2 rate 1Mbit burst 500b lat 50ms

Über das tc Kommando lässt sich die QDisc auch bearbeiten, um temporäre Änderungen vorzunehmen. Ein tc qdisc replace dev wg0 handle 8001: root tbf rate 2Mbit burst 1k latency 50ms erhöht die Bandbreite zum Beispiel temporär auf 2 MBit/s7.


  1. Über tc qdisc show lassen sich auch die QDiscs aller Netzwerkschnittstellen anzeigen. ↩︎

  2. First in, first out: Ein Puffer, den dem Datenpakete in der Reihenfolge verlassen, in welcher sie hinzugefügt wurden. Der Stau vor einer Ampel ist ein klassischer FIFO. Wer zuerst an der roten Ampel ankommt, kann auch zuerst weiterfahren, wenn die Ampel grün wird. ↩︎

  3. Dabei verbrauchen leere Datenpakete ebenfalls mindestens ein Token, da auch sie den Link kurz belegen. ↩︎

  4. Es gibt Möglichkeiten einen TBF so zu konfigurieren, dass keine Bursts auftreten. Dies ist in der man-Page beschrieben. Da diese Bursts im Normalfall nicht stören, beschäftigen uns erst einmal mit der einfachen Konfiguration, bei welcher Bursts auftreten können. ↩︎

  5. Eventuell will man nur die mittlere Datenrate limitieren. Kurze Ausreißer stören nicht und es ist besser, wenn kurze Anfragen schnell abgearbeitet werden, weil dann für die bandbreitenlimitierten Benutzer kleine Downloads schneller beendet werden. ↩︎

  6. Etwas mehr schadet nicht und sorgt dafür, dass die gewünschte Bandbreite auch erreicht wird. ↩︎

  7. Dabei wurde eine Größe für den Token-Speicher von 1000 Tokens angegeben, obwohl 834 Token gereicht hätten. 😱 ↩︎