Lepsze podejście do ciągłego wdrażania Google Cloud
Opublikowany: 2022-03-11Ciągłe wdrażanie (CD) to praktyka automatycznego wdrażania nowego kodu do środowiska produkcyjnego. Większość systemów ciągłego wdrażania sprawdza, czy kod, który ma zostać wdrożony, jest wykonalny, uruchamiając testy jednostkowe i funkcjonalne, a jeśli wszystko wygląda dobrze, wdrożenie jest wdrażane. Samo wdrożenie zwykle odbywa się etapami, aby umożliwić wycofanie, jeśli kod nie zachowuje się zgodnie z oczekiwaniami.
Nie brakuje postów na blogu o tym, jak zaimplementować własny potok CD przy użyciu różnych narzędzi, takich jak stos AWS, stos Google Cloud, potok Bitbucket itp. Ale uważam, że większość z nich nie pasuje do mojego wyobrażenia o tym, co to jest dobry potok CD powinien wyglądać tak: taki, który najpierw kompiluje, a testuje i wdraża tylko ten jeden skompilowany plik.
W tym artykule zamierzam zbudować oparty na zdarzeniach potok ciągłego wdrażania, który najpierw kompiluje, a następnie uruchamia testy na naszym końcowym artefakcie wdrażania. To nie tylko sprawia, że wyniki naszych testów są bardziej wiarygodne, ale także ułatwia rozszerzanie potoku CD. Wyglądałoby to mniej więcej tak:
- Zatwierdzenie jest wprowadzane do naszego repozytorium źródłowego.
- To uruchamia kompilację skojarzonego obrazu.
- Testy są uruchamiane na zbudowanym artefakcie.
- Jeśli wszystko wygląda dobrze, obraz jest wdrażany do produkcji.
W tym artykule założono przynajmniej przelotną znajomość Kubernetes i technologii kontenerów, ale jeśli nie znasz lub możesz skorzystać z odświeżenia, zobacz Co to jest Kubernetes? Przewodnik po konteneryzacji i wdrażaniu.
Problem z większością konfiguracji CD
Oto mój problem z większością potoków CD: zwykle robią wszystko w pliku kompilacji. Większość postów na blogu, które czytałem na ten temat, będzie zawierała pewną odmianę następującej sekwencji w dowolnym pliku kompilacji ( cloudbuild.yaml
dla Google Cloud Build, bitbucket-pipeline.yaml
dla Bitbucket).
- Uruchom testy
- Zbuduj obraz
- Wypchnij obraz do repozytorium kontenerów
- Zaktualizuj środowisko za pomocą nowego obrazu
Nie przeprowadzasz testów na końcowym artefakcie.
Robiąc rzeczy w tej kolejności, uruchamiasz swoje testy. Jeśli się powiedzie, budujesz obraz i kontynuujesz z resztą potoku. Co się stanie, jeśli proces budowania zmieni Twój obraz w taki sposób, że testy już nie przejdą? Moim zdaniem powinieneś zacząć od stworzenia artefaktu (ostatecznego obrazu kontenera) i ten artefakt nie powinien się zmieniać między kompilacją a czasem wdrożenia do produkcji. Gwarantuje to, że dane, które posiadasz o wspomnianym artefakcie (wyniki testu, rozmiar itp.) są zawsze aktualne.
Twoje środowisko budowania ma „klucze do królestwa”.
Używając środowiska kompilacji do wdrażania obrazu na stosie produkcyjnym, skutecznie umożliwiasz mu zmianę środowiska produkcyjnego. Uważam to za bardzo złą rzecz, ponieważ każdy, kto ma dostęp do zapisu w twoim repozytorium źródłowym, może teraz robić wszystko, co chce, w twoim środowisku produkcyjnym.
Musisz ponownie uruchomić cały potok, jeśli ostatni krok się nie powiódł.
Jeśli ostatni krok się nie powiedzie (na przykład z powodu problemu z poświadczeniami), musisz ponownie uruchomić cały potok, zabierając czas i inne zasoby, które można lepiej spożytkować na coś innego.
To prowadzi mnie do ostatniego punktu:
Twoje kroki nie są niezależne.
W bardziej ogólnym sensie posiadanie niezależnych kroków pozwala na większą elastyczność w potoku. Załóżmy, że chcesz dodać testy funkcjonalne do swojego potoku. Mając swoje kroki w jednym pliku kompilacji, musisz uruchomić środowisko kompilacji funkcjonalnego środowiska testowego i uruchomić w nim testy (najprawdopodobniej sekwencyjnie). Jeśli Twoje kroki byłyby niezależne, możesz uruchomić zarówno testy jednostkowe, jak i testy funkcjonalne przez zdarzenie „zbudowano obraz”. Pracowaliby wtedy równolegle we własnym środowisku.
Moja idealna konfiguracja CD
Moim zdaniem lepszym sposobem podejścia do tego problemu byłoby zastosowanie szeregu niezależnych kroków połączonych ze sobą mechanizmem zdarzeń.
Ma to kilka zalet w porównaniu z poprzednią metodą:
Możesz wykonać kilka niezależnych akcji na różnych wydarzeniach.
Jak wspomniano powyżej, udane zbudowanie nowego wizerunku byłoby po prostu opublikowaniem wydarzenia „udanej budowy”. Z kolei możemy uruchomić kilka rzeczy, gdy to zdarzenie zostanie wyzwolone. W naszym przypadku rozpoczęlibyśmy testy jednostkowe i funkcjonalne. Możesz także pomyśleć o takich rzeczach, jak ostrzeganie programisty, gdy zostanie wyzwolone zdarzenie nieudanej kompilacji lub jeśli testy nie zakończą się pomyślnie.
Każde środowisko ma swój własny zestaw praw.
Dzięki temu, że każdy krok odbywa się we własnym środowisku, eliminujemy potrzebę posiadania wszystkich praw w jednym środowisku. Teraz środowisko kompilacji może tylko kompilować, środowisko testowe może tylko testować, a środowisko wdrażania może tylko wdrażać. Dzięki temu masz pewność, że po zbudowaniu Twojego wizerunku nie ulegnie on zmianie. Wyprodukowany artefakt to ten, który trafi do twojego stosu produkcyjnego. Pozwala również na łatwiejszą inspekcję tego, który krok potoku robi to, co robi, ponieważ możesz połączyć jeden zestaw poświadczeń z jednym krokiem.
Jest większa elastyczność.
Chcesz wysłać komuś wiadomość e-mail o każdej udanej kompilacji? Po prostu dodaj coś, co zareaguje na to wydarzenie i wyśle e-mail. To proste — nie musisz zmieniać kodu kompilacji ani zapisywać na stałe czyjegoś e-maila w repozytorium źródłowym.
Ponowne próby są łatwiejsze.
Posiadanie niezależnych kroków oznacza również, że nie musisz ponownie uruchamiać całego potoku, jeśli jeden krok się nie powiedzie. Jeśli stan niepowodzenia jest tymczasowy lub został naprawiony ręcznie, możesz po prostu powtórzyć krok, który się nie powiódł. Pozwala to na wydajniejszy rurociąg. Gdy krok kompilacji zajmuje kilka minut, dobrze jest nie musieć ponownie kompilować obrazu tylko dlatego, że zapomniałeś przyznać środowisku wdrażania prawa zapisu do klastra.
Wdrażanie ciągłego wdrażania Google Cloud
Google Cloud Platform posiada wszystkie narzędzia niezbędne do zbudowania takiego systemu w krótkim czasie i przy bardzo małej ilości kodu.
Nasza aplikacja testowa to prosta aplikacja Flask, która wyświetla tylko kawałek statycznego tekstu. Ta aplikacja jest wdrażana w klastrze Kubernetes, który obsługuje ją w szerszym Internecie.
Będę wdrażał uproszczoną wersję potoku, którą wprowadziłem wcześniej. Zasadniczo usunąłem kroki testowe, więc teraz wygląda to tak:
- Nowe zatwierdzenie jest wprowadzane do repozytorium źródłowego
- To uruchamia kompilację obrazu. Jeśli się powiedzie, jest przesyłane do repozytorium kontenerów, a zdarzenie jest publikowane w temacie Pub/Sub
- Mały skrypt subskrybuje ten temat i sprawdza parametry obrazu — jeśli zgadzają się z tym, o co prosiliśmy, jest on wdrażany w klastrze Kubernetes.
Oto graficzna reprezentacja naszego potoku.
Przepływ jest następujący:
- Ktoś zobowiązuje się do naszego repozytorium.
- To wyzwala kompilację chmury, która buduje obraz Docker na podstawie repozytorium źródłowego.
- Kompilacja chmury wypycha obraz do repozytorium kontenerów i publikuje wiadomość w chmurze pub/sub.
- To uruchamia funkcję chmury, która sprawdza parametry opublikowanej wiadomości (status kompilacji, nazwa zbudowanego obrazu itp.).
- Jeśli parametry są dobre, funkcja w chmurze aktualizuje wdrożenie Kubernetes o nowy obraz.
- Kubernetes wdraża nowe kontenery z nowym obrazem.
Kod źródłowy
Nasz kod źródłowy to bardzo prosta aplikacja Flask, która wyświetla tylko jakiś statyczny tekst. Oto struktura naszego projektu:

