Konfigurowalna konfiguracja systemu rozproszonego

W tym poście chcielibyśmy podzielić się interesującym sposobem na zarządzanie konfiguracją systemu rozproszonego.
Konfiguracja jest bezpośrednio reprezentowana w języku Scala w sposób bezpieczny typowo. Przykładowa implementacja jest opisana szczegółowo. Omówiono różne aspekty propozycji, w tym wpływ na cały proces rozwoju.

Konfigurowalna konfiguracja systemu rozproszonego

(po polsku)

Wprowadzenie

Budowanie solidnych systemów rozproszonych wymaga użycia poprawnej i spójnej konfiguracji na wszystkich węzłach. Typowym rozwiązaniem jest użycie tekstowego opisu wdrożenia (terraform, ansible lub coś podobnego) oraz automatycznie generowanych plików konfiguracyjnych (często przeznaczonych dla każdego węzła/roli). Chcielibyśmy również korzystać z tych samych protokołów w tych samych wersjach na wszystkich komunikujących się węzłach (w przeciwnym razie moglibyśmy napotkać problemy z niekompatybilnością). W świecie JVM oznacza to, że przynajmniej biblioteka messagingowa powinna mieć tę samą wersję na wszystkich komunikujących się węzłach.

A co z testowaniem systemu? Oczywiście powinniśmy mieć testy jednostkowe dla wszystkich komponentów przed przystąpieniem do testów integracyjnych. Aby móc ekstrapolować wyniki testów na czas działania, musimy upewnić się, że wersje wszystkich bibliotek są identyczne zarówno w środowisku uruchomieniowym, jak i testowym.

Podczas uruchamiania testów integracyjnych, często znacznie łatwiej jest mieć tę samą klasę klasy (classpath) na wszystkich węzłach. Musimy tylko upewnić się, że ta sama klasa klasy jest używana podczas wdrożenia. (Możliwe jest używanie różnych klas klas na różnych węzłach, ale trudniej jest przedstawić tę konfigurację i poprawnie ją wdrożyć.) Aby uprościć sprawy, rozważymy tylko identyczne klasy klas na wszystkich węzłach.

Konfiguracja ma tendencję do ewolucji razem z oprogramowaniem. Zwykle używamy wersji do identyfikacji różnych
etapów ewolucji oprogramowania. Wydaje się rozsądne objąć konfigurację zarządzaniem wersjami i identyfikować różne konfiguracje przy pomocy etykiet. Jeśli w produkcji znajduje się tylko jedna konfiguracja, możemy użyć pojedynczej wersji jako identyfikatora. Czasami możemy mieć wiele środowisk produkcyjnych. Dla każdego środowiska możemy potrzebować osobnej gałęzi konfiguracji. W związku z tym konfiguracje mogą być oznaczane etykietą gałęzi i wersji, aby unikalnie identyfikować różne konfiguracje. Każda etykieta gałęzi i wersja odpowiada pojedynczej kombinacji rozproszonych węzłów, portów, zasobów zewnętrznych i wersji bibliotek klasowych na każdym węźle. Tutaj poruszymy tylko pojedynczą gałąź i zidentyfikujemy konfiguracje przy pomocy trzyczęściowa wersja dziesiętna (1.2.3), w ten sam sposób, jak inne artefakty.

W nowoczesnych środowiskach pliki konfiguracyjne nie są już edytowane ręcznie. Zwykle generujemy
pliki konfiguracyjne w czasie wdrożenia i nigdy ich nie dotykamy później. Można by zapytać, dlaczego nadal używamy formatu tekstowego do plików konfiguracyjnych? Istotną alternatywą jest umieszczenie konfiguracji w jednostce kompilacyjnej i korzystanie z walidacji konfiguracji w czasie kompilacji.

W tym poście zbadamy pomysł przechowywania konfiguracji w skompilowanym artefakcie.

Konfiguracja nadająca się do kompilacji

W tej sekcji omówimy przykład statycznej konfiguracji. Dwie proste usługi — usługa echo i klient usługi echo są konfigurowane i implementowane. Następnie instancjonowane są dwa różne systemy rozproszone z obiema usługami. Jeden jest dla konfiguracji jednolódowej, a drugi dla konfiguracji dwułódowej.

Typowy system rozproszony składa się z kilku węzłów. Węzły można zidentyfikować przy użyciu jakiegoś typu:

sealed trait NodeId
case object Backend extends NodeId
case object Frontend extends NodeId

lub po prostu

