Kompilowalna konfiguracja zdalnego systemu

Chciałbym opowiedzieć o interesującym mechanizmie pracy z konfiguracją systemu rozproszonego. Konfiguracja jest przedstawiona bezpośrednio w języku kompilowanym (Scala) z użyciem typów bezpiecznych. W tym poście omówiono przykład takiej konfiguracji oraz różne aspekty wdrażania kompilowanej konfiguracji w ogólny proces rozwoju.

Kompilowalna konfiguracja zdalnego systemu

(english)

Wprowadzenie

Budowanie niezawodnego systemu rozproszonego wymaga, aby na wszystkich węzłach używana była poprawna konfiguracja, synchronizowana z innymi węzłami. Zwykle wykorzystuje się technologie DevOps (terraform, ansible lub coś podobnego) do automatycznej generacji plików konfiguracyjnych (często swoich dla każdego węzła). Chcielibyśmy również mieć pewność, że na wszystkich współpracujących węzłach używane są identyczne protokoły (w tym tej samej wersji). W przeciwnym razie nasz system rozproszony będzie miał wbudowaną niekompatybilność. W świecie JVM jednym z skutków tego wymagania jest potrzeba użycia wszędzie tej samej wersji biblioteki, zawierającej komunikaty protokołu.

A co z testowaniem systemu rozproszonego? Oczywiście zakładamy, że dla wszystkich komponentów przewidziano testy jednostkowe, zanim przejdziemy do testów integracyjnych. (Abyśmy mogli ekstrapolować wyniki testów na środowisko uruchomieniowe, musimy również zapewnić identyczny zestaw bibliotek na etapie testowania i w środowisku uruchomieniowym.)

Pracując z testami integracyjnymi, często łatwiej jest używać jednego classpathu na wszystkich węzłach. Będziemy musieli jedynie zapewnić, że ten sam classpath będzie używany także w środowisku uruchomieniowym. (Chociaż możliwe jest uruchamianie różnych węzłów z różnymi classpathami, prowadzi to do skomplikowania całej konfiguracji i trudności z wdrażaniem oraz testami integracyjnymi.) W ramach tego posta zakładamy, że na wszystkich węzłach będzie używany ten sam classpath.

Konfiguracja rozwija się wraz z aplikacją. Do identyfikacji różnych etapów ewolucji oprogramowania używamy wersji. Logiczne wydaje się również identyfikowanie różnych wersji konfiguracji. Samą konfigurację warto umieścić w systemie kontroli wersji. Jeżeli w produkcji istnieje tylko jedna konfiguracja, możemy użyć po prostu numeru wersji. Jeśli jednak używamy wielu instancji produkcji, będziemy potrzebować kilku
gałęzi konfiguracji oraz dodatkowej etykiety obok wersji (na przykład nazwy gałęzi). Dzięki temu będziemy mogli jednoznacznie zidentyfikować dokładną konfigurację. Każdy identyfikator konfiguracji jednoznacznie odpowiada określonej kombinacji rozproszonych węzłów, portów, zewnętrznych zasobów, wersji bibliotek. W ramach tego wpisu zakładamy, że istnieje tylko jedna gałąź i możemy zidentyfikować konfigurację w zwykły sposób, używając trzech liczb oddzielonych kropką (1.2.3).

W nowoczesnych środowiskach pliki konfiguracyjne są rzadko tworzone ręcznie. Częściej są generowane w trakcie wdrażania i nie są już modyfikowane (aby nic nie zepsuć). Pojawia się zasadnicze pytanie, dlaczego nadal używamy formatu tekstowego do przechowywania konfiguracji? Alternatywą o dużym potencjale wydaje się możliwość użycia zwykłego kodu do konfiguracji i osiągnięcia korzyści dzięki sprawdzeniom podczas kompilacji.

W tym wpisie właśnie badamy ideę przedstawienia konfiguracji wewnątrz skompilowanego artefaktu.

Kompilowana konfiguracja

W tej sekcji omówiony jest przykład statycznej kompilowanej konfiguracji. Zrealizowane są dwa proste serwisy — serwis echa oraz klient serwisu echa. Na podstawie tych dwóch serwisów tworzone są dwa warianty systemu. W jednym z wariantów oba serwisy znajdują się na jednym węźle, w drugim wariancie — na różnych węzłach.

Zwykle system rozproszony zawiera kilka węzłów. Można 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 nawet

