Skompilowana konfiguracja systemu rozproszonego

Chciałbym opowiedzieć o jednym ciekawym mechanizmie pracy z konfiguracją systemu rozproszonego. Konfiguracja jest reprezentowana bezpośrednio w języku skompilowanym (Scala) przy użyciu bezpiecznych typów. W tym poście przedstawiono przykład takiej konfiguracji i omówiono różne aspekty wdrażania skompilowanej konfiguracji w całym procesie programowania.

Skompilowana konfiguracja systemu rozproszonego

(język angielski)

Wprowadzenie

Budowa niezawodnego systemu rozproszonego oznacza, że ​​wszystkie węzły korzystają z prawidłowej konfiguracji, zsynchronizowanej z innymi węzłami. Technologie DevOps (terraform, ansible lub coś w tym stylu) są zwykle używane do automatycznego generowania plików konfiguracyjnych (często specyficznych dla każdego węzła). Chcielibyśmy także mieć pewność, że wszystkie węzły komunikujące się korzystają z identycznych protokołów (w tym tej samej wersji). W przeciwnym razie w naszym rozproszonym systemie zostanie wbudowana niezgodność. W świecie JVM jedną z konsekwencji tego wymagania jest to, że wszędzie musi być używana ta sama wersja biblioteki zawierająca komunikaty protokołu.

A co z testowaniem systemu rozproszonego? Oczywiście zakładamy, że wszystkie komponenty mają testy jednostkowe, zanim przejdziemy do testów integracyjnych. (Abyśmy mogli ekstrapolować wyniki testów na środowisko wykonawcze, musimy również zapewnić identyczny zestaw bibliotek na etapie testowania i w czasie wykonywania.)

Podczas pracy z testami integracyjnymi często łatwiej jest użyć tej samej ścieżki klas wszędzie i na wszystkich węzłach. Wszystko, co musimy zrobić, to upewnić się, że w czasie wykonywania używana jest ta sama ścieżka klas. (Chociaż możliwe jest uruchamianie różnych węzłów z różnymi ścieżkami klas, zwiększa to złożoność ogólnej konfiguracji i trudności z testami wdrażania i integracji.) Na potrzeby tego wpisu zakładamy, że wszystkie węzły będą używać tej samej ścieżki klas.

Konfiguracja ewoluuje wraz z aplikacją. Używamy wersji, aby zidentyfikować różne etapy ewolucji programu. Logiczne wydaje się także identyfikowanie różnych wersji konfiguracji. I umieść samą konfigurację w systemie kontroli wersji. Jeśli w produkcji jest tylko jedna konfiguracja, możemy po prostu użyć numeru wersji. Jeśli będziemy używać wielu instancji produkcyjnych, będziemy potrzebować kilku
gałęzie konfiguracyjne oraz dodatkową etykietę oprócz wersji (np. nazwę gałęzi). W ten sposób możemy wyraźnie zidentyfikować dokładną konfigurację. Każdy identyfikator konfiguracji jednoznacznie odpowiada określonej kombinacji rozproszonych węzłów, portów, zasobów zewnętrznych i wersji bibliotek. Na potrzeby tego wpisu założymy, że jest tylko jedna gałąź i konfigurację możemy zidentyfikować w zwykły sposób za pomocą trzech liczb oddzielonych kropką (1.2.3).

W nowoczesnych środowiskach pliki konfiguracyjne rzadko są tworzone ręcznie. Częściej są one generowane podczas wdrażania i nie są już dotykane (tak, że niczego nie łam). Powstaje naturalne pytanie: dlaczego nadal używamy formatu tekstowego do przechowywania konfiguracji? Realną alternatywą wydaje się możliwość użycia zwykłego kodu do konfiguracji i skorzystania ze kontroli w czasie kompilacji.

W tym poście przyjrzymy się idei reprezentowania konfiguracji wewnątrz skompilowanego artefaktu.

Skompilowana konfiguracja