case class NodeId(hostName: String)

lub nawet

object Singleton
type NodeId = Singleton.type

Te węzły pełnią różne role, uruchamiają różne usługi i powinny być w stanie komunikować się z innymi węzłami za pomocą połączeń TCP/HTTP.

Do połączenia TCP wymagany jest przynajmniej numer portu. Chcemy również upewnić się, że klient i serwer komunikują się tym samym protokołem. Aby modelować połączenie między węzłami, zadeklarujmy następującą klasę:

case class TcpEndPoint[Protocol](node: NodeId, port: Port[Protocol])

gdzie Port jest tylko Int w dozwolonym zakresie:

type PortNumber = Refined[Int, Closed[_0, W.`65535`.T]]

Udoskonalone typy

Zobacz refined bibliotekę. W skrócie, pozwala na dodanie warunków kompilacyjnych do innych typów. W tym przypadku Int może mieć tylko wartości 16-bitowe, które mogą reprezentować numer portu. Nie ma wymogu używania tej biblioteki w tym podejściu konfiguracyjnym. Po prostu wydaje się, że dobrze pasuje.

Dla HTTP (REST) możemy także potrzebować ścieżki usługi:

type UrlPathPrefix = Refined[String, MatchesRegex[W.`"[a-zA-Z_0-9\/]*"`.T]]
case class PortWithPrefix[Protocol](portNumber: PortNumber, pathPrefix: UrlPathPrefix)

Typ fantomowy

Aby zidentyfikować protokół podczas kompilacji, używamy funkcji Scala polegającej na deklarowaniu argumentu typu Protokół który nie jest używany w klasie. To tak zwany typ fantomowy. W czasie wykonywania rzadko potrzebujemy instancji identyfikatora protokołu, dlatego go nie przechowujemy. Podczas kompilacji ten typ fantomowy zapewnia dodatkowe bezpieczeństwo typów. Nie możemy przekazać portu z nieprawidłowym protokołem.

Jednym z najpowszechniej używanych protokołów jest REST API z serializacją Json:

sealed trait JsonHttpRestProtocol[RequestMessage, ResponseMessage]

gdzie RequestMessage jest podstawowym typem wiadomości, które klient może wysłać do serwera, a ResponseMessage jest wiadomością odpowiedzi z serwera. Oczywiście możemy stworzyć inne opisy protokołu, które określają protokół komunikacyjny z pożądaną precyzją.

Na potrzeby tego posta użyjemy prostszej wersji protokołu:

sealed trait SimpleHttpGetRest[RequestMessage, ResponseMessage]

W tym protokole wiadomość żądania jest dodawana do url, a wiadomość odpowiedzi jest zwracana jako zwykły ciąg znaków.

Konfiguracja usługi może być opisana przez nazwę usługi, zbiór portów i pewne zależności. Istnieje kilka możliwych sposobów reprezentacji wszystkich tych elementów w Scali (na przykład, HList, typy danych algebraiczne). Na potrzeby tego posta użyjemy wzorca Cake i przedstawimy kompozytowe elementy (moduły) jako cechy. (Wzorzec Cake nie jest wymogiem dla tego kompilowalnego podejścia konfiguracyjnego. To tylko jedna z możliwych implementacji pomysłu.)

Zależności mogą być reprezentowane za pomocą wzorca Cake jako końcówki innych węzłów:

  type EchoProtocol[A] = SimpleHttpGetRest[A, A]

  trait EchoConfig[A] extends ServiceConfig {
    def portNumber: PortNumber = 8081
    def echoPort: PortWithPrefix[EchoProtocol[A]] = PortWithPrefix[EchoProtocol[A]](portNumber, "echo")
    def echoService: HttpSimpleGetEndPoint[NodeId, EchoProtocol[A]] = providedSimpleService(echoPort)
  }

Usługa Echo potrzebuje jedynie skonfigurowanego portu. I ogłaszamy, że ten port obsługuje protokół echo. Zauważ, że w tym momencie nie musimy określać konkretnego portu, ponieważ cechy pozwalają na deklarację metod abstrakcyjnych. Jeśli użyjemy metod abstrakcyjnych, kompilator wymusi implementację w instancji konfiguracji. Tutaj dostarczyliśmy implementację (8081) i będzie ona używana jako wartość domyślna, jeśli ją pominiemy w konkretnej konfiguracji.

