Z 90 do 12 minut: jak przyspieszyliśmy pipeline .NET
Opublikowano · 5 min czytania · Tomasz Dłuski
Zmiany w kodzie u naszego klienta codziennie trafiały do pipeline’ów, których pełny check miał p99 na poziomie półtorej godziny. Po uporządkowaniu builda .NET ten sam wskaźnik wynosi 12 minut. Mediana spadła z 40 minut do 11 minut i 30 sekund. Zmiana polegała na zbudowaniu około 200 projektów C# w jednej solucji i przeniesieniu kopiowania plików na koniec całego procesu.
Pełny check: od startu joba do wyniku
| Percentyl | Przed | Po |
|---|---|---|
| p99 | 90 min | 12 min |
| p50 · mediana | 40 min | 11 min 30 s |
| p1 | 25 min | 10 min 10 s |
p99 oznacza czas, w którym kończy się około 99% uruchomień. Nie jest maksimum: pojedyncze przebiegi mogą trwać dłużej. p50 to mediana, czyli środek rozkładu, a p1 opisuje jego szybki kraniec: około 1% uruchomień kończy się w tym czasie lub wcześniej. Dzięki temu widać zarówno zmianę typowego czasu, jak i poprawę wśród dłuższych przebiegów.
Spadek p99 z 90 do 12 minut oznacza 7,5-krotne przyspieszenie tego wskaźnika. Mediana daje drugi, bardziej codzienny punkt odniesienia: typowy check zajmuje teraz około jedenastu i pół minuty. Wszystkie wartości w tabeli dotyczą wykonania joba. Czas oczekiwania na wolnego agenta jest osobną sprawą i nie został do nich doliczony.
Dysk pracował, kolejne buildy czekały
Repozytorium zawierało około 200 plików .csproj, ale pipeline budował je przez wiele osobnych solucji .sln, uruchamianych jedna po drugiej. Każde wywołanie patrzyło na swój fragment systemu. Wspólne zależności wracały w kolejnych przebiegach, a liczba operacji kopiowania plików sięgała około 14 tysięcy.
Na jednej maszynie wirtualnej działało wiele agentów Azure DevOps. Ich joby współdzieliły zasoby dyskowe, więc intensywne odczyty i zapisy jednego builda spowalniały również pozostałe. Powstawało przeciążenie I/O. Samo zwiększenie równoległości nie usuwało powielanego kopiowania, które obciążało całą maszynę.
Efekt narastał: uruchomione joby konkurowały o I/O, długo zajmowały agentów, a następne czekały w kolejce. Autorzy PR otrzymywali wyniki później także wtedy, gdy ich własne buildy nie były źródłem nadmiernego kopiowania.
Ten sam kod, inny przebieg builda
Uproszczony przebieg jednego builda. Litery oznaczają przykładowe solucje i projekty.
Przed
Jedna solucja po drugiej
Solucja A
Post-build: kopiowanie DLL
do wspólnego folderu
Solucja B
Post-build: kopiowanie DLL
do wspólnego folderu
Solucja C
Post-build: kopiowanie DLL
do wspólnego folderu
Około 14 000 operacji kopiowania plików
Powtarzane kopiowanie między kolejnymi buildami.
Po
Jeden wspólny graf
Jeden plik .sln
około 200 projektów .csproj
- Wspólna zależność
Niezależne projekty równolegle
- ProjektA
- ProjektB
- ProjektC
Cała solucja zbudowana
Jeden końcowy etap kopiowania
tylko potrzebne pliki
Jedna solucja, zależności widoczne dla MSBuild
Zebraliśmy projekty w jedną solucję i przekazaliśmy MSBuild odpowiedzialność za kolejność budowania. Gdy projekt zależy od innego projektu, silnik musi uwzględnić tę zależność. Gdy kilka projektów może powstawać niezależnie, ich buildy mogą wykonywać się równolegle. Znika potrzeba ręcznego szeregowania całych solucji i ponownego uruchamiania procesu dla ich wspólnych fragmentów.
Ważna jest również konfiguracja wykonania. MSBuild obsługuje wiele procesów roboczych przez opcję -maxcpucount, w skrócie -m. Pozwala to używać wielu rdzeni do równoległego budowania projektów. Samo przeniesienie plików .csproj do jednego .sln nie włącza tej możliwości automatycznie. Graf zależności i odpowiednio skonfigurowana wieloprocesowość muszą działać razem.
Najpierw zbuduj całość, potem kopiuj
Legacy projekty miały akcje post-build, które kopiowały DLL do wspólnego folderu. Zakończenie pojedynczego projektu uruchamiało więc kolejną porcję operacji na plikach, chociaż reszta solucji nadal się budowała. Takie operacje powtarzały tę samą pracę i komplikowały równoległe wykonanie: kilka projektów mogło próbować zapisywać wspólne pliki.
Za pomocą wspólnego pliku .targets centralnie wyłączyliśmy lub nadpisaliśmy te kroki. MSBuild pozwala nadpisywać definicje targetów, przy czym znaczenie ma kolejność importów: używana jest ostatnia definicja o danej nazwie. Dzięki temu konfigurację zbędnego kopiowania można uporządkować wspólnie, zamiast utrzymywać podobne poprawki osobno w setkach projektów.
Potrzebne kopiowanie zostało jednym etapem po pomyślnym zakończeniu budowania całej solucji. To ważne rozróżnienie: AfterBuild dołączony do projektu dotyczy tego projektu. Nie jest globalnym sygnałem, że skończyło się wszystko. Końcowy krok musi czekać na całą solucję; dopiero wtedy zbiera potrzebne pliki. W naszym procesie pozwoliło to wykonać tę pracę raz.
Sam build: średnio poniżej dwóch minut
Wcześniej: 20-40 min
Teraz: średnio < 2 min
Ta sama maszyna. Zwykle do 3 minut.
Osobno zmierzyliśmy sam etap builda. Na tej samej maszynie wcześniej zajmował od 20 do 40 minut. Po zmianie średnia wynosi mniej niż dwie minuty, a build zwykle mieści się w trzech minutach. Maszyna mogła wykorzystać wiele rdzeni, a dysk przestał obsługiwać powielane kopiowanie. Ten pomiar dotyczy pojedynczego builda. Pełny check z tabeli na początku obejmuje dwa buildy, w tym drugi na potrzeby analizy narzędziem klienta, oraz pozostałe kroki pipeline’u.
Mniej kopiowania przyspieszyło też pozostałe joby
Przyspieszenie pojedynczego builda wspólnej solucji przyniosło korzyść wszystkim buildom wykonywanym na tej samej VM. Po ograniczeniu kopiowania zmalała konkurencja o I/O. Więcej jobów mogło sprawnie pracować jednocześnie, a pozostałe joby przestały mieć przeciągające się do półtorej godziny przebiegi powodowane tym przeciążeniem.
To drugi poziom poprawy: wewnątrz jednego builda MSBuild wykorzystuje wiele rdzeni, a między jobami agenci mają do dyspozycji mniej obciążone zasoby dyskowe. Optymalizacja jednego procesu skróciła więc także czas pracy jego sąsiadów na maszynie.
Podejrzewamy, że koszt tysięcy operacji kopiowania dodatkowo zwiększało skanowanie plików przez antywirusa. To hipoteza, nie potwierdzona przyczyna. Zaobserwowany efekt po ograniczeniu kopiowania był szerszy niż szybszy pojedynczy build: poprawiła się praca całej współdzielonej VM.
Dwa buildy z pełną analizą, nadal dwa razy szybciej
W pipeline z analizą wcześniej sprawdzane były tylko wybrane projekty. Każdy z nich był budowany osobno, razem z drzewem zależności potrzebnym do jego sprawdzenia. Mniejszy zakres sprawdzania nie przekładał się na krótki czas wykonania: już same kroki builda trwały zbyt długo, a wspólne fragmenty drzewa wracały przy kolejnych projektach.
Wykorzystaliśmy więc tę samą, nową solucję obejmującą całość. Najpierw budujemy ją raz. Następnie budujemy ją ponownie na potrzeby analizy narzędziem, którego używa klient. Pipeline wykonuje dwa buildy wspólnego pliku .sln i pełną analizę, zamiast sprawdzać tylko wybrane projekty przez osobne wywołania ich buildów.
Nawet z tym drugim buildem cały pipeline z pełną analizą zajmuje około połowy czasu wcześniejszego, pojedynczego przebiegu budującego wybrane projekty osobno wraz z ich zależnościami. Sprawdzamy więc więcej, a wynik otrzymujemy szybciej. O czasie wykonania decyduje także to, ile pracy powtarzamy między projektami i czy silnik budowania może wykorzystać wspólny graf zależności.
Jak to dowieźliśmy? Współpracą dev i ops
Zmiana została dowieziona, bo deweloperzy zaopiekowali się buildem jako częścią kodu. Problem tkwił w definicjach projektów, ich zależnościach i firmowej logice budowania. Sama konfiguracja agentów nie wystarczała, żeby go usunąć.
Zespół DevOps potrzebował tu współpracy deweloperów znających te projekty. Połączenie wiedzy o kodzie z wiedzą o środowisku CI pozwoliło usunąć zbędną pracę u źródła i odciążyć współdzieloną maszynę. Tak duża optymalizacja była efektem współpracy dev i ops.