W tej sekcji przedstawiono przykład statycznej skompilowanej konfiguracji. Zaimplementowano dwie proste usługi - usługę echo i klienta usługi echo. W oparciu o te dwie usługi montowane są dwie opcje systemu. W jednym wariancie obie usługi zlokalizowane są w tym samym węźle, w innym wariancie – w różnych węzłach.

Zazwyczaj system rozproszony zawiera kilka węzłów. Możesz identyfikować węzły za pomocą wartości pewnego typu NodeId:

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

lub

case class NodeId(hostName: String)

lub

object Singleton
type NodeId = Singleton.type

Węzły pełnią różne role, uruchamiają usługi i można między nimi nawiązywać połączenia TCP/HTTP.

Aby opisać połączenie TCP potrzebujemy przynajmniej numeru portu. Chcielibyśmy również uwzględnić protokół obsługiwany na tym porcie, aby mieć pewność, że zarówno klient, jak i serwer korzystają z tego samego protokołu. Połączenie opiszemy za pomocą następującej klasy:

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

gdzie Port - tylko liczba całkowita Int wskazanie zakresu dopuszczalnych wartości:

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

Wyrafinowane typy

Zobacz bibliotekę rafinowany и moja raport. Krótko mówiąc, biblioteka umożliwia dodawanie ograniczeń do typów sprawdzanych w czasie kompilacji. W tym przypadku prawidłowymi wartościami numeru portu są 16-bitowe liczby całkowite. W przypadku skompilowanej konfiguracji użycie udoskonalonej biblioteki nie jest obowiązkowe, ale poprawia zdolność kompilatora do sprawdzania konfiguracji.

W przypadku protokołów HTTP (REST) ​​oprócz numeru portu może nam się przydać także ścieżka do usługi:

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

Typy fantomowe

Aby zidentyfikować protokół w czasie kompilacji, używamy parametru typu, który nie jest używany w klasie. Decyzja ta wynika z faktu, że w czasie wykonywania nie używamy instancji protokołu, ale chcielibyśmy, aby kompilator sprawdził zgodność protokołu. Określając protokół, nie będziemy mogli przekazać niewłaściwej usługi jako zależności.

Jednym z powszechnych protokołów jest API REST z serializacją Json:

sealed trait JsonHttpRestProtocol[RequestMessage, ResponseMessage]

gdzie RequestMessage - rodzaj żądania, ResponseMessage — rodzaj odpowiedzi.
Oczywiście możemy zastosować inne opisy protokołów, które zapewnią wymaganą przez nas dokładność opisu.

Na potrzeby tego wpisu użyjemy uproszczonej wersji protokołu:

sealed trait SimpleHttpGetRest[RequestMessage, ResponseMessage]

W tym przypadku żądanie jest ciągiem znaków dołączonym do adresu URL, a odpowiedzią jest ciąg zwrócony w treści odpowiedzi HTTP.

Konfiguracja usługi jest opisana przez nazwę usługi, porty i zależności. Elementy te można reprezentować w Scali na kilka sposobów (np. HList-s, algebraiczne typy danych). Na potrzeby tego wpisu użyjemy wzorca ciasta i przedstawimy moduły za pomocą trait„ow. (Wzorzec ciasta nie jest wymaganym elementem tego podejścia. Jest to po prostu jedna z możliwych implementacji.)

Zależności między usługami można przedstawić jako metody zwracające porty EndPointinnych 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)
  }

Aby utworzyć usługę echo, wystarczy numer portu i wskazanie, że port obsługuje protokół echo. Możemy nie określić konkretnego portu, ponieważ... cechy pozwalają na deklarowanie metod bez implementacji (metody abstrakcyjne). W tym wypadku przy tworzeniu konkretnej konfiguracji kompilator wymagałby od nas podania implementacji metody abstrakcyjnej oraz podania numeru portu. Ponieważ zaimplementowaliśmy tę metodę, tworząc konkretną konfigurację, nie możemy określić innego portu. Zostanie użyta wartość domyślna.

W konfiguracji klienta deklarujemy zależność od usługi echo:

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

