Kwadrans na wdrożenie kodu – czy to w ogóle możliwe?

Co można zmieścić w tym czasie z perspektywy wielofunkcyjnego zespołu?

W dzisiejszych czasach automatyzacja wkroczyła już bardzo daleko w obszar wytwarzania oprogramowania. Mamy z nią do czynienia już nie tylko w przypadku budowania kodu aplikacji czy też uruchamiania testów, ale także w procesie wdrażania produktu dla klienta i jego późniejszego utrzymania. Nie ma w zasadzie w tym niczego zaskakującego ani tajemniczego, a procesy Continuous Integration (CI) i Continuous Delivery (CD) stały się naszą codziennością. Zespoły developerskie składają się z inżynierów o szerokich kompetencjach, wśród których są specjaliści od automatyzacji testów (QA) i zarządzania infrastrukturą (DevOps).

W Metapack wielofunkcyjne zespoły Software Development potrafią sprostać wyzwaniom stawianym przez biznes w czasie znacznie krótszym, niż miało to miejsce dekadę temu.

Co na to wpłynęło i co zostało zoptymalizowane, aby czas dostarczania nowych funkcjonalności mógł być liczony
w jednostkach o rząd wielkości mniejszych niż to miało miejsce jeszcze parę lat temu? Odpowiadając na to pytanie, przedstawię Wam jak wyglądała transformacja, jaką musiał przejść sam proces wytwarzania „software-u”. Ponadto pokażę, jak zespoły radziły sobie z różnymi wyzwaniami, które pojawiały się na ich drodze w trakcie budowania platformy do obsługi procesów biznesowych naszych klientów.

Dawno, dawno temu…

Jak to zwykle bywa w opowieściach, wszystko się zaczęło dawno, dawno temu od monolitu, do którego ze sprintu na sprint wiele zespołów wprowadzało setki swoich zmian. Kod zarządzany był przez archaiczny system kontroli wersji, który był postawiony na serwerze „za ścianą”. Aplikacja końcowa kompilowana i składana była przez nasze własne narzędzia i jak się zapewne domyślacie, wygospodarowanie czasu na sprawdzenie, czy bieżące zmiany nie zepsuły całości, było zawsze dużym wyzwaniem. Wdrożenia natomiast za każdym razem wiązały się z ogromnymi emocjami. Z czasem zaczęły pojawiać się stopniowe usprawnienia w postaci dedykowanego systemu do budowania źródeł (TFS), czy też automatyczne testy nocne z wykorzystaniem SoapUI, które eliminowały konieczność wykonywania długotrwałych testów manualnych. Pomimo tego, iż w dużym stopniu udało się skrócić czas potrzebny na przygotowanie wersji dla klienta, to nadal wymagał on kilku wielogodzinnych etapów. Pozostawał jeszcze jeden problem do rozwiązania: skomplikowane wdrożenie. Do tego przejdziemy w dalszej części, a póki co zobaczmy, jak się sprawy miały w przypadku naszego procesu CI.

Kilka lat temu przed naszym zespołem postawiono wyzwanie, aby przenieść wszystkie źródła i narzędzia z lokalnych serwerów do chmury.

W trakcie realizacji tego zadania udało się wdrożyć szereg kolejnych usprawnień i otworzyć furtki do następnych. Tym samym serwer TFS i używany przez niego system kontroli wersji został zastąpiony przez narzędzia w chmurze – Bitbucket oraz powszechnie znany system Git wraz z wszelkimi jego dobrodziejstwami, jak np.: sprawdzone strategie zarządzania gałęziami czy przegląd zmian z wykorzystaniem „pull requestów”. Z kolei „pipeline-y” do automatycznego budowania oraz testowania kodu zostały zbudowane od zera przy wykorzystaniu bardzo wszechstronnego i intuicyjnego narzędzia jakim jest Teamcity. W zasadzie bez większego wysiłku skróciliśmy czas budowania naszych głównych produktów o kolejne dziesiątki minut opierając się wyłącznie na wewnętrznych możliwościach tego ostatniego, jak np. redukcja ilości przebudowywanych komponentów tylko do tych, w których występowały zmiany w kodzie czy też optymalizacja zarządzania współdzielonymi bibliotekami dzięki wbudowanym mechanizmom buforowania (cache).

Migracja zakończyła się sukcesem, my jednak nie spoczęliśmy na laurach i z każdym kolejnym sprintem wprowadzaliśmy stopniowe usprawnienia do istniejących mechanizmów. Było to m.in. zrównoleglenie wykonywania testów jednostkowych czy też konsolidacja dużej ilości niewielkich projektów (Solutions w .Net) w jedną całość, aby skorzystać z właściwości równoległego kompilowania kodu przy użyciu wszystkich dostępnych procesorów „CPUs” maszyny. Nadal jednak czas dostarczenia monolitu na produkcję nie zbliżył się nawet do tytułowych piętnastu minut.