object Singleton
type NodeId = Singleton.type

Węzły pełnią różne role, uruchomione są na nich serwisy i między nimi mogą być ustanowione połączenia TCP/HTTP.

Aby opisać połączenie TCP, potrzebujemy przynajmniej numeru portu. Chcielibyśmy także odzwierciedlić protokół, który jest obsługiwany na tym porcie, aby zapewnić, że zarówno klient, jak i serwer używają tego samego protokołu. Opiszemy połączenie za pomocą klasy:

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

gdzie Port — po prostu liczba całkowita Int z określeniem zakresu dopuszczalnych wartości:

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

Typy uszczegółowione

Zobacz bibliotekę refined i moje referat. W skrócie, biblioteka pozwala na dodawanie ograniczeń do typów, które są weryfikowane w czasie kompilacji. W tym przypadku dopuszczalnymi wartościami numeru portu są całkowite liczby 16-bitowe. Użycie biblioteki refined nie jest obowiązkowe dla kompilowanej konfiguracji, ale pozwala na poprawę możliwości sprawdzania konfiguracji przez kompilator.

Dla protokołów HTTP (REST) oprócz numeru portu może być również potrzebna ś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 wewnątrz klasy. Takie rozwiązanie jest związane z tym, że w czasie runtime nie używamy instancji protokołu, ale chcielibyśmy, aby kompilator weryfikował zgodność protokołów. Dzięki wskazaniu protokołu nie możemy przekazać nieodpowiedniej usługi jako zależności.

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

sealed trait JsonHttpRestProtocol[RequestMessage, ResponseMessage]

gdzie RequestMessage — typ żądania, ResponseMessage — typ odpowiedzi.
Oczywiście można również używać innych opisów protokołów, które zapewniają wymaganą dokładność opisu.

Na potrzeby tego posta będziemy używać uproszczonej wersji protokołu:

sealed trait SimpleHttpGetRest[RequestMessage, ResponseMessage]

Tutaj żądanie jest ciągiem dodawanym do url, a odpowiedź to zwracany ciąg w ciele odpowiedzi HTTP.

Konfiguracja usługi opisuje nazwę usługi, porty i zależności. Te elementy można przedstawić w Scalę na kilka sposobów (na przykład, HList-ami, typami danych algebraicznych). Na potrzeby tego posta będziemy używać Cake Pattern i przedstawiać moduły za pomocą traitCake Pattern nie jest obowiązkowym elementem opisanego podejścia. To tylko jedna z możliwych realizacji.

Zależności między usługami można przedstawić w postaci metod zwracających 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)
  }

Do stworzenia usługi echo wystarczy tylko numer portu i wskazanie, że ten port obsługuje protokół echo. Moglibyśmy nie podawać konkretnego portu, ponieważ trait’ty pozwalają na deklarowanie metod bez implementacji (metody abstrakcyjne). W takim przypadku przy tworzeniu konkretnej konfiguracji kompilator wymagałby od nas dostarczenia implementacji metody abstrakcyjnej oraz podania numeru portu. Ponieważ zaimplementowaliśmy metodę, to przy tworzeniu konkretnej konfiguracji możemy nie podawać 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ść ma ten sam typ, co eksportowana usługa echoService. W szczególności w kliencie echo wymagamy tego samego protokołu. Dlatego przy łączeniu dwóch usług możemy być pewni, że wszystko będzie działać poprawnie.

Implementacja usług

Do uruchomienia i zatrzymania usługi potrzebna jest funkcja. (Możliwość zatrzymywania usługi jest kluczowa dla testowania.) Jak zwykle istnieje kilka sposobów realizacji takiej funkcji (na przykład moglibyśmy użyć klas typów opartych na typie konfiguracji). W celach niniejszego wpisu skorzystamy z Cake Pattern. Przedstawimy usługę za pomocą klasy cats.Resource, ponieważ ta klasa już zawiera środki do bezpiecznego i gwarantowanego zwalniania zasobów w razie problemów. Aby uzyskać zasób, musimy dostarczyć konfigurację i gotowy kontekst czasowy. Funkcja uruchamiająca usługę może mieć następujący kształt:

  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 dla tej usługi
  • AddressResolver — obiekt czasu wykonania, który pozwala uzyskać adresy innych węzłów (patrz dalej)