Zależność jest tego samego typu co wyeksportowana usługa echoService. W szczególności w kliencie echo potrzebujemy tego samego protokołu. Dlatego łącząc dwie usługi możemy być pewni, że wszystko będzie działać poprawnie.

Wdrażanie usług

Do uruchamiania i zatrzymywania usługi wymagana jest funkcja. (Możliwość zatrzymania usługi ma kluczowe znaczenie podczas testowania). Ponownie istnieje kilka opcji implementacji takiej funkcji (na przykład możemy użyć klas typów w oparciu o typ konfiguracji). Na potrzeby tego wpisu wykorzystamy wzór ciasta. Będziemy reprezentować usługę za pomocą klasy cats.Resource, ponieważ Ta klasa już zapewnia środki gwarantujące bezpieczne uwolnienie zasobów w przypadku problemów. Aby otrzymać zasób musimy podać konfigurację i gotowy kontekst wykonawczy. Funkcja uruchamiania usługi 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

  • Config — typ konfiguracji dla tej usługi
  • AddressResolver — obiekt wykonawczy, który pozwala poznać adresy innych węzłów (patrz poniżej)

i inne typy z biblioteki cats:

  • F[_] — rodzaj efektu (w najprostszym przypadku F[A] może być po prostu funkcją () => A. W tym poście będziemy używać cats.IO.)
  • Reader[A,B] - mniej więcej synonim funkcji A => B
  • cats.Resource - zasób, który można pozyskać i uwolnić
  • Timer — timer (pozwala na chwilę zasnąć i odmierzać odstępy czasowe)
  • ContextShift - analogowe ExecutionContext
  • Applicative — klasa typu efektu, która pozwala łączyć poszczególne efekty (prawie monada). W bardziej skomplikowanych zastosowaniach wydaje się, że lepiej jest użyć Monad/ConcurrentEffect.

Używając tej sygnatury funkcji możemy zaimplementować kilka usług. Na przykład usługa, która nic nie robi:

  trait ZeroServiceImpl[F[_]] extends ServiceImpl[F] {
    type Config <: Any
    def resource(...): ResourceReader[F, Config, Unit] =
      Reader(_ => Resource.pure[F, Unit](()))
  }

(Cm. źródło, w którym realizowane są inne usługi - usługa echa, klient echa
и kontrolery na całe życie.)

Węzeł to obiekt, który może uruchomić kilka usług (uruchomienie łańcucha zasobów zapewnia Cake Pattern):

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

Należy pamiętać, że określamy dokładny typ konfiguracji wymagany dla tego węzła. Jeśli zapomnimy określić jeden z typów konfiguracji wymaganych przez konkretną usługę, wystąpi błąd kompilacji. Nie będziemy też mogli uruchomić węzła, jeśli nie podamy jakiegoś obiektu odpowiedniego typu ze wszystkimi niezbędnymi danymi.

Rozpoznawanie nazw hostów

Aby połączyć się ze zdalnym hostem, potrzebujemy prawdziwego adresu IP. Możliwe, że adres będzie znany później niż reszta konfiguracji. Potrzebujemy więc funkcji, która odwzorowuje identyfikator węzła na adres:

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

Istnieje kilka sposobów wdrożenia tej funkcji:

  1. Jeśli adresy staną się nam znane przed wdrożeniem, możemy wygenerować kod Scala za pomocą
    adresy, a następnie uruchom kompilację. Spowoduje to skompilowanie i uruchomienie testów.
    W tym przypadku funkcja będzie znana statycznie i można ją przedstawić w kodzie jako mapowanie Map[NodeId, NodeAddress].
  2. W niektórych przypadkach rzeczywisty adres jest znany dopiero po uruchomieniu węzła.
    W takim przypadku możemy zaimplementować „usługę wykrywania”, która będzie działać przed innymi węzłami, a wszystkie węzły zarejestrują się w tej usłudze i zażądają adresów innych węzłów.
  3. Jeśli możemy zmodyfikować /etc/hosts, możesz użyć predefiniowanych nazw hostów (np my-project-main-node и echo-backend) i po prostu połącz te nazwy
    z adresami IP podczas wdrażania.

W tym poście nie będziemy omawiać tych przypadków bardziej szczegółowo. Dla naszych
w przykładzie zabawki wszystkie węzły będą miały ten sam adres IP - 127.0.0.1.

Następnie rozważamy dwie opcje systemu rozproszonego:

  1. Umieszczenie wszystkich usług w jednym węźle.
  2. Oraz hostowanie usługi echa i klienta echa w różnych węzłach.

Konfiguracja dla jeden węzeł:

Konfiguracja pojedynczego węzła

object SingleNodeConfig extends EchoConfig[String] 
  with EchoClientConfig[String] with FiniteDurationLifecycleConfig
{
  case object Singleton // identifier of the single node 
  // configuration of server
  type NodeId = Singleton.type
  def nodeId = Singleton

  /** Type safe service port specification. */
  override def portNumber: PortNumber = 8088

  // configuration of client

  /** We'll use the service provided by the same host. */
  def echoServiceDependency = echoService

  override def testMessage: UrlPathElement = "hello"

  def pollInterval: FiniteDuration = 1.second

  // lifecycle controller configuration
  def lifetime: FiniteDuration = 10500.milliseconds // additional 0.5 seconds so that there are 10 requests, not 9.
}

Obiekt implementuje konfigurację zarówno klienta jak i serwera. Stosowana jest również konfiguracja czasu wygaśnięcia, tak aby po upływie interwału lifetime zakończyć program. (Ctrl-C również działa i prawidłowo zwalnia wszystkie zasoby.)

Ten sam zestaw cech konfiguracyjnych i implementacyjnych można wykorzystać do stworzenia systemu składającego się z: dwa oddzielne węzły:

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
  {
    // NB! dependency specification
    def echoServiceDependency = NodeServerConfig.echoService

    def pollInterval: FiniteDuration = 1.second

    def lifetime: FiniteDuration = 10500.milliseconds // additional 0.5 seconds so that there are 10 request, not 9.

    def testMessage: String = "dolly"
  }

Ważny! Zwróć uwagę na sposób połączenia usług. Usługę realizowaną przez jeden węzeł określamy jako implementację metody zależności innego węzła. Typ zależności jest sprawdzany przez kompilator, ponieważ zawiera typ protokołu. Po uruchomieniu zależność będzie zawierać poprawny identyfikator węzła docelowego. Dzięki temu schematowi podajemy numer portu dokładnie raz i zawsze mamy pewność, że odnosimy się do prawidłowego portu.

Implementacja dwóch węzłów systemu

Do tej konfiguracji używamy tych samych implementacji usług bez zmian. Jedyna różnica polega na tym, że mamy teraz dwa obiekty, które implementują różne zestawy 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 wymaga jedynie konfiguracji serwera. Drugi węzeł implementuje klienta i wykorzystuje inną część konfiguracji. Oba węzły wymagają także zarządzania przez całe życie. Węzeł serwera działa przez czas nieokreślony, dopóki nie zostanie zatrzymany SIGTERM'om, a węzeł kliencki kończy działanie po pewnym czasie. Cm. aplikacja uruchamiająca.

Ogólny proces rozwoju

Zobaczmy, jak to podejście konfiguracyjne wpływa na cały proces programowania.

Konfiguracja zostanie skompilowana wraz z resztą kodu i wygenerowany zostanie artefakt (.jar). Wydaje się, że sensowne jest umieszczenie konfiguracji w osobnym artefakcie. Dzieje się tak dlatego, że możemy mieć wiele konfiguracji opartych na tym samym kodzie. Ponownie możliwe jest generowanie artefaktów odpowiadających różnym gałęziom konfiguracji. Zależności od konkretnych wersji bibliotek zapisywane są wraz z konfiguracją i wersje te zapisywane są na zawsze za każdym razem, gdy zdecydujemy się na wdrożenie danej wersji konfiguracji.

Każda zmiana konfiguracji zamienia się w zmianę kodu. I dlatego każdy
zmiana zostanie objęta normalnym procesem zapewnienia jakości:

Bilet w narzędziu do śledzenia błędów -> PR -> recenzja -> połącz z odpowiednimi oddziałami ->
integracja -> wdrożenie

Główne konsekwencje wdrożenia skompilowanej konfiguracji to:

  1. Konfiguracja będzie spójna we wszystkich węzłach systemu rozproszonego. Ze względu na to, że wszystkie węzły otrzymują tę samą konfigurację z jednego źródła.

  2. Problematyczna jest zmiana konfiguracji tylko w jednym z węzłów. Dlatego „dryft konfiguracyjny” jest mało prawdopodobny.

  3. Drobne zmiany w konfiguracji stają się coraz trudniejsze.

  4. Większość zmian konfiguracyjnych będzie stanowić część ogólnego procesu rozwoju i będzie podlegać przeglądowi.

Czy potrzebuję osobnego repozytorium do przechowywania konfiguracji produkcyjnej? Konfiguracja ta może zawierać hasła i inne wrażliwe informacje, do których chcielibyśmy ograniczyć dostęp. Na tej podstawie wydaje się sensowne przechowywanie ostatecznej konfiguracji w osobnym repozytorium. Możesz podzielić konfigurację na dwie części — jedną zawierającą publicznie dostępne ustawienia konfiguracyjne i drugą zawierającą ustawienia zastrzeżone. Dzięki temu większość programistów będzie miała dostęp do typowych ustawień. To oddzielenie jest łatwe do osiągnięcia przy użyciu cech pośrednich zawierających wartości domyślne.

Możliwe odmiany

Spróbujmy porównać skompilowaną konfigurację z kilkoma popularnymi alternatywami:

  1. Plik tekstowy na maszynie docelowej.
  2. Scentralizowany magazyn klucz-wartość (etcd/zookeeper).
  3. Komponenty procesu, które można ponownie skonfigurować/uruchomić bez ponownego uruchamiania procesu.
  4. Przechowywanie konfiguracji poza kontrolą artefaktów i wersji.

Pliki tekstowe zapewniają znaczną elastyczność w zakresie drobnych zmian. Administrator systemu może zalogować się do zdalnego węzła, dokonać zmian w odpowiednich plikach i zrestartować usługę. Jednak w przypadku dużych systemów taka elastyczność może nie być pożądana. Wprowadzone zmiany nie pozostawiają śladów w innych systemach. Nikt nie ocenia zmian. Trudno ustalić, kto dokładnie dokonał zmian i z jakiego powodu. Zmiany nie są testowane. Jeśli system jest rozproszony, administrator może zapomnieć o dokonaniu odpowiednich zmian w innych węzłach.

(Należy również zaznaczyć, że użycie skompilowanej konfiguracji nie zamyka możliwości wykorzystania w przyszłości plików tekstowych. Wystarczy dodać parser i walidator, który wygeneruje ten sam typ co wyjście Configi możesz używać plików tekstowych. Od razu wynika, że ​​złożoność systemu ze skompilowaną konfiguracją jest nieco mniejsza niż złożoność systemu korzystającego z plików tekstowych, ponieważ pliki tekstowe wymagają dodatkowego kodu.)

Scentralizowany magazyn klucz-wartość jest dobrym mechanizmem dystrybucji metaparametrów rozproszonej aplikacji. Musimy zdecydować, jakie są parametry konfiguracyjne, a co tylko dane. Miejmy funkcję C => A => Bi parametry C rzadko się zmienia, i dane A - często. W tym przypadku możemy tak powiedzieć C - parametry konfiguracyjne oraz A - dane. Wydaje się, że parametry konfiguracyjne różnią się od danych tym, że generalnie zmieniają się rzadziej niż dane. Ponadto dane zazwyczaj pochodzą z jednego źródła (od użytkownika), a parametry konfiguracyjne z innego (od administratora systemu).