Technologie na ratunek

Dużym wsparciem dla nas były strategiczne decyzje firmy dotyczące architektury planowanych nowych funkcjonalności oraz ich integracji z obecną platformą. Tak rozpoczęliśmy naszą przygodę z wysokodostępnymi i niezależnymi mikro-usługami sieciowymi w chmurze AWS. Autonomia serwisów równała się autonomii zespołów, tzn. każdy właściciel nowej usługi decydował o jej wewnętrznej architekturze oraz technologii.

Mojemu zespołowi przypadła rola zbudowania alternatywy dla podstawowej funkcjonalności naszej platformy. Zadanie ambitne i wymagające, szczególnie z uwagi na szereg zależności pomiędzy różnymi komponentami oraz potrzebę zachowania kompatybilności względem istniejącego rozwiązania. W efekcie naszych prac powstała koncepcja platformy opartej o kontenery (Docker) z możliwością rozszerzania jej funkcjonalności za pomocą dedykowanych modułów wstrzykiwanych w procesie integracji.

Powstało coś na wzór dobrze znanego wzorca Dependency Injection, który mogliśmy z łatwością wykorzystać w samym kodzie nowej platformy dzięki niezależności źródeł oraz wybraniu najnowszej wersji .Net Core.

Tylko jak to właściwie wpłynęło na zmniejszenie czasu dostarczania nowych funkcjonalności dla klienta? Postaram się pokrótce wyjaśnić.

Wydzielenie do osobnego repozytorium dużej części rdzenia systemu pozwoliło na usunięcie „martwego” kodu oraz pokrycie reszty testami automatycznymi w takim stopniu, aby być pewnym kompatybilności wstecz. W tym momencie byliśmy w stanie zbudować bazowy obraz tzw. „core” serwisu. Co najważniejsze, o ile nie było żadnej zmiany w obszarze „core-owym”, proces budowania nie musiał być już powtarzany za każdym razem przy wydawaniu kolejnej wersji aplikacji.

Drugim usprawnieniem było zlikwidowanie zależności pomiędzy modułami rozszerzającymi funkcjonalności platformy oraz zwiększenie tym samym autonomii zespołów, które zyskały możliwość wyboru adekwatnej do ich potrzeb bazy danych, czy decydowania o sposobie integracji z systemami zewnętrznymi. Dodatkowo do dyspozycji każdego zespołu oddany został uniwersalny „pipeline” do umieszczenia modułu w postaci niezależnego serwisu bezpośrednio w infrastrukturze AWS. Dzięki temu kod wpinany do gałęzi „master” jest natychmiast budowany oraz automatycznie integrowany z główną platformą do postaci gotowego do wgrania obrazu kontenera (docker).

Następnie całość jest weryfikowana pod kątem jakości. Ten etap jest wykonywany dwutorowo: część odpowiedzialna za statyczną analizę kodu wykonuje się razem z testami jednostkowymi (nUnit/xUnit); w tym samym czasie weryfikowany jest sam moduł pod kątem biznesowym. Do tego celu wykorzystujemy testy wyższego poziomu tzw. testy akceptacyjne napisane zgodnie z podejściem BDD we frameworku SpecFlow. Ich ilość, zgodnie z piramidą testów, jest oczywiście proporcjonalnie mniejsza, dzięki czemu wykonują się szybko i w obrębie tylko jednego modułu, co pozwoliło nam wyeliminować potrzebę wykonywania dodatkowych testów nocnych.

Czy tak zweryfikowany obraz może trafić bezpośrednio do chmury?

Już prawie, ale zanim to się wydarzy musimy być pewni, że nasz serwis będzie współpracował z usługami zależnymi, pochodzącymi bezpośrednio z AWS jak np. DynamoDB, SQS czy S3. Tylko jak to zrobić, skoro chcemy się o tym dowiedzieć jak najwcześniej, żeby nie tracić potem czasu na odkręcanie całego misternego procesu? Na ratunek przyszedł nam bardzo pożyteczny framework localstack.cloud.

Z racji tego, że localstack dostarcza usługi AWS w postaci obrazu kontenera mogliśmy przeprowadzić nasze testy integracyjne już na etapie procesu CI.

Stan usług zewnętrznych jest przy tym zawsze stały i znany. Przede wszystkim jednak testy mogą być niezależne od innych uruchamianych na identycznej infrastrukturze, a ich koszt finansowy (z pominięciem kosztów agenta Teamcity) jest praktycznie zerowy. Nie wspominając już o zaoszczędzonym czasie, jaki poświęcilibyśmy na tworzenie za każdym razem niezależnej infrastruktury w chmurze. To ostatnie zweryfikowaliśmy w pierwszym podejściu, w którym do tworzenia zasobów użyliśmy skryptów Terraform.

