Jak zbudować system rozproszony, utrzymać go i nie zwariować?
Czasami stoimy przed zadaniem zbudowania aplikacji, która jednocześnie wymaga wielu zasobów, wysokiej dostępności i szybkiego czasu odpowiedzi. Zazwyczaj konieczna jest wtedy komunikacja kilku serwerów jednocześnie, co jest nie lada wyzwaniem dla programisty. W artykule poniżej zebrałem kilka rad i przemyśleń wynikających z mojego doświadczenia, które pomogą zbudować i utrzymać system rozproszony.
Paweł Kamiński. CTO w AltaLogu. Absolwent Kolegium MISMaP UW na kierunku głównym informatyka. Na co dzień programuje w Pythonie i Javie. Entuzjasta czystego kodu dobrych wzorców programowania. Wierny fan machine learningu, algorytmiki oraz skalowalnych rozwiązań współbieżnych i rozproszonych. W przerwach od programowania gra w koszykówkę.
Spis treści
Czym jest system rozproszony?
W dużym uproszczeniu system rozproszony to grupa procesów znajdujących się na różnych maszynach i komunikujących się ze sobą. Pod tą nazwą można rozumieć zarówno hurtownie danych, systemy Big Data czy też platformy obliczeń.
Kluczowymi cechami każdego systemu rozproszonego są:
1. Skalowalność – zarówno administracyjna (przepisy/organizacje), geograficzna (prędkość światła), jak i fizyczna (parametry serwerów).
2. Tolerowanie awarii – całość powinna działać możliwe poprawnie, mimo występujących błędów w częściach systemu.
3. Otwartość – architektura powinna pozwalać na dokładanie kolejnych modułów do istniejącej podstawy.
4. Przenośność – system powinien pozwalać na łatwe przenoszenie na inne serwery, zarówno w celu konserwacji, jak i zbicia kosztów.
Gdzie powinny być umieszczone maszyny powstającego projektu?
Zdecydowanie najwygodniejszym miejscem do wystawienia systemu jest duży cloud (AWS, GCP, Azure). Nie musimy dokładnie wiedzieć, ile potrzebujemy docelowo zasobów, więc możemy zapewnić skalowalność fizyczną. Dzięki cloudowi możemy udostępnić system w kilku centrach danych jednocześnie, co ułatwia skalowalność geograficzną. Poza tym wymienione cloudy umożliwiają wystawienie infrastruktury jako kod (np. za pomocą terraform). To z kolei wymusza u nas utrzymanie porządku w serwerach, dostępach do nich i firewallu, co już przy kilkudziesięciu maszynach nie jest łatwe.
Jeśli jesteśmy zmuszeni postawić system w Polsce (bo to wymóg klienta), możemy skorzystać z usług naszych lokalnych cloudów (np. Netia Compute albo Beyond.pl). Niestety tutaj tracimy na starcie skalowalność geograficzną i część funkcjonalności związanych wystawieniem infrastruktury jako kod (stan na dziś).
W najgorszym wypadku będziemy zmuszeni do wystawienia systemu na wskazanych maszynach na wskazanej serwerowni. Niestety odbiera to możliwość skalowania geograficznego, znacznie utrudnia skalowanie fizyczne, a częstokroć wprowadza ograniczenia związane ze skalowalnością administracyjną.
Jak dobrać technologię do projektu?
Najpierw powinniśmy dobrze sprecyzować wymagania dotyczące systemu, jak widać z krótkiego opisu, zwykle jest to złożony system. Następnie powinniśmy ustalić zadania, które powinien wykonywać system, wstępnie zaplanować architekturę, oszacować ruch pomiędzy konkretnymi częściami oraz ustalić sposób komunikacji. Następnym etapem jest research, zwykle znaczna część problemów, które napotkamy, już ma rozwiązanie, lub framework/bibliotekę/komponent do rozwiązania (np. Spark, Kafka, Cassandra, cockroachDB).
Jeśli mamy kilka technologii, które uważamy za równie dobre rozwiązania, powinniśmy zastosować te, które są najbardziej popularne. Przy dostatecznie dużej społeczności wokół technologii mamy większą szansę znaleźć kogoś, kto spotkał podobny problem do naszego. Dopiero, jeśli teraz będziemy mieli remis, to powinniśmy wybrać tą, którą najbardziej chcielibyśmy się nauczyć.
Każda z użytych technologii powinna mieć możliwość bezbolesnego zreplikowania, dodania shardu lub dodania kolejnej maszyny jako mocy obliczeniowej. Pozostałe dedykowane elementy powinny być zawsze zbudowane za pomocą dockera. Dlaczego? Po pierwsze, mamy odizolowane środowisko, które na każdej maszynie działa tak samo. Po drugie, nie ma problemu z przeniesieniem powstałego oprogramowania na inne maszyny. Jeśli mamy więcej niż jeden kontener, sensownym rozwiązaniem jest użycie orkiestratora kontenerów (np. Kubernetes lub Docker Swarm). To z kolei pozwoli na łatwe przydzielanie zasobów do kontenerów, jak i szybkie tworzenie replik kontenerów w razie potrzeby. W ten sposób jesteśmy w stanie utrzymać skalowalność fizyczną.
Jak zacząć budować system?
Wstępnie mamy wybraną technologię, architekturę i maszyny, na których będzie działał system. Jako że całość będzie się wielokrotnie zmieniać, a procesy mają masę zależności między sobą, powinniśmy mieć możliwość sprawdzenia, czy nasz system działa poprawnie po zmianach. Każdy commit dodawany do repozytorium powinien być automatycznie zbudowany i przejść wszystkie testy, które napisaliśmy (jednostkowe, jak i integracyjne). Aby osiągnąć ten cel, niezwykle przydatnym narzędziem jest mechanizm continuous integration (CI). Większość nowoczesnych repozytoriów kodu oferuje możliwość wystawienia własnego CI, ale czasami wygodniej jest użyć dodatkowego systemu (np. Jenkins).
Przy większych projektach po pewnym czasie częściej będziemy czytać kod niż go pisać, a w ekstremalnych przypadkach spędzać nad nim 80% czasu. Dlatego najlepiej od razu ustalić zasady pisania kodu z zespołem tak, by był możliwie czysty i czytelny, a następnie przenieść zasady na definicje linterów w wybranych językach programowania. Mając już zdefiniowane CI łatwo do niego dodać sprawdzenie całego kodu przez lintery. Niestety nie wszystkie zasady da się zawsze przenieść do lintera. W związku z tym każda zmiana powinna przejść przez proces code review. Bez tego szybko okaże się, że jesteśmy bezproduktywni, kod przestaje być spójny, a system szybko straci właściwość otwartości na rozbudowę.
Jak wprowadzać poprawki do systemu, który już żyje?
W pewnym momencie zechcemy wystawić naszą usługę w stanie produkcyjnym. Można to wykonać ręcznie, tyle że ręczny deploy zawsze kosztuje wiele czasu. Zamiast tego warto stworzyć mechanizm continuous deployment (CD). Na początku jest to pewna inwestycja czasu, ale w przyszłości chroni nas przed błędami ludzkimi (w szczególności o zapomnieniu któregoś z kroków deploymentu). Ponadto dobrze napisany mechanizm CD pozwala na szybkie przywrócenie stanu systemu do poprzedniej wersji w razie, gdyby wgranie zmiany powodowało błędy.
Przy wielu zmianach zdecydowanie dobrą praktyką jest posiadanie środowiska stagingowego obsługującego 1% ruchu i środowiska produkcyjnego obsługującego resztę (lub powieleniem systemu produkcyjnego, jeśli dane są tylko odbierane). Poprawki dzienne powinny być wgrywane na staging, zaś raz w tygodniu zmiany ze stagingu powinny być przenoszone na produkcję. Dzięki temu zmniejszamy ryzyko niepoprawnego działania oraz czas niedostępności środowiska produkcyjnego. Jeśli wystawiamy usługę, kluczowa jest rotacja użytkownikami, którzy odpowiadają za środowisko stagingowe. Poza tym przy mechanizmie CD wystawienie dwóch (lub więcej) środowisk powinno być w zasadzie bezbolesne.
Jak dowiedzieć się, że system żyje?
Mamy wystawiony system, ale zawsze coś może wydarzyć się po drodze, a to w pewnym momencie zabraknie dysku, a to zadania, które napisaliśmy wymagają za dużo ramu, albo kod, który wprowadziliśmy nie działa poprawnie/wydajnie. Powstaje więc pytanie, jak się o tym dowiedzieć? Pierwszym krokiem powinno być wystawienie scentralizowanej bazy logów. Dzięki temu będziemy w stanie w jednym miejscu sprawdzić informacje o błędach, czasach trwania zadań czy o stanie maszyn. Jednym z najlepszych rozwiązań otwartoźródłowych jest stos logstash-elasticsearch-kibana (ELK).
Z drugiej strony ciężko oczekiwać byśmy spędzali cały czas przed systemem z logami. Należy więc mieć mechanizm wysyłający informacje o zagrożeniach w całym systemie. Jednym z przykładów takiego mechanizmu jest Prometheus, sam z siebie sczytuje dane odnośnie do stanu maszyn i kontenerów, ale istnieje wiele wtyczek pozwalających na wysyłanie danych z innych mechanizmów (np. Elasticsearcha). Tutaj ważne jest ograniczenie liczby występujących alertów. Jeśli będziemy często otrzymywali informacje o zagrożeniach, szybko zaczniemy je ignorować. To niestety często prowadzi do braku reakcji w przypadku faktycznego przerwania działania systemu.
Podsumowując
Przy budowie systemu rozproszonego warto poświęcić kilka godzin więcej na research, niż być zmuszonym zaorać pracę z kilku tygodni. Jeśli nie wiemy jak usiąść do problemu, warto zapoznać się z wiedzą zaprezentowaną przez bardziej doświadczonych: np. Google landing.google.com/sre/sre-book/toc/index.html. Przy każdym większym systemie warto mieć monitoring stanu systemu, scentralizowane logi, CI/CD, wysokie pokrycie testami oraz kilka wdrożeń produkcyjnych. Poza tym warto, by nasz system był jak najbardziej bezstanowy i możliwie najłatwiej przenośny.
Zdjęcie główne artykułu pochodzi z burst.shopify.com.