Możemy zadeklarować zależność w konfiguracji klienta usługi echo:

  trait EchoClientConfig[A] {
    def testMessage: String = "test"
    def pollInterval: FiniteDuration
    def echoServiceDependency: HttpSimpleGetEndPoint[_, EchoProtocol[A]]
  }

Zależność ma ten sam typ co echoService. W szczególności wymaga tego samego protokołu. Dzięki temu możemy być pewni, że jeśli połączymy te dwie zależności, będą działać poprawnie.

Implementacja usług

Usługa potrzebuje funkcji do uruchomienia i łagodnego zamknięcia (możliwość zamknięcia usługi jest kluczowa dla testowania). Ponownie istnieje kilka opcji określenia takiej funkcji dla danej konfiguracji (na przykład, moglibyśmy użyć klas typów). Na potrzeby tego posta ponownie użyjemy wzorca Cake. Możemy przedstawić usługę za pomocą cats.Resource który już zapewnia klamrowanie i zwalnianie zasobów. Aby nabyć zasób, powinniśmy podać konfigurację i pewien kontekst wykonawczy. Funkcja uruchamiająca usługę może wyglądać następująco:

  type ResourceReader[F[_], Config, A] = Reader[Config, Resource[F, A]]

  trait ServiceImpl[F[_]] {
    type Config
    def resource(
      implicit
      resolver: AddressResolver[F],
      timer: Timer[F],
      contextShift: ContextShift[F],
      ec: ExecutionContext,
      applicative: Applicative[F]
    ): ResourceReader[F, Config, Unit]
  }

gdzie

  • Konfiguracja — typ konfiguracji wymagany przez to uruchamianie usługi
  • AddressResolver — obiekt wykonawczy, który ma możliwość uzyskania rzeczywistych adresów innych węzłów (czytaj dalej dla szczegółów).

pozostałe typy pochodzą od cats:

  • F[_] — typ efektu (w najprostszym przypadku F[A] może być po prostu () => A. W tym poście użyjemy cats.IO.)
  • Reader[A,B] — to mniej więcej synonim funkcji A => B
  • cats.Resource — ma sposoby na nabywanie i uwalnianie
  • Timer — pozwala na spanie/miarę czasu
  • ContextShift — analog ExecutionContext
  • Applicative — opakowanie funkcji w efekcie (prawie monada) (możemy ostatecznie zastąpić to czymś innym)

Używając tego interfejsu możemy zaimplementować kilka usług. Na przykład, usługę, która nic nie robi:

  trait ZeroServiceImpl[F[_]] extends ServiceImpl[F] {
    type Config  Resource.pure[F, Unit](()))
  }

(Zobacz Kod źródłowy dla innych implementacji usług — usługa echo,
klient echo i kontrolery cyklu życia.)

Węzeł to pojedynczy obiekt, który uruchamia kilka usług (uruchomienie łańcucha zasobów jest możliwe dzięki wzorcowi Cake):

object SingleNodeImpl extends ZeroServiceImpl[IO]
  with EchoServiceService
  with EchoClientService
  with FiniteDurationLifecycleServiceImpl
{
  type Config = EchoConfig[String] with EchoClientConfig[String] with FiniteDurationLifecycleConfig
}

Zauważ, że w węźle określamy dokładny typ konfiguracji, który jest potrzebny przez ten węzeł. Kompilator nie pozwoli nam zbudować obiektu (Cake) z niewystarczającym typem, ponieważ każda cecha usługi deklaruje ograniczenie na Konfiguracja Typ. Nie będziemy w stanie uruchomić węzła bez dostarczenia pełnej konfiguracji.

Rozwiązywanie adresów węzłów

Aby nawiązać połączenie, potrzebujemy prawdziwego adresu hosta dla każdego węzła. Może on być znany później niż inne części konfiguracji. Dlatego potrzebujemy sposobu na dostarczenie mapowania między identyfikatorem węzła a jego rzeczywistym adresem. To mapowanie to funkcja:

case class NodeAddress[NodeId](host: Uri.Host)
trait AddressResolver[F[_]] {
  def resolve[NodeId](nodeId: NodeId): F[NodeAddress[NodeId]]
}

