Schnellere Rust-CI-Pipelines, die Tarpaulin verwenden

Rust ist eine großartige Sprache. Wir geben Tipps, um quälend langsame Neukompilierungen bei der Verwendung des Code-Coverage-Tools Cargo Tarpaulin zu minimieren.

Lesezeit:
6 min
Schnellere Rust-CI-Pipelines, die Tarpaulin verwenden Image by dashu83 on Freepik

Rust ist eine großartige Sprache. Sie bietet nicht nur eine Laufzeit-Performance, die gleichauf mit Sprachen wie C und C++ ist, sondern verhindert auch, dass man sich dank vieler Kompilierzeitprüfungen selbst ins Bein schießt. Dies bedeutet jedoch auch, dass der Compiler im Vergleich zu einigen anderen Sprachen viel mehr Arbeit leisten muss: Borrow-Checker, Typen, Generics, Makros, LLVM-Optimierung… Während diese Funktionen zu einem besseren Produkt führen, möchte niemand 30 Minuten herumsitzen, nur um festzustellen, dass ein einziger Test aufgrund eines Tippfehlers fehlgeschlagen ist.

In diesem Artikel gehen wir auf einige spezifische Tipps ein, um quälend langsame Neukompilierungen während der Verwendung des Code Coverage Tools Cargo Tarpaulin zu minimieren.

Verwendung von Caches zur Beschleunigung von Pipelines

Eine einfache Lösung zur Beschleunigung der meisten Pipelines ist das Hinzufügen eines Caches, der über die Pipeline-Ausführung hinweg bestehen bleibt. Das folgende Beispiel verwendet Gitlab caches, um einen separaten Rust-bezogenen Cache für jeden Git-Branch zu haben:

variables:
  CARGO_HOME: $CI_PROJECT_DIR/cargo
cache:
  key: $CI_COMMIT_REF_SLUG
  paths:
    - cargo/
    - target/
tests:
  script:
    - cargo test

Man beachte, dass wir auch das Home-Verzeichnis von Cargo zwischenspeichern, indem wir die Umgebungsvariable setzen. Dadurch wird verhindert, dass jeder Pipeline-Job alle Abhängigkeiten jedes Mal von Grund auf neu herunterladen und kompilieren muss. Warum sollte man etwas neu kompilieren, das sich nicht ändert, oder? Es stellt sich heraus, dass das nicht immer so einfach ist…

Was ist Tarpaulin?

Tarpaulin ist ein Code-Coverage-Tool für das Cargo Build-System. Es liefert Metriken dafür, wie viele Zeilen von Unit- und Integrationstests abgedeckt werden, was bei der Identifizierung möglicher Fehler durch fehlende Tests sehr hilfreich sein kann. Ein Beispiel aus unserer Crypto-Bibliothek bei Identeco könnte wie folgt aussehen:

> cargo tarpaulin
Sep 09 13:26:47.297  INFO cargo_tarpaulin::report: Coverage Results:
|| Uncovered Lines:
|| src/credential_check/client.rs: 72, 88
|| src/credential_check/server.rs: 97, 115
|| ...
|| Tested/Total Lines:
|| src/credential_check/client.rs: 28/30 -6.67%
|| src/credential_check/server.rs: 63/65 -3.08%
|| ...
97.68% coverage, 547/560 lines covered, -0.6909937888198812% change in coverage

Hier können wir sehen, dass 13 Zeilen derzeit nicht abgedeckt sind. Tarpaulin sagt uns, wo und wie sich die Testabdeckung im Vergleich zum letzten Lauf verändert hat. Großartig! Wenn wir cargo tarpaulin anstelle von cargo test in unserer CI-Pipeline ausführen, können wir die Änderung der Testabdeckung in der Merge-Request anzeigen lassen und den Reviewern helfen, sicherzustellen, dass neue Änderungen ordentlich getestet werden.

Pipeline #10166 passed for e3bff582 on 16-fix-and-improve-... - 5 hours ago - Test coverage 100.00% (+12.04%) from 1 job
Bild: Diese Pipeline zeigt +12.04% Abdeckung

Integration von Tarpaulin in die Pipeline

Nun zurück zu unserem ursprünglichen Problem: Wir wollen unsere Pipeline beschleunigen und Neukompilierungen vermeiden während wir gleichzeitig Tarpaulin verwenden. Der erste Gedanke könnte sein, einen Pipeline-Cache wie oben gezeigt zu verwenden, cargo test gegen cargo tarpaulin auszutauschen und Feierabend zu machen. Aber wie sich herausstellt, verwendet Tarpaulin das --force-clean Flag und kompiliert standardmäßig alles neu, unabhängig davon, ob bereits eine inkrementelle Kompilierung existiert. Das wird gemacht, um einige kleinere Fehler bei der Abdeckung von Cargo zu vermeiden, aber führt eben auch dazu, dass unser Cache bei jedem Pipeline-Lauf bereinigt wird.

Das verwirrende Verhalten von --skip-clean

Die Verwendung von cargo tarpaulin --skip-clean sollte also alles beschleunigen, richtig? Nun, isoliert betrachtet, ja, aber am Ende man könnten sich am Kopf kratzen, warum alles noch langsamer wird als vorher. Schauen wir uns den folgenden Ausschnitt aus der Gitlab-Pipeline an:

variables:
  CARGO_HOME: $CI_PROJECT_DIR/cargo