i inne typy z biblioteki cats:

  • F[_] — typ efektu (w najprostszej formie F[A] może być po prostu funkcją () => A. W tym poście wykorzystamy cats.IO.)
  • Reader[A,B] — mniej więcej synonim funkcji A => B
  • cats.Resource — zasób, który można uzyskać i zwolnić
  • Timer — timer (pozwala spać przez pewien czas i mierzyć interwały czasowe)
  • ContextShift — odpowiednik ExecutionContext
  • Applicative — klasa typu efektu, która pozwala łączyć poszczególne efekty (prawie monada). W bardziej złożonych aplikacjach, wydaje się, że lepiej jest używać Monad/ConcurrentEffect.

Korzystając z tej sygnatury funkcji, 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](()))
  }

(Patrz kod źródłowy, w którym zaimplementowane są inne usługi — usługa echo, klient echo
i kontrolery cyklu życia.)

Węzeł jest obiektem, który może uruchomić kilka usług (uruchomienie łańcucha zasobów realizowane jest za pomocą wzorca Cake Pattern):

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

Zauważ, że określamy dokładny typ konfiguracji, który jest wymagany dla tego węzła. Jeśli zapomnimy wskazać którykolwiek z typów konfiguracji wymaganych przez poszczególną usługę, wystąpi błąd kompilacji. Również nie będziemy w stanie uruchomić węzła, jeśli nie dostarczymy jakiegoś obiektu o odpowiednim typie z wszystkimi wymaganymi danymi.

Rozwiązywanie nazw węzłów

Aby połączyć się z zdalnym węzłem, potrzebujemy rzeczywistego adresu IP. Możliwe, że adres stanie się znany później niż inne części konfiguracji. Dlatego potrzebujemy funkcji, która mapuje identyfikator węzła na adres:

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

Możemy zaproponować kilka sposobów implementacji takiej funkcji:

  1. Jeśli adresy stają się dla nas znane przed wdrożeniem, możemy wygenerować kod Scala z
    adresami, a następnie uruchomić kompilację. Podczas tego nastąpi kompilacja i przeprowadzenie testów.
    W takim przypadku funkcja będzie znana statycznie i może być przedstawiona w kodzie jako wywołanie Map[NodeId, NodeAddress].
  2. W niektórych przypadkach rzeczywisty adres staje się znany dopiero po uruchomieniu węzła.
    W takim przypadku możemy wdrożyć „usługę odkrywania” (discovery), która uruchamia się przed innymi węzłami, a wszystkie węzły będą rejestrowały się w tej usłudze i żądały adresów innych węzłów.
  3. Jeśli możemy modyfikować /etc/hosts, to można wykorzystać wcześniej ustalone nazwy hostów (takie jak my-project-main-node i echo-backend) i po prostu powiązać te nazwy
    z adresami IP podczas wdrażania.

W ramach tego posta nie będziemy omawiać tych przypadków bardziej szczegółowo. Dla naszego
przykładu zabawowego wszystkie węzły będą miały ten sam adres IP — 127.0.0.1.

Następnie rozważymy dwa warianty rozproszonego systemu:

  1. Umiejscowienie wszystkich usług na jednym węźle.
  2. I umiejscowienie usługi echa i klienta echa na różnych węzłach.

Konfiguracja dla jednego węzła:

Konfiguracja dla 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.
}

Obiekt implementuje konfigurację i klienta oraz serwera. Używana jest również konfiguracja czasu życia, aby po upływie interwału lifetime zakończyć działanie programu. (Ctrl-C działa również i poprawnie zwalnia wszystkie zasoby.)

Ta sama zestaw trait'ów konfiguracji i implementacji może być użyty do stworzenia systemu składającego się z dwóch oddzielnych węzłów:

Konfiguracja dla 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"
  }

Uwaga! Zwróć uwagę, jak odbywa się wiązanie usług. Wskazujemy usługę, która jest realizowana przez jeden węzeł jako implementację metody zależności innego węzła. Typ zależności jest sprawdzany przez kompilator, ponieważ zawiera typ protokołu. Przy uruchomieniu zależność będzie zawierać poprawny identyfikator docelowego węzła. Dzięki takiemu schematowi podajemy numer portu tylko raz i zawsze gwarantujemy odniesienie do właściwego portu.

Implementacja dwóch węzłów systemu