Istnieje kilka możliwych sposobów implementacji takiej funkcji.

  1. Jeśli znamy rzeczywiste adresy przed wdrożeniem, podczas inicjalizacji hostów węzłów, możemy wygenerować kod Scala z rzeczywistymi adresami i uruchomić kompilację później (co wykonuje kontrole w czasie kompilacji, a następnie uruchamia zestaw testów integracyjnych). W takim przypadku nasza funkcja mapująca jest znana statycznie i może zostać uproszczona do czegoś w rodzaju Map[NodeId, NodeAddress].
  2. Czasami uzyskujemy rzeczywiste adresy dopiero w późniejszym czasie, gdy węzeł jest faktycznie uruchomiony, lub nie mamy adresów węzłów, które jeszcze nie zostały uruchomione. W takim przypadku możemy mieć usługę odkrywania, która jest uruchamiana przed wszystkimi innymi węzłami, a każdy węzeł może ogłaszać swój adres w tej usłudze i subskrybować zależności.
  3. Jeśli możemy zmodyfikować /etc/hosts, możemy użyć predefiniowanych nazw hostów (jak my-project-main-node i echo-backend) i po prostu skojarzyć tę nazwę z adresem ip w czasie wdrożenia.

W tym poście nie omawiamy tych przypadków bardziej szczegółowo. W rzeczywistości w naszym przykładowym projekcie wszystkie węzły będą miały ten sam adres IP — 127.0.0.1.

W tym poście rozważymy dwa układy systemów rozproszonych:

  1. Układ jednego węzła, w którym wszystkie usługi są umieszczone na jednym węźle.
  2. Układ dwóch węzłów, w którym usługa i klient znajdują się na różnych węzłach.

Konfiguracja dla jednego węzła układ jest następująca:

Konfiguracja jednego węzła