cache:
  key: $CI_COMMIT_REF_SLUG
  paths:
    - cargo/
    - target/
check:
  script:
    - cargo check
tests:
  script:
    - cargo tarpaulin --skip-clean

Wir haben zwei Pipeline-Jobs: check und tests. Beide verwenden den global definierten Pipeline-Cache mit den Verzeichnissen target/ und cargo/. Hier ist der Haken: Nicht nur, dass tarpaulin das --skip-clean Flag zu ignorieren scheint, auch cargo check muss nun alles neu kompilieren! Dieses Verhalten kann auch reproduziert werden, indem die Befehle lokal ausgeführt werden, was besonders ärgerlich ist, wenn die eigene IDE cargo check regelmäßig ausführt.

Neukompilierung aufgrund von Rustflags

Wie sich nach etwas Suchen durch Issues auf GitHub herausstellte, verwendet Tarpaulin verschiedene RUSTFLAGS zum Kompilieren:

> cargo tarpaulin --print-rust-flags
Sep 09 14:11:55.260  INFO cargo_tarpaulin::config: Creating config
Sep 09 14:11:55.367  INFO cargo_tarpaulin: Printing `RUSTFLAGS`
RUSTFLAGS="-Cdebuginfo=2 --cfg=tarpaulin -Clink-dead-code"

Aha! Inkrementelle Kompilierungsdateien können nur wiederverwendet werden, wenn sie mit denselben RUSTFLAGS kompiliert werden. Das erklärt das seltsame Verhalten von oben: Das --skip-clean scheint ignoriert zu werden, da der ursprüngliche Build von cargo check aufgrund der unterschiedlichen Flags nicht von cargo tarpaulin verwendet werden kann. Aber jetzt überschreibt Tarpaulin diesen Build mit seinem eigenen Coverage-Build, der im Gegenzug von cargo check im nächsten Pipeline-Lauf nicht mehr verwendet werden kann, was unseren Cache im Grunde nutzlos macht.

Zum Zeitpunkt des initialen Schreibens war dieses Rekompilierungsverhalten nicht explizit dokumentiert, wurde aber inzwischen in die Tarpaulin-Dokumentation aufgenommen:

As Tarpaulin changes the RUSTFLAGS when building tests sometimes rebuilds of test binaries can’t be avoided. There is also a --clean and --skip-clean argument, the default has been changed at times to avoid issues with incremental compilation when changing RUSTFLAGS. If you aim to reduce the amount of unnecessary recompilation attempting to add the --skip-clean flag should be the first step. After that you can either:

  1. Use cargo tarpaulin --print-rust-flags and use those flags for dev and coverage
  2. Use --target-dir when running tarpaulin and have a coverage build and dev build

Was ist die Lösung?

Es gibt zwei Möglichkeiten: Entweder wir fügen einen separaten Pipeline-Cache zum tests-Job hinzu, oder wir können ein anderes --target-dir für Tarpaulin festlegen. Anstatt ein Flag zu übergeben, kann dies auch in tarpaulin.toml konfiguriert werden, um Verwirrung für Entwickler zu vermeiden, die Tarpaulin lokal ausführen. Wir haben uns dafür entschieden, das Zielverzeichnis auf target/tarpaulin zu setzen. Wir hatten in der Vergangenheit Probleme mit dem --target-dir Flag, wodurch manchmal Build-Artefakte mit in die Coverage-Berechnung eingeflossen sind. Deswegen sind wir mittlerweile auf die Lösung mit den verschiedenen Caches umgestiegen. Falls Sie GitLab-Ci verwenden, gehen Sie sicher, dass sie unterschiedliche Schlüssel wie rust-$CI_COMMIT_REF_SLUG und tarpaulin-$CI_COMMIT_REF_SLUG für die Caches verwenden.

Nach einem Austausch mit dem Tarpaulin-Maintainer wies er darauf hin, dass dies aufgrund des daraus resultierenden aufgeblähten Zielverzeichnisses nicht der Standard ist. Außerdem sollte dieses Verhalten in Zukunft mit diesem Issue verbessert werden, das auf Umsetzung eines anderen RFC wartet.

Wenn Sie die Kompilierungszeiten von Rust allgemein weiter beschleunigen wollen, können Sie sich auch diesen Blog von Endler.dev ansehen.

Ein Hinweis auf fehlende Abdeckung

Erinnern Sie sich an die Beispielausgabe für Tarpaulin, die ich oben gezeigt habe? Nun, die vermeintlich nicht abgedeckten Zeilen sind tatsächlich abgedeckt. Unabhängig davon, ob --skip-clean verwendet wird oder nicht, Tarpaulin kämpft immer noch mit Abdeckungsfehlern, was bei Entwicklern zu Verwirrung führen kann.

In unserem speziellen Beispiel scheint es, dass die Formatierung zu Fehlern in der Zeilenabdeckung führen kann, da der folgende unformatierte Einzeiler abgedeckt ist:

let response = self.send_request(request).await.map_err(ClientError::Request)?;

Während das formatierte mehrzeilige Äquivalent trotz expliziter Tests als nicht abgedeckt gekennzeichnet ist:

let response = self
    .send_request(request)
    .await
    .map_err(ClientError::Request)?;

Experten kontaktieren

Sie haben weitere Fragen zum Thema oder benötigen konkrete Hilfe? Schreiben Sie uns eine Nachricht oder vereinbaren Sie direkt einen Termin für ein Gespräch.

Weiterlesen

Zum Blog