Dla tej konfiguracji używamy tych samych implementacji usług bez zmian. Jedyną różnicą jest to, że teraz mamy 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 potrzebuje tylko konfiguracji serwera. Drugi węzeł implementuje klienta i używa innej części konfiguracji. Oba węzły potrzebują również zarządzania cyklem życia. Węzeł serwera działa przez nieograniczony czas, dopóki nie zostanie zatrzymany. SIGTERMa węzeł kliencki kończy działanie po pewnym czasie. Zob. uruchomienie aplikacji.

Ogólny proces rozwoju

Zobaczmy, jak to podejście do konfiguracji wpływa na ogólny proces rozwoju.

Konfiguracja zostanie skompilowana razem z resztą kodu i zostanie wygenerowany artefakt (.jar). Zasadniczo sensowne jest umieszczenie konfiguracji w osobnym artefakcie. Wynika to z tego, że możemy mieć wiele konfiguracji opartych na tym samym kodzie. Ponownie, można generować artefakty odpowiadające różnym gałęziom konfiguracji. Razem z konfiguracją zachowane są zależności od konkretnych wersji bibliotek, a te wersje są zachowywane na zawsze, kiedy tylko zdecydujemy się wdrożyć tę wersję konfiguracji.

Jakiekolwiek zmiany w konfiguracji przekształcają się w zmiany w kodzie. A zatem, każda taka
zmiana będzie objęta zwykłym procesem zapewnienia jakości:

Ticket w systemie śledzenia błędów -> PR -> przegląd -> scalanie z odpowiednimi gałęziami ->
integracja -> wdrożenie

Główne skutki wprowadzenia kompilowanej konfiguracji:

  1. Konfiguracja będzie uzgadniana na wszystkich węzłach systemu rozproszonego. Ponieważ wszystkie węzły otrzymują tę samą konfigurację z jednego źródła.

  2. Trudno jest zmienić konfigurację tylko na jednym z węzłów. Dlatego 'niesynchronizacja konfiguracji' (configuration drift) jest mało prawdopodobna.

  3. Coraz trudniej wprowadzać drobne zmiany w konfiguracji.

  4. Większość zmian konfiguracji będzie następowała w ramach ogólnego procesu rozwoju i będzie poddawana przeglądowi.

Czy potrzebujemy osobnego repozytorium do przechowywania konfiguracji produkcyjnej? Taka konfiguracja może zawierać hasła i inne poufne informacje, do których dostęp chcielibyśmy ograniczyć. W związku z tym, wydaje się sensowne przechowywanie ostatecznej konfiguracji w osobnym repozytorium. Można podzielić konfigurację na dwie części — jedną, zawierającą publiczne parametry konfiguracji, i drugą, zawierającą parametry o ograniczonym dostępie. Pozwoli to większości programistów na dostęp do ogólnych parametrów. Takie oddzielenie nie jest trudne do osiągnięcia przy użyciu pośrednich traitów z wartościami domyślnymi.

Możliwe wariacje

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

  1. Plik tekstowy na docelowej maszynie.
  2. Centralne magazynowanie klucz-wartość (etcd/zookeeper).
  3. Komponenty procesu, które mogą być rekonfigurowane/uruchamiane na nowo bez restartu procesu.
  4. Przechowywanie konfiguracji poza artefaktem i kontrolą wersji.

Pliki tekstowe zapewniają znaczną elastyczność pod względem małych zmian. Administrator systemu może zalogować się na zdalny węzeł, wprowadzić zmiany w odpowiednich plikach i ponownie uruchomić usługę. Jednak w przypadku dużych systemów taka elastyczność może być niepożądana. Wprowadzane zmiany nie pozostawiają śladów w innych systemach. Nikt nie dokonuje przeglądu zmian. Trudno ustalić, kto dokładnie wprowadzał zmiany i z jakiego powodu. Zmiany nie są testowane. W przypadku systemu rozproszonego administrator może zapomnieć o wprowadzeniu odpowiednich zmian na innych węzłach.

(Należy również zauważyć, że zastosowanie kompilowanej konfiguracji nie zamyka możliwości korzystania z plików tekstowych w przyszłości. Wystarczy dodać parser i walidator, które wyprodukują ten sam typ Konfiguracja, i można korzystać z plików tekstowych. Stąd bezpośrednio wynika, że złożoność systemu z kompilowaną konfiguracją jest nieco mniejsza niż złożoność systemu korzystającego z plików tekstowych, ponieważ pliki tekstowe wymagają dodatkowego kodu.)