object SingleNodeConfig extends EchoConfig[String] 
  with EchoClientConfig[String] with FiniteDurationLifecycleConfig
{
  case object Singleton \/\/ identyfikator pojedynczego węzła 
  \/\/ konfiguracja serwera
  type NodeId = Singleton.type
  def nodeId = Singleton

  \/** Specyfikacja portu usługi typowe dla bezpieczeństwa. *\/ 
  override def portNumber: PortNumber = 8088

  \/\/ konfiguracja klienta

  \/** Będziemy korzystać z usługi udostępnianej przez ten sam host. *\/ 
  def echoServiceDependency = echoService

  override def testMessage: UrlPathElement = "hello"

  def pollInterval: FiniteDuration = 1.second

  \/\/ konfiguracja kontrolera cyklu życia
  def lifetime: FiniteDuration = 10500.milliseconds \/\/ dodatkowe 0.5 sekundy, aby były 10 żądań, a nie 9.
}

Tutaj tworzymy jedną konfigurację, która rozszerza zarówno konfigurację serwera, jak i klienta. Konfigurujemy również kontroler cyklu życia, który normalnie zakończy działanie klienta i serwera po upływie lifetime okresu.

Ten sam zestaw implementacji usług i konfiguracji może być użyty do stworzenia układu systemu z dwoma oddzielnymi węzłami. Musimy tylko stworzyć dwie oddzielne konfiguracje węzłów z odpowiednimi usługami:

Konfiguracja dwóch węzłów

  object NodeServerConfig extends EchoConfig[String] with SigTermLifecycleConfig
  {
    type NodeId = NodeIdImpl

    def nodeId = NodeServer

    override def portNumber: PortNumber = 8080
  }

  object NodeClientConfig extends EchoClientConfig[String] with FiniteDurationLifecycleConfig
  {
    \/\/ UWAGA! specyfikacja zależności
    def echoServiceDependency = NodeServerConfig.echoService

    def pollInterval: FiniteDuration = 1.second

    def lifetime: FiniteDuration = 10500.milliseconds \/\/ dodatkowe 0.5 sekundy, aby były 10 żądań, a nie 9.

    def testMessage: String = "dolly"
  }

Zobacz, jak określamy zależność. Wspominamy o udostępnionej usłudze drugiego węzła jako zależności aktualnego węzła. Typ zależności jest sprawdzany, ponieważ zawiera typ fantomowy, który opisuje protokół. A w czasie działania będziemy mieli poprawny identyfikator węzła. To jeden z ważnych aspektów zaproponowanego podejścia do konfiguracji. Daje nam możliwość ustawienia portu tylko raz i upewnienia się, że odnosimy się do poprawnego portu.

Implementacja dwóch węzłów

Dla tej konfiguracji używamy dokładnie tych samych implementacji usług. Żadne zmiany w ogóle. Jednak tworzymy dwie różne implementacje węzłów, które zawierają różny zestaw usług:

  object TwoJvmNodeServerImpl extends ZeroServiceImpl[IO] with EchoServiceService with SigIntLifecycleServiceImpl {
    type Config = EchoConfig[String] with SigTermLifecycleConfig
  }

  object TwoJvmNodeClientImpl extends ZeroServiceImpl[IO] with EchoClientService with FiniteDurationLifecycleServiceImpl {
    type Config = EchoClientConfig[String] with FiniteDurationLifecycleConfig
  }

Pierwszy węzeł implementuje serwer i potrzebuje tylko konfiguracji po stronie serwera. Drugi węzeł implementuje klienta i potrzebuje innej części konfiguracji. Oba węzły wymagają określenia czasu życia. Dla celów tego posta węzeł serwisowy będzie miał nieskończony czas życia, który można zakończyć przy użyciu SIGTERM, podczas gdy klient echo zakończy się po skonfigurowanym ograniczonym czasie. Zobacz aplikację startową po szczegóły.

Ogólny proces rozwoju

Zobaczmy, jak to podejście zmienia sposób, w jaki pracujemy z konfiguracją.

Konfiguracja jako kod będzie kompilowana i generowała artefakt. Wydaje się rozsądne, aby oddzielić artefakt konfiguracji od innych artefaktów kodu. Często możemy mieć wiele konfiguracji w tej samej bazie kodowej. Oczywiście możemy mieć również wiele wersji różnych gałęzi konfiguracji. W konfiguracji możemy wybrać konkretne wersje bibliotek, a to pozostanie stałe zawsze, gdy wdrażamy tę konfigurację.

Zmiana konfiguracji staje się zmianą kodu. Dlatego powinna być objęta tym samym procesem zapewnienia jakości:

Zgłoszenie -> PR -> przegląd -> scalanie -> ciągła integracja -> ciągłe wdrażanie

Istnieją następujące konsekwencje tego podejścia:

  1. Konfiguracja jest spójna dla instancji danego systemu. Wydaje się, że nie ma możliwości wystąpienia błędnego połączenia między węzłami.
  2. Nie jest łatwo zmienić konfigurację tylko w jednym węźle. Wydaje się to nierozsądne, aby logować się i zmieniać niektóre pliki tekstowe. Tak więc drift konfiguracji staje się mniej możliwy.
  3. Małe zmiany w konfiguracji są trudne do wprowadzenia.
  4. Większość zmian w konfiguracji będzie przebiegać według tego samego procesu rozwoju i przejdzie pewną kontrolę.

Czy potrzebujemy osobnego repozytorium dla konfiguracji produkcyjnej? Konfiguracja produkcyjna może zawierać wrażliwe informacje, które chcielibyśmy zachować poza zasięgiem wielu osób. Dlatego warto byłoby utrzymać osobne repozytorium z ograniczonym dostępem, które będzie zawierać konfigurację produkcyjną. Możemy podzielić konfigurację na dwie części — jedna zawiera większość otwartych parametrów produkcyjnych, a druga zawiera sekretną część konfiguracji. To umożliwi dostęp większości programistów do większości parametrów, jednocześnie ograniczając dostęp do naprawdę wrażliwych rzeczy. Łatwo to osiągnąć, używając pośrednich atrybutów z domyślnymi wartościami parametrów.

Wariacje

Przyjrzyjmy się zaletom i wadom proponowanego podejścia w porównaniu do innych technik zarządzania konfiguracją.

Przede wszystkim wymienimy kilka alternatyw różnych aspektów proponowanego sposobu radzenia sobie z konfiguracją:

  1. Plik tekstowy na docelowej maszynie.
  2. Centralne przechowywanie kluczy i wartości (jak etcd/zookeeper).
  3. Komponenty podprocesów, które można konfigurować/powiększać bez ponownego uruchamiania procesu.
  4. Konfiguracja poza artefaktem i kontrolą wersji.

Plik tekstowy daje pewną elastyczność w zakresie ad-hoc poprawek. Administrator systemu może zalogować się do docelowego węzła, wprowadzić zmianę i po prostu zrestartować usługę. Może to nie być tak dobre dla większych systemów. Nie zostają żadne ślady po zmianie. Zmiana nie jest przeglądana przez dodatkową parę oczu. Może być trudno ustalić, co spowodowało zmianę. Nie została przetestowana. Z perspektywy systemu rozproszonego administrator może po prostu zapomnieć zaktualizować konfigurację w jednym z pozostałych węzłów.

(Zresztą, jeśli ostatecznie zajdzie potrzeba rozpoczęcia używania plików konfiguracyjnych tekstowych, będziemy musieli jedynie dodać parser + walidator, który będzie mógł generować ten sam Konfiguracja typ i to wystarczy, aby rozpocząć korzystanie z konfiguracji tekstowej. To także pokazuje, że złożoność konfiguracji w czasie kompilacji jest nieco mniejsza niż złożoność konfiguracji tekstowych, ponieważ w wersji tekstowej potrzebujemy dodatkowego kodu.)

Centralne przechowywanie kluczy i wartości to dobry mechanizm do dystrybucji metaparametrów aplikacji. Musimy tutaj przemyśleć, co uważamy za wartości konfiguracyjne, a co jest tylko danymi. Mając funkcję C => A => B zwykle nazywamy rzadko zmieniające się wartości C „konfiguracją”, podczas gdy często zmieniane dane A — to tylko dane wejściowe. Konfiguracja powinna być dostarczona do funkcji wcześniej niż dane. A. Biorąc pod uwagę tę ideę, możemy powiedzieć, że to przewidywana częstotliwość zmian może być używana do odróżnienia danych konfiguracyjnych od zwykłych danych. Ponadto dane zazwyczaj pochodzą z jednego źródła (użytkownika), a konfiguracja pochodzi z innego źródła (administratora). Zajmowanie się parametrami, które mogą być zmienione po inicjalizacji procesu, prowadzi do zwiększenia złożoności aplikacji. Dla takich parametrów będziemy musieli zająć się ich mechanizmem dostarczania, analizą i walidacją, obsługą błędnych wartości. Dlatego, aby zmniejszyć złożoność programu, lepiej będzie ograniczyć liczbę parametrów, które mogą zmieniać się w czasie działania (lub nawet całkowicie je wyeliminować).

Z perspektywy tego wpisu powinniśmy rozróżnić między parametrami statycznymi a dynamicznymi. Jeśli logika usługi wymaga rzadkiej zmiany niektórych parametrów w czasie działania, możemy je nazwać parametrami dynamicznymi. W przeciwnym razie są one statyczne i mogą być konfigurowane za pomocą zaproponowanego podejścia. W przypadku dynamicznej rekonfiguracji mogą być potrzebne inne podejścia. Na przykład, części systemu mogą być ponownie uruchamiane z nowymi parametrami konfiguracyjnymi w podobny sposób, jak uruchamia się osobne procesy rozproszonego systemu.
(Moim skromnym zdaniem należy unikać rekonfiguracji w czasie działania, ponieważ zwiększa to złożoność systemu.
Może być łatwiej polegać na wsparciu systemu operacyjnego w zakresie ponownego uruchamiania procesów. Chociaż nie zawsze może to być możliwe.)

Jednym z ważnych aspektów używania statycznej konfiguracji, który czasami sprawia, że ludzie rozważają konfigurację dynamiczną (bez innych powodów), jest przestoik usługi podczas aktualizacji konfiguracji. Rzeczywiście, jeśli musimy wprowadzić zmiany w statycznej konfiguracji, musimy ponownie uruchomić system, aby nowe wartości stały się skuteczne. Wymagania dotyczące przestoju różnią się w zależności od systemów, więc może to nie być tak krytyczne. Jeśli jest krytyczne, musimy zaplanować z wyprzedzeniem jakiekolwiek ponowne uruchomienia systemu. Na przykład mogliśmy zaimplementować drainację połączeń AWS ELB. W tym scenariuszu, gdy musimy ponownie uruchomić system, uruchamiamy nową instancję systemu równolegle, a następnie przełączamy ELB na nią, pozwalając starym systemom na zakończenie obsługi istniejących połączeń.

Co z przechowywaniem konfiguracji wewnątrz artefaktu wersjonowanego lub na zewnątrz? Przechowywanie konfiguracji wewnątrz artefaktu oznacza w większości przypadków, że ta konfiguracja przeszła ten sam proces zapewnienia jakości co inne artefakty. Można być więc pewnym, że konfiguracja jest dobrej jakości i godna zaufania. Przeciwnie, konfiguracja w osobnym pliku oznacza, że nie ma śladów tego, kto i dlaczego wprowadził zmiany do tego pliku. Czy to ma znaczenie? Uważamy, że dla większości systemów produkcyjnych lepiej jest mieć stabilną i wysokiej jakości konfigurację.

Wersja artefaktu pozwala dowiedzieć się, kiedy został utworzony, jakie wartości zawiera, jakie funkcje są włączone/wyłączone, kto był odpowiedzialny za wprowadzenie każdej zmiany w konfiguracji. Może to wymagać pewnych działań, aby utrzymać konfigurację wewnątrz artefaktu i jest to wybór projektowy.

Zalety i wady

Chcielibyśmy podkreślić kilka zalet oraz omówić pewne wady zaproponowanego podejścia.

Zalety

Cecha kompilowalnej konfiguracji pełnego systemu rozproszonego:

  1. Statyczna kontrola konfiguracji. To daje wysoki poziom pewności, że konfiguracja jest poprawna, biorąc pod uwagę ograniczenia typu.
  2. Bogaty język konfiguracji. Typowo inne podejścia do konfiguracji są ograniczone do maksymalnie podstawowej substytucji zmiennych.
    Używając Scali, można wykorzystać szeroki zakres funkcji języka, aby poprawić konfigurację. Na przykład, możemy używać traitów do dostarczania wartości domyślnych, obiektów do ustawiania różnych zasięgów, możemy odnosić się do valzdefiniowanych tylko raz w zasięgu zewnętrznym (DRY). Możliwe jest użycie sekwencji dosłownych lub instancji niektórych klas (Seq, Map, itd.).
  3. DSL. Scala ma przyzwoite wsparcie dla twórców DSL. Można korzystać z tych funkcji, aby ustanowić język konfiguracji, który jest bardziej wygodny i przyjazny dla użytkownika końcowego, tak aby końcowa konfiguracja była przynajmniej czytelna dla użytkowników z danej dziedziny.
  4. Integralność i spójność w całym systemie. Jedną z korzyści z posiadania konfiguracji dla całego rozproszonego systemu w jednym miejscu jest to, że wszystkie wartości są definiowane ściśle raz i w każdej sytuacji są ponownie wykorzystywane tam, gdzie są potrzebne. Również bezpieczne typowo deklaracje portów zapewniają, że we wszystkich możliwych poprawnych konfiguracjach węzły systemu będą mówić tym samym językiem. Istnieją wyraźne zależności między węzłami, co sprawia, że trudno jest zapomnieć o dostarczeniu niektórych usług.
  5. Wysoka jakość zmian. Ogólne podejście polegające na przekazywaniu zmian konfiguracyjnych przez normalny proces PR ustanawia wysokie standardy jakości również w konfiguracji.
  6. Jednoczesne zmiany konfiguracyjne. Kiedy wprowadzamy jakiekolwiek zmiany w konfiguracji, automatyczne wdrożenie zapewnia aktualizację wszystkich węzłów.
  7. Uproszczenie aplikacji. Aplikacja nie musi analizować i weryfikować konfiguracji oraz radzić sobie z niepoprawnymi wartościami konfiguracyjnymi. To upraszcza ogólną aplikację. (Nieco bardziej złożona staje się sama konfiguracja, ale to świadome ustępstwo na rzecz bezpieczeństwa.) Powrót do zwykłej konfiguracji jest całkiem proste — wystarczy dodać brakujące elementy. Łatwiej jest zacząć od skompilowanej konfiguracji i odłożyć wdrożenie dodatkowych elementów na później.
  8. Konfiguracja wersjonowana. Ponieważ zmiany konfiguracyjne podążają tym samym procesem rozwoju, otrzymujemy artefakt o unikalnej wersji. Pozwala nam to przywrócić konfigurację, jeśli zajdzie taka potrzeba. Możemy nawet wdrożyć konfigurację, która była używana rok temu, a zadziała dokładnie tak samo. Stabilna konfiguracja poprawia przewidywalność i niezawodność rozproszonego systemu. Konfiguracja jest ustalana w czasie kompilacji i nie może być łatwo manipulowana w systemie produkcyjnym.
  9. Modularność. Proponowana struktura jest modularna, a moduły mogą być łączone na różne sposoby, aby
    wsparcie różnych konfiguracji (ustawień/układów). W szczególności możliwe jest posiadanie układu z pojedynczym węzłem o małej skali oraz ustawienia z wieloma węzłami o dużej skali. Rozsądne jest posiadanie wielu układów produkcyjnych.
  10. Testowanie. Do celów testowych można wdrożyć serwis mock i używać go jako zależności w bezpieczny typowo sposób. Można jednocześnie utrzymywać kilka różnych układów testowych z różnymi częściami zastępowanymi przez mocki.
  11. Testowanie integracyjne. Czasami w systemach rozproszonych trudno jest przeprowadzić testy integracyjne. Używając opisanego podejścia do bezpiecznej typowo konfiguracji całego systemu rozproszonego, możemy uruchomić wszystkie rozproszone części na jednym serwerze w kontrolowany sposób. Łatwo jest emulować sytuację
    kiedy jeden z serwisów staje się niedostępny.

Wady

Podejście do skonfigurowanej kompilacji różni się od zwykłej konfiguracji i może nie odpowiadać wszystkim potrzebom. Oto niektóre wady skonfigurowanej konfiguracji:

  1. Statyczna konfiguracja. Może być nieodpowiednia dla wszystkich aplikacji. W niektórych przypadkach istnieje potrzeba szybkiej naprawy konfiguracji w produkcji, omijając wszystkie środki bezpieczeństwa. To podejście utrudnia to. Kompilacja i ponowne wdrożenie są wymagane po wprowadzeniu jakichkolwiek zmian w konfiguracji. To zarówno zaleta, jak i obciążenie.
  2. Generowanie konfiguracji. Kiedy konfiguracja jest generowana przez narzędzie automatyzacji, to podejście wymaga dalszej kompilacji (która może niezawodnościować). Może wymagać dodatkowego wysiłku na zintegrowanie tego dodatkowego kroku w systemie budowania.
  3. Narzędzia. Dzisiaj istnieje wiele narzędzi, które opierają się na konfiguracjach tekstowych. Niektóre z nich
    nie będą miały zastosowania, gdy konfiguracja jest skompilowana.
  4. Potrzebna jest zmiana w myśleniu. Programiści i DevOps są zaznajomieni z plikami konfiguracyjnymi tekstowymi. Idea kompilowania konfiguracji może wydawać się im dziwna.
  5. Przed wprowadzeniem konfigurowalnej kompilacji wymagany jest wysoka jakość procesu rozwoju oprogramowania.

Istnieją pewne ograniczenia zaimplementowanego przykładu:

  1. Jeśli dostarczymy dodatkową konfigurację, która nie jest wymagane przez implementację węzła, kompilator nie pomoże nam wykryć brakującą implementację. Można to rozwiązać, używając HList lub ADT (klas klasowych) dla konfiguracji węzła zamiast cech i wzorca Cake.
  2. Musimy dostarczyć pewien boilerplate w pliku konfiguracyjnym: (pakiet, importować, obiekt deklaracje;
    nadpisz's dla parametrów, które mają wartości domyślne). To może być częściowo zaadresowane za pomocą DSL.
  3. W tym poście nie poruszamy dynamicznej rekonfiguracji klastrów podobnych węzłów.

Podsumowanie

W tym wpisie omówiliśmy pomysł bezpośredniego reprezentowania konfiguracji w kodzie źródłowym w sposób bezpieczny typowo. Podejście to można wykorzystać w wielu aplikacjach jako alternatywę dla konfiguracji opartych na XML i innych tekstowych konfiguracjach. Mimo że nasz przykład został zaimplementowany w Scali, można go również przenieść na inne języki kompilowane (np. Kotlin, C#, Swift itd.). Można spróbować tego podejścia w nowym projekcie i, jeśli nie będzie pasowało, przejść do tradycyjnego sposobu.

Oczywiście kompilowalna konfiguracja wymaga wysokiej jakości procesu rozwoju. W zamian obiecuje zapewnić równie wysokiej jakości solidną konfigurację.

To podejście można rozszerzać na różne sposoby:

  1. Można użyć makr do weryfikacji konfiguracji i uzyskać błąd w czasie kompilacji w przypadku jakichkolwiek błędów w ograniczeniach logiki biznesowej.
  2. Można zaimplementować DSL, aby reprezentować konfigurację w sposób przyjazny dla użytkownika w danej dziedzinie.
  3. Dynamiczne zarządzanie zasobami z automatycznymi dostosowaniami konfiguracji. Na przykład, kiedy dostosowujemy liczbę węzłów klastra, możemy chcieć (1) aby węzły uzyskały nieco zmodyfikowaną konfigurację; (2) aby menedżer klastra otrzymał nowe informacje o węzłach.

Dziękuję

Chciałbym podziękować Andreyowi Saksonovowi, Pawłowi Popovowi, Antonowi Nehaevowi za inspirujące uwagi do wersji roboczej tego wpisu, które pomogły mi uczynić go jaśniejszym.

Źródło: habr.com

Kup solidny hosting stron z ochroną przed DDoS, serwery VPS VDS 🔥 Kup solidny hosting stron z ochroną przed DDoS, serwery VPS VDS | ProHoster