Schnellere Rust-CI-Pipelines, die Tarpaulin verwenden
- Lesezeit:
- 6 min
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.
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 changingRUSTFLAGS
. 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:
- Use
cargo tarpaulin --print-rust-flags
and use those flags for dev and coverage- 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
Wir hatten in der Vergangenheit Probleme mit dem target/tarpaulin
zu setzen.--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)?;
Artikel teilen