Dodatkową korzyścią, jaka płynie z użycia skonteneryzowanych usług AWS jest fakt, że podobne rozwiązanie można zastosować na lokalnej maszynie. Zespoły mogą wykorzystać do tego celu stworzony przez nas pakiet nuget (zestaw skryptów powershell w połączeniu z docker-compose), który podczas budowania kodu automatycznie integruje moduł z platformą i przeprowadza testy akceptacyjne całego serwisu.

Podsumowując: cały proces od wpięcia kodu do gałęzi”master”, a następnie zbudowanie obrazu (docker) i przetestowanie aplikacji, aż do zweryfikowania jej interakcji z usługami AWS zajmuje u nas obecnie od 5-u do 10-u minut w zależności od ilości testów.

Pozostaje nam już tylko ostatni element – wdrożenie (deployment)

Jak pamiętamy, wydanie wersji dla klienta wiązało się przeważnie ze stresem i czasochłonnymi testami regresywnymi. W nowym rozdaniu nie chcieliśmy powtarzać tego samego, dlatego też największy nacisk położyliśmy na niezawodność i stabilność. Przede wszystkim jednak postawiliśmy na automatyzację przy minimalnym wkładzie ze strony człowieka. Dzięki takiemu podejściu staraliśmy się wykorzystać potencjał idący od samego początku procesu. Skoro raz przygotowane z wielką pieczołowitością testy akceptacyjne (SpecFlow) zweryfikowały gotowe artefakty, to dlaczego by ich nie wykorzystać w kolejnych etapach.

W ten sposób powstał koncept weryfikacji wydajności usługi w procesie CD (deployment pipeline), a przy okazji pojawiła się możliwość stworzenia testów dymnych (smoke tests). Te ostatnie pełnią jednocześnie rolę mechanizmu rozgrzewającego nasz serwis (warm-up), aby w momencie startowania był on od razu gotowy do przyjęcia ruchu.

W wyniku uruchomionych wcześniej testów uzyskaliśmy wiarygodne próbki zapytań do naszego API, które z powodzeniem wykorzystaliśmy w tworzonym przez nas procesie CD. Służą nam one do wygenerowania dużego obciążenia na wystawiony właśnie serwis, co realizujemy za pomocą świetnego narzędzia Gatling. Nie robimy tego oczywiście na środowisku produkcyjnym tylko na zaktualizowanym wcześniej środowisku testowym, będącym jednocześnie lustrzanym odbiciem tego pierwszego pod kątem infrastruktury. Jeśli czasy odpowiedzi z API (najczęściej 95 percentyl) mieszczą się w zdefiniowanym kryterium (SLO) dla publikowanego właśnie modułu to oznacza, że nic nie stoi na przeszkodzie, aby zatwierdzić aktualizację serwisu na produkcji. Za ten ostatni etap odpowiedzialny jest zautomatyzowany skrypt Terraform, który w kilku prostych krokach dokonuje promocji usługi do najnowszej wersji. Cała ta czynność, dzięki wykorzystaniu technologii Fargate w AWS ECS oraz wspomnianemu mechanizmowi „warm-up”, odbywa się oczywiście bez żadnych przestojów dla trwającego ruchu (zero-downtime), a serwis pozostaje wyskalowany adekwatnie do panujących warunków w środowisku produkcyjnym.

Czy kwadrans od wpięcia do wdrożenia kodu na produkcję to tylko teoretyczna mrzonka?

Ile to wszystko zajmuje?

Przeciętnie, środowisko testowe aktualizuje się w kilka minut. Do tego dodajemy 5 minut na testy obciążeniowe (load tests). Po ich potwierdzeniu mamy zielone światło do „update-tu” produkcji. W tym momencie przypisujemy nowy obraz do definicji zadania naszej usługi (task definition), przeprowadzamy krótkie testy dymne oraz przełączamy ostatecznie ruch, co łącznie zajmuje kolejne parę minut.

Podsumowując: biorąc pod uwagę najkorzystniejszy scenariusz, gdzie proces CI trwa maksymalnie 5 minut, do tego 7 minut potrzebujemy na „update” środowiska testowego i load testy, to na deser mamy całe 3 minuty na bezpieczne przełączenie produkcji. Ot i mamy w kwadrans gotowe.

Wykorzystywane technologie i narzędzia:

  • AWS
  • Teamcity
  • Jenkins
  • Gatling
  • Docker
  • Terraform
  • SpecFlow
  • LocalStack

Autor:

Adam, Lider zespołu Ninja
Adam,
Lider zespołu Ninja

Inne aktualności