Centralizowane magazynowanie par klucz-wartość jest dobrym mechanizmem do rozdzielania meta-parametrów rozproszonej aplikacji. Powinniśmy określić, co to są parametry konfiguracyjne, a co - tylko dane. Niech mamy funkcję C => A => B, przy czym parametry C rzadko się zmieniają, a dane A — często. W tym przypadku możemy powiedzieć, że C — to parametry konfiguracyjne, a A — to dane. Wydaje się, że parametry konfiguracyjne różnią się od danych tym, że zmieniają się z reguły rzadziej niż dane. Również 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 muszą być aktualizowane bez ponownego uruchamiania programu, może to prowadzić do skomplikowania programu, ponieważ będziemy musieli jakoś dostarczać parametry, przechowywać je, analizować i weryfikować, przetwarzać niepoprawne wartości. Dlatego, z punktu widzenia zmniejszenia złożoności programu, ma sens zmniejszenie liczby parametrów, które mogą zmieniać się w trakcie działania programu (lub całkowicie nie obsługiwać takich parametrów).

W kontekście tego postu dokonamy rozróżnienia między parametrami statycznymi a dynamicznymi. Jeśli logika działania usługi wymaga zmiany parametrów podczas działania programu, będziemy nazywać te parametry dynamicznymi. W przeciwnym razie parametry są statyczne i mogą być skonfigurowane przy użyciu kompilowalnej konfiguracji. W przypadku dynamicznej rekonfiguracji może być potrzebny mechanizm ponownego uruchamiania części programu z nowymi parametrami, podobnie jak ma to miejsce w przypadku ponownego uruchamiania procesów w systemie operacyjnym. (Naszym zdaniem, zaleca się unikanie rekonfiguracji w czasie rzeczywistym, ponieważ zwiększa to złożoność systemu. Jeśli to możliwe, lepiej skorzystać z standardowych możliwości systemu operacyjnego do ponownego uruchamiania procesów.)

Jednym z ważnych aspektów używania statycznej konfiguracji, który skłania ludzi do rozważenia dynamicznego rekonfigurowania, jest czas, jaki system potrzebuje na ponowne uruchomienie po aktualizacji konfiguracji (downtime). Rzeczywiście, jeśli musimy wprowadzić zmiany w statycznej konfiguracji, będziemy musieli ponownie uruchomić system, aby nowe wartości mogły wejść w życie. Problem downtimu ma różną ostrość dla różnych systemów. W niektórych przypadkach można zaplanować ponowne uruchomienie na czas o najmniejszym obciążeniu. W przypadku, gdy wymagana jest ciągłość usługi, można wdrożyć «drainage of connections» (AWS ELB connection draining). W takim przypadku, gdy musimy ponownie uruchomić system, uruchamiamy równoległy egzemplarz tego systemu, przełączamy na niego równoważnik obciążenia i czekamy, aż stare połączenia zakończą się. Po zakończeniu wszystkich starych połączeń wyłączamy stary egzemplarz systemu.

Przyjrzyjmy się teraz kwestiom przechowywania konfiguracji wewnątrz artefaktu lub na zewnątrz. Jeśli przechowujemy konfigurację wewnątrz artefaktu, to przynajmniej mieliśmy możliwość zweryfikowania poprawności konfiguracji podczas budowy artefaktu. W przypadku, gdy konfiguracja znajduje się poza kontrolowanym artefaktem, trudno jest śledzić, kto i dlaczego wprowadził zmiany w tym pliku. Jak ważne jest to? Z naszego punktu widzenia dla wielu systemów produkcyjnych istotne jest posiadanie stabilnej i wysokiej jakości konfiguracji.

Wersja artefaktu pozwala określić, kiedy został stworzony, jakie wartości zawiera, jakie funkcje są włączone/wyłączone oraz kto ponosi odpowiedzialność za jakiekolwiek zmiany w konfiguracji. Oczywiście, przechowywanie konfiguracji wewnątrz artefaktu wymaga pewnych wysiłków, dlatego należy podjąć świadomą decyzję.

Za i przeciw

Chciałbym podkreślić zalety i wady proponowanej technologii.

Zalety