├── docker │ ├── Dockerfile │ └── uwsgi.ini ├── k8s │ ├── deployment.yaml │ └── service.yaml ├── LICENSE ├── Pipfile ├── Pipfile.lock └── src └── main.py
Katalog Docker zawiera wszystko, co jest potrzebne do zbudowania obrazu Docker. Obraz jest oparty na obrazie uWSGI i Nginx i po prostu instaluje zależności i kopiuje aplikację do właściwej ścieżki.
Katalog k8s zawiera konfigurację Kubernetes. Składa się z jednej usługi i jednego wdrożenia. Wdrożenie uruchamia jeden kontener na podstawie obrazu zbudowanego z Dockerfile . Usługa następnie uruchamia moduł równoważenia obciążenia, który ma publiczny adres IP i przekierowuje do kontenerów aplikacji.
Kompilacja w chmurze
Samą konfigurację kompilacji w chmurze można wykonać za pomocą konsoli w chmurze lub wiersza poleceń Google Cloud. Wybrałem konsolę w chmurze.
Tutaj budujemy obraz dla dowolnego zatwierdzenia w dowolnej gałęzi, ale możesz mieć na przykład różne obrazy dla deweloperów i produkcji.
Jeśli kompilacja się powiedzie, kompilacja w chmurze sama opublikuje obraz w rejestrze kontenerów. Następnie opublikuje wiadomość w temacie pub/sub dotyczącym kompilacji w chmurze.
Kompilacja w chmurze publikuje również komunikaty, gdy kompilacja jest w toku i gdy jedna się nie powiedzie, dzięki czemu możesz również reagować na te komunikaty.
Dokumentacja powiadomień pub/sub dotyczących kompilacji chmury znajduje się tutaj, a format wiadomości można znaleźć tutaj
Pub/Sub w chmurze
Jeśli spojrzysz na kartę pub/sub w chmurze w konsoli w chmurze, zobaczysz, że kompilacja chmury utworzyła temat o nazwie kompilacje w chmurze. W tym miejscu kompilacja chmury publikuje swoje aktualizacje statusu.
Funkcja chmury
To, co teraz zrobimy, to utworzenie funkcji w chmurze, która zostanie wyzwolona przy każdej wiadomości opublikowanej w temacie dotyczącym kompilacji w chmurze. Ponownie możesz użyć konsoli w chmurze lub narzędzia wiersza poleceń Google Cloud. To, co zrobiłem w moim przypadku, polega na tym, że używam kompilacji chmury do wdrażania funkcji chmury za każdym razem, gdy nastąpi w niej zmiana.
Kod źródłowy funkcji chmury znajduje się tutaj.
Przyjrzyjmy się najpierw kodowi, który wdraża tę funkcję chmury:
steps: - name: 'gcr.io/cloud-builders/gcloud' id: 'test' args: ['functions', 'deploy', 'new-image-trigger', '--runtime=python37', '--trigger-topic=cloud-builds', '--entry-point=onNewImage', '--region=us-east1', '--source=https://source.developers.google.com/projects/$PROJECT_ID/repos/$REPO_NAME']
Tutaj używamy obrazu Google Cloud Docker. Pozwala to na łatwe uruchamianie poleceń Gcloud. To, co wykonujemy, jest równoznaczne z uruchomieniem następującego polecenia bezpośrednio z terminala:
gcloud functions deploy new-image-trigger --runtime=python37 --trigger-topic=cloud-builds --entry-point=onNewImage --region=us-east1 --source=https://source.developers.google.com/projects/$PROJECT_ID/repos/$REPO_NAME
Prosimy Google Cloud o wdrożenie nowej funkcji w chmurze (lub zastąpienie, jeśli funkcja o tej nazwie w tym regionie już istnieje), która będzie używać środowiska wykonawczego Python 3.7 i będzie wyzwalana przez nowe komunikaty w temacie dotyczącym kompilacji w chmurze. Mówimy również Google, gdzie znaleźć kod źródłowy tej funkcji (tutaj PROJECT_ID i REPO_NAME są zmiennymi środowiskowymi, które są ustawiane przez proces kompilacji). Mówimy mu również, jaką funkcję wywołać jako punkt wejścia.
Na marginesie, aby to zadziałało, musisz nadać swojemu kontu usługi cloudbuild zarówno „programistę funkcji chmury”, jak i „użytkownika konta usługi”, aby mogło wdrożyć funkcję chmury.
Oto kilka komentowanych fragmentów kodu funkcji chmury
Dane punktu wejścia będą zawierać wiadomość otrzymaną w temacie pub/sub.
def onNewImage(data, context):
Pierwszym krokiem jest pobranie zmiennych dla tego konkretnego wdrożenia ze środowiska (zdefiniowaliśmy je, modyfikując funkcję chmury w konsoli chmury.
project = os.environ.get('PROJECT') zone = os.environ.get('ZONE') cluster = os.environ.get('CLUSTER') deployment = os.environ.get('DEPLOYMENT') deploy_image = os.environ.get('IMAGE') target_container = os.environ.get('CONTAINER')
Pominiemy część, w której sprawdzamy, czy struktura wiadomości jest taka, jakiej oczekujemy, i sprawdzamy, czy kompilacja się powiodła i wytworzyła jeden artefakt obrazu.
Następnym krokiem jest upewnienie się, że zbudowany obraz jest tym, który chcemy wdrożyć.
image = decoded_data['results']['images'][0]['name'] image_basename = image.split('/')[-1].split(':')[0] if image_basename != deploy_image: logging.error(f'{image_basename} is different from {deploy_image}') return
Teraz otrzymujemy klienta Kubernetes i pobieramy wdrożenie, które chcemy zmodyfikować
v1 = get_kube_client(project, zone, cluster) dep = v1.read_namespaced_deployment(deployment, 'default') if dep is None: logging.error(f'There was no deployment named {deployment}') return
Na koniec łatamy wdrożenie nowym obrazem; Kubernetes zajmie się jego wdrożeniem.
for i, container in enumerate(dep.spec.template.spec.containers): if container.name == target_container: dep.spec.template.spec.containers[i].image = image logging.info(f'Updating to {image}') v1.patch_namespaced_deployment(deployment, 'default', dep)
Wniosek
To jest bardzo prosty przykład tego, jak lubię architekturę rzeczy w potoku CD. Możesz wykonać więcej kroków, zmieniając tylko to, które zdarzenie pub/sub ma je wyzwalać.
Na przykład możesz uruchomić kontener, który uruchamia testy w obrazie i publikuje zdarzenie w przypadku powodzenia, a inne w przypadku niepowodzenia i reaguje na nie, aktualizując wdrożenie lub ostrzegając w zależności od wyniku.
Zbudowany potok jest dość prosty, ale możesz napisać inne funkcje w chmurze dla innych części (na przykład funkcję w chmurze, która wyśle e-mail do programisty, który zatwierdził kod, który złamał twoje testy jednostkowe).
Jak widać, nasze środowisko kompilacji nie może niczego zmienić w naszym klastrze Kubernetes, a nasz kod wdrożenia (funkcja w chmurze) nie może modyfikować zbudowanego obrazu. Nasz rozdział uprawnień wygląda dobrze i możemy spać spokojnie, wiedząc, że nieuczciwy programista nie zniszczy naszego klastra produkcyjnego. Możemy również dać naszym bardziej zorientowanym na operacje programistom dostęp do kodu funkcji chmury, aby mogli go naprawić lub ulepszyć.
Jeśli masz jakieś pytania, uwagi lub ulepszenia, zachęcamy do kontaktu w komentarzach poniżej.