Jeśli rzadko zmieniające się parametry wymagają aktualizacji bez ponownego uruchamiania programu, często może to prowadzić do komplikacji programu, ponieważ będziemy musieli w jakiś sposób dostarczyć parametry, przechowywać, analizować, sprawdzać i przetwarzać nieprawidłowe wartości. Dlatego z punktu widzenia zmniejszenia złożoności programu sensowne jest zmniejszenie liczby parametrów, które mogą zmieniać się w trakcie działania programu (lub w ogóle nie obsługiwać takich parametrów).

Na potrzeby tego wpisu rozróżnimy parametry statyczne i dynamiczne. Jeżeli logika usługi wymaga zmiany parametrów w trakcie działania programu, wówczas takie parametry nazwiemy dynamicznymi. W przeciwnym razie opcje są statyczne i można je skonfigurować przy użyciu skompilowanej konfiguracji. Do dynamicznej rekonfiguracji możemy potrzebować mechanizmu ponownego uruchamiania części programu z nowymi parametrami, podobnie jak w przypadku ponownego uruchamiania procesów systemu operacyjnego. (Naszym zdaniem wskazane jest unikanie rekonfiguracji w czasie rzeczywistym, ponieważ zwiększa to złożoność systemu. Jeśli to możliwe, lepiej jest wykorzystać standardowe możliwości systemu operacyjnego do ponownego uruchamiania procesów.)

Jednym z ważnych aspektów korzystania z konfiguracji statycznej, który skłania ludzi do rozważenia dynamicznej rekonfiguracji, jest czas potrzebny na ponowne uruchomienie systemu po aktualizacji konfiguracji (przestój). Tak naprawdę, jeśli będziemy musieli wprowadzić zmiany w konfiguracji statycznej, będziemy musieli zrestartować system, aby nowe wartości zaczęły obowiązywać. Problem z przestojami ma różną wagę w przypadku różnych systemów. W niektórych przypadkach można zaplanować ponowne uruchomienie w czasie, gdy obciążenie jest minimalne. Jeśli potrzebujesz zapewnić ciągłą obsługę, możesz to wdrożyć Opróżnianie połączenia AWS ELB. Jednocześnie, gdy będziemy musieli zrestartować system, uruchamiamy równoległą instancję tego systemu, przełączamy na nią balanser i czekamy na zakończenie starych połączeń. Po zakończeniu wszystkich starych połączeń zamykamy starą instancję systemu.

Rozważmy teraz kwestię przechowywania konfiguracji wewnątrz lub na zewnątrz artefaktu. Jeśli przechowujemy konfigurację wewnątrz artefaktu, to przynajmniej mieliśmy możliwość sprawdzenia poprawności konfiguracji podczas montażu artefaktu. Jeśli konfiguracja wykracza poza kontrolowany artefakt, trudno jest śledzić, kto i dlaczego dokonał zmian w tym pliku. Jak ważne jest to? Naszym zdaniem dla wielu systemów produkcyjnych ważna jest stabilna i wysokiej jakości konfiguracja.

Wersja artefaktu pozwala określić, kiedy został utworzony, jakie zawiera wartości, jakie funkcje są włączone/wyłączone oraz kto jest odpowiedzialny za jakąkolwiek zmianę w konfiguracji. Oczywiście przechowywanie konfiguracji w artefakcie wymaga pewnego wysiłku, dlatego należy podjąć świadomą decyzję.

Plusy i minusy

Chciałbym zastanowić się nad zaletami i wadami proponowanej technologii.

Zalety

Poniżej znajduje się lista głównych cech skompilowanej konfiguracji systemu rozproszonego:

  1. Kontrola konfiguracji statycznej. Pozwala mieć pewność, że
    konfiguracja jest prawidłowa.
  2. Bogaty język konfiguracji. Zwykle inne metody konfiguracji ograniczają się co najwyżej do podstawienia zmiennej łańcuchowej. Podczas korzystania ze Scali dostępnych jest wiele funkcji językowych usprawniających konfigurację. Na przykład możemy użyć
    cechy dla wartości domyślnych, używając obiektów do grupowania parametrów, możemy odwoływać się do wartości zadeklarowanych tylko raz (DRY) w otaczającym zakresie. Możesz utworzyć instancję dowolnej klasy bezpośrednio w konfiguracji (Seq, Map, zajęcia niestandardowe).
  3. DSL. Scala posiada wiele funkcji językowych, które ułatwiają tworzenie DSL. Można skorzystać z tych funkcjonalności i zaimplementować język konfiguracji wygodniejszy dla docelowej grupy użytkowników, tak aby konfiguracja była czytelna przynajmniej dla ekspertów dziedzinowych. Specjaliści mogą np. uczestniczyć w procesie przeglądu konfiguracji.
  4. Integralność i synchronizacja pomiędzy węzłami. Jedną z zalet przechowywania konfiguracji całego systemu rozproszonego w jednym miejscu jest to, że wszystkie wartości są deklarowane dokładnie raz, a następnie ponownie wykorzystywane tam, gdzie są potrzebne. Używanie typów fantomowych do deklarowania portów gwarantuje, że węzły używają kompatybilnych protokołów we wszystkich prawidłowych konfiguracjach systemu. Posiadanie wyraźnych obowiązkowych zależności między węzłami gwarantuje, że wszystkie usługi są połączone.
  5. Zmiany wysokiej jakości. Dokonywanie zmian w konfiguracji przy użyciu wspólnego procesu rozwoju pozwala osiągnąć wysokie standardy jakości również w przypadku konfiguracji.
  6. Jednoczesna aktualizacja konfiguracji. Automatyczne wdrożenie systemu po zmianach konfiguracji zapewnia aktualizację wszystkich węzłów.
  7. Uproszczenie aplikacji. Aplikacja nie wymaga analizowania, sprawdzania konfiguracji ani obsługi nieprawidłowych wartości. Zmniejsza to złożoność aplikacji. (Część złożoności konfiguracji zaobserwowana w naszym przykładzie nie jest cechą skompilowanej konfiguracji, a jedynie świadomą decyzją wynikającą z chęci zapewnienia większego bezpieczeństwa typu.) Powrót do zwykłej konfiguracji jest dość łatwy - wystarczy zaimplementować brakujące Części. Można więc np. zacząć od skompilowanej konfiguracji, odkładając wdrożenie niepotrzebnych części do czasu, kiedy będzie to naprawdę potrzebne.
  8. Zweryfikowana konfiguracja. Ponieważ zmiany konfiguracji podążają za zwykłym losem wszelkich innych zmian, otrzymane przez nas dane wyjściowe to artefakt z unikalną wersją. Dzięki temu w razie potrzeby możemy np. wrócić do poprzedniej wersji konfiguracji. Możemy nawet skorzystać z konfiguracji sprzed roku i system będzie działał dokładnie tak samo. Stabilna konfiguracja poprawia przewidywalność i niezawodność systemu rozproszonego. Ponieważ konfiguracja jest ustalana na etapie kompilacji, dość trudno jest ją sfałszować na produkcji.
  9. Modułowość. Proponowany framework ma charakter modułowy, a moduły można łączyć na różne sposoby, tworząc różne systemy. W szczególności można skonfigurować system tak, aby w jednym wykonaniu działał na jednym węźle, a w innym na wielu węzłach. Można utworzyć kilka konfiguracji dla instancji produkcyjnych systemu.
  10. Testowanie. Zastępując poszczególne usługi obiektami próbnymi, można uzyskać kilka wersji systemu wygodnych do testowania.
  11. Testy integracyjne. Posiadanie jednej konfiguracji dla całego systemu rozproszonego umożliwia uruchomienie wszystkich komponentów w kontrolowanym środowisku w ramach testów integracyjnych. Łatwo jest na przykład emulować sytuację, w której niektóre węzły stają się dostępne.

Wady i ograniczenia

Skompilowana konfiguracja różni się od innych podejść konfiguracyjnych i może nie być odpowiednia dla niektórych zastosowań. Poniżej kilka wad:

  1. Konfiguracja statyczna. Czasami trzeba szybko skorygować konfigurację w produkcji, omijając wszelkie mechanizmy zabezpieczające. Z takim podejściem może to być trudniejsze. Przynajmniej nadal wymagana będzie kompilacja i automatyczne wdrażanie. Jest to zarówno przydatna cecha tego podejścia, jak i wada w niektórych przypadkach.
  2. Generowanie konfiguracji. W przypadku, gdy plik konfiguracyjny jest generowany przez narzędzie automatyczne, może być konieczne podjęcie dodatkowych wysiłków w celu zintegrowania skryptu kompilacji.
  3. Narzędzia. Obecnie narzędzia i techniki przeznaczone do pracy z konfiguracją opierają się na plikach tekstowych. Nie wszystkie tego typu narzędzia/techniki będą dostępne w skompilowanej konfiguracji.
  4. Konieczna jest zmiana postaw. Programiści i DevOps są przyzwyczajeni do plików tekstowych. Sam pomysł skompilowania konfiguracji może być nieco nieoczekiwany i nietypowy i spowodować odrzucenie.
  5. Wymagany jest proces rozwoju wysokiej jakości. Aby wygodnie korzystać ze skompilowanej konfiguracji, konieczna jest pełna automatyzacja procesu budowy i wdrażania aplikacji (CI/CD). W przeciwnym razie będzie to dość niewygodne.

Zastanówmy się także nad szeregiem ograniczeń rozważanego przykładu, które nie są związane z ideą skompilowanej konfiguracji:

  1. Jeśli podamy niepotrzebne informacje konfiguracyjne, które nie są wykorzystywane przez węzeł, to kompilator nie pomoże nam wykryć brakującej implementacji. Problem ten można rozwiązać, porzucając wzór ciasta i stosując bardziej sztywne typy, na przykład HList lub algebraiczne typy danych (klasy przypadków) do reprezentowania konfiguracji.
  2. W pliku konfiguracyjnym znajdują się wiersze niezwiązane z samą konfiguracją: (package, import,deklaracje obiektów; override def's dla parametrów, które mają wartości domyślne). Można tego częściowo uniknąć, wdrażając własne łącze DSL. Ponadto inne typy konfiguracji (na przykład XML) również nakładają pewne ograniczenia na strukturę plików.
  3. Na potrzeby tego wpisu nie rozważamy dynamicznej rekonfiguracji klastra podobnych węzłów.

wniosek

W tym poście zgłębiliśmy pomysł przedstawienia konfiguracji w kodzie źródłowym z wykorzystaniem zaawansowanych możliwości systemu typu Scala. Podejście to można zastosować w różnych aplikacjach jako zamiennik tradycyjnych metod konfiguracji opartych na plikach XML lub tekstowych. Mimo że nasz przykład jest zaimplementowany w Scali, te same pomysły można przenieść na inne języki kompilowane (takie jak Kotlin, C#, Swift, ...). Możesz spróbować tego podejścia w jednym z poniższych projektów, a jeśli to nie zadziała, przejść do pliku tekstowego, dodając brakujące części.

Oczywiście skompilowana konfiguracja wymaga wysokiej jakości procesu rozwoju. W zamian zapewniona jest wysoka jakość i niezawodność konfiguracji.

Rozważane podejście można rozszerzyć:

  1. Do sprawdzania w czasie kompilacji można używać makr.
  2. Możesz zaimplementować DSL, aby przedstawić konfigurację w sposób dostępny dla użytkowników końcowych.
  3. Możesz wdrożyć dynamiczne zarządzanie zasobami z automatycznym dostosowywaniem konfiguracji. Na przykład zmiana liczby węzłów w klastrze wymaga, aby (1) każdy węzeł otrzymał nieco inną konfigurację; (2) menadżer klastra otrzymał informację o nowych węzłach.

Podziękowanie

Chciałbym podziękować Andriejowi Saksonowowi, Pawłowi Popowowi i Antonowi Niechajewowi za konstruktywną krytykę projektu artykułu.

Źródło: www.habr.com

Dodaj komentarz