Poniżej znajduje się lista głównych możliwości kompilowanej konfiguracji rozproszonego systemu:

  1. Statyczna weryfikacja konfiguracji. Umożliwia upewnienie się, że
    konfiguracja jest poprawna.
  2. Bogaty język konfiguracji. Zwykle inne metody konfigurowania są ograniczone maksymalnie do podstawienia zmiennych tekstowych. Używając Scali, uzyskujemy szeroki wachlarz możliwości językowych, aby poprawić konfigurację. Na przykład, możemy używać
    traitów dla wartości domyślnych, używać obiektów do grupowania parametrów, możemy odnosić się do valów, które zostały zadeklarowane tylko raz (DRY) w zasięgu widoczności. Można bezpośrednio w konfiguracji instancjonować dowolne klasy (Seq, Map, klasy użytkownika).
  3. DSL. W Scali istnieje szereg możliwości językowych, które ułatwiają tworzenie DSL. Można skorzystać z tych możliwości i wdrożyć język konfiguracji, który byłby bardziej przyjazny dla docelowej grupy użytkowników, tak, aby konfiguracja była co najmniej czytelna dla specjalistów dziedzinowych. Specjaliści mogą na przykład uczestniczyć w procesie przeglądu konfiguracji.
  4. Integralność i synchronizacja między węzłami. Jedną z zalet przechowywania konfiguracji całego rozproszonego systemu w jednym miejscu jest to, że wszystkie wartości są deklarowane dokładnie raz, a następnie są wielokrotnie wykorzystywane wszędzie tam, gdzie są potrzebne. Użycie typów fantomowych do deklaracji portów pozwala zapewnić, że we wszystkich poprawnych konfiguracjach systemu węzły korzystają z kompatybilnych protokołów. Istnienie jawnych obowiązkowych zależności między węzłami zapewnia, że wszystkie usługi będą ze sobą połączone.
  5. Wysoka jakość wprowadzania zmian. Wprowadzanie zmian w konfiguracji, korzystając z ogólnego procesu rozwoju, umożliwia osiągnięcie wysokich standardów jakości również dla konfiguracji.
  6. Jednoczesna aktualizacja konfiguracji. Automatyczne wdrażanie systemu po wprowadzeniu zmian w konfiguracji gwarantuje, że wszystkie węzły zostaną zaktualizowane.
  7. Uproszczenie aplikacji. Aplikacja nie wymaga parsowania, sprawdzania konfiguracji i obsługi nieprawidłowych wartości. Zmniejsza to złożoność aplikacji. (Niektóre skomplikowanie konfiguracji, które występuje w naszym przykładzie, nie jest cechą kompilowanej konfiguracji, a jedynie świadomą decyzją, spowodowaną chęcią zapewnienia większej typowej bezpieczeństwa.) Łatwo jest wrócić do zwykłej konfiguracji — wystarczy zrealizować brakujące części. Dlatego można na przykład rozpocząć od kompilowanej konfiguracji, odkładając realizację zbędnych części na czas, gdy naprawdę będą potrzebne.
  8. Wersjonowana konfiguracja. Ponieważ zmiany w konfiguracji podlegają takim samym zasadom jak wszelkie inne zmiany, otrzymujemy artefakt o unikalnej wersji. Umożliwia nam to na przykład powrót do poprzedniej wersji konfiguracji w razie potrzeby. Możemy nawet skorzystać z konfiguracji sprzed roku i przy tym system będzie działał dokładnie tak samo. Stabilna konfiguracja poprawia przewidywalność i niezawodność rozproszonego systemu. Ponieważ konfiguracja jest zablokowana na etapie kompilacji, jest dość trudno ją sfałszować w produkcji.
  9. Modułowość. Proponowany framework jest modułowy, a moduły mogą być łączone w różnych wariantach w celu uzyskania różnych systemów. Szczególnie można skonfigurować system do uruchomienia na jednym węźle w jednym wariancie, a w innym — na wielu węzłach. Można stworzyć kilka konfiguracji dla produkcyjnych egzemplarzy systemu.
  10. Testowanie. Zastępując poszczególne usługi obiektami mokującymi, można uzyskać kilka wersji systemu, wygodnych do testowania.
  11. Testy integracyjne. Posiadanie jednolitej konfiguracji całego rozproszonego systemu zapewnia możliwość uruchomienia wszystkich komponentów w kontrolowanym środowisku w ramach testowania integracyjnego. Łatwo jest emulować, na przykład, sytuację, gdy niektóre węzły stają się niedostępne.

Wady i ograniczenia

Konfiguracja kompilowana różni się od innych podejść do konfiguracji i może nie być odpowiednia dla niektórych aplikacji. Poniżej przedstawiono niektóre wady:

  1. Konfiguracja statyczna. Czasami konieczne jest szybkie poprawienie konfiguracji w produkcji, omijając wszystkie mechanizmy ochronne. W ramach tego podejścia może to być trudniejsze. Przynajmniej kompilacja i automatyczne wdrożenie będą wciąż wymagane. To jednocześnie przydatna cecha podejścia i wada w niektórych przypadkach.
  2. Generowanie konfiguracji. W przypadku, gdy plik konfiguracyjny jest generowany przez automatyczne narzędzie, mogą być wymagane dodatkowe wysiłki w celu integracji skryptu budującego.
  3. Narzędzia. Obecnie narzędzia i metodyki przeznaczone do pracy z konfiguracją opierają się na plikach tekstowych. Nie wszystkie takie narzędzia/metodyki będą dostępne w przypadku konfiguracji kompilowanej.
  4. Wymaga zmiany podejścia. Programiści i DevOps przyzwyczaili się do plików tekstowych. Sama idea kompilacji konfiguracji może być nieco zaskakująca i nietypowa oraz wywoływać opór.
  5. Wymaga wysokiej jakości procesu rozwoju. Aby komfortowo korzystać z kompilowanej konfiguracji, konieczna jest pełna automatyzacja procesu budowy i wdrażania aplikacji (CI/CD). W przeciwnym razie będzie to wystarczająco niewygodne.

Przyjrzyjmy się również pewnym ograniczeniom rozważanego przykładu, które są niezwiązane z ideą kompilowanej konfiguracji:

  1. Jeśli dostarczamy zbędne informacje konfiguracyjne, które nie są używane przez węzeł, kompilator nie pomoże nam zidentyfikować braku implementacji. Problem ten można rozwiązać, rezygnując z wzorca Cake i stosując bardziej rygorystyczne typy, na przykład, HList lub algebraiczne typy danych (case classy) do reprezentacji konfiguracji.
  2. W pliku konfiguracyjnym znajdują się ciągi niezwiązane bezpośrednio z konfiguracją: (pakiet, importować, deklaracje obiektów; nadpisz‘y dla parametrów z wartościami domyślnymi). Częściowo można tego uniknąć, implementując własny DSL. Ponadto inne rodzaje konfiguracji (np. XML) również nakładają określone ograniczenia na strukturę pliku.
  3. W ramach tego posta nie rozważamy dynamicznej rekonfiguracji klastra podobnych węzłów.

Podsumowanie

W tym poście omówiliśmy pomysł przedstawienia konfiguracji w kodzie źródłowym z wykorzystaniem zaawansowanych możliwości systemu typów Scala. Takie podejście może znaleźć zastosowanie w różnych aplikacjach jako alternatywa dla tradycyjnych metod konfiguracji opartych na plikach xml lub tekstowych. Chociaż nasz przykład został zaimplementowany w Scali, te same pomysły można przenieść na inne języki kompilowane (takie jak Kotlin, C#, Swift, …). To podejście można wypróbować w jednym z następnych projektów, a jeśli się nie sprawdzi, przejść do plików tekstowych, dodając brakujące szczegóły.

Oczywiście, kompilowana konfiguracja wymaga wysokiej jakości procesu rozwoju. W zamian zapewnia wysoką jakość i niezawodność konfiguracji.

Omówione podejście może być rozszerzone:

  1. Można użyć makra do wykonywania sprawdzeń w czasie kompilacji.
  2. Można zaimplementować DSL w celu przedstawienia konfiguracji w przystępnej formie dla użytkowników końcowych.
  3. Można zrealizować dynamiczne zarządzanie zasobami z automatyczną adaptacją konfiguracji. Na przykład, przy zmianie liczby węzłów w klastrze potrzebne jest, aby (1) każdy węzeł otrzymał nieco różniąca się konfigurację; (2) menedżer klastra otrzymywał informacje o nowych węzłach.

Podziękowania

Chciałbym podziękować Andriejowi Saksonowowi, Pawłowi Popowowi i Antonowi Nechajewowi za konstruktywną krytykę w wersji roboczej artykułu.

Ź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