HashiCorp Nomad – Prosty orkiestrator aplikacji
To już ostatni wpis w serii poświęconej narzędziom od HashiCorp, który przygotowałem w tym roku. W tym artykule podzielę się wszystkimi niezbędnymi informacjami dotyczącymi Nomada. Na wstępie wyjaśnię architekturę Nomada, a w dalszej części omówię jak zdeployować klaster. Przeanalizujemy dostępne rodzaje zadań, jakie Nomad może uruchamiać, skorzystamy także z narzędzia nomad-pack, które dodatkowo usprawni proces wdrażania aplikacji. Na zakończenie połączymy wszystko z poprzednimi wpisami, aby automatycznie wdrożyć naszą usługę WorkoutRecorder. Zintegrujemy się również z Vaultem i Consulem aby dynamicznie pobierać wszystkie wymagane dane. Serdecznie zapraszam do lektury!
Pozostałe wpisy w serii dotyczące narzędzi HashiCorp:
- HashiCorp Packer – automatyzacja budowy obrazów maszyn
- HashiCorp Terraform – infrastruktura jako kod
- HashiCorp Vault – centrum zarządzania sekretami
- HashiCorp Consul – networking i zarządzanie configuracją
- HashiCorp Nomad – prosty orkiestrator aplikacji ( Jesteś tutaj! )
Spis treści
HashiCorp Nomad
Nomad to narzędzie służące do orkiestracji zadań zwanych jobami. Zadania mogą przyjmować formę kontenerów, zwykłych komend, aplikacji Java lub innych. Nomad obsługuje różne platformy takie jak Linux, Windows czy macOS i może działać zarówno w chmurze, hybrydowo jak i on-premises. Stopień wejścia jest zdecydowanie niższy niż w przypadku Kubernetesa. Ze względu na to, że Nomad jest produktem firmy HashiCorp doskonale integruje się z innymi produktami tej firmy takimi jak Vault czy Consul. Nomad posiada mechanizm auto-uzdrawiania, utrzymując zadaną liczbę instancji aplikacji.
Pracę z Nomadem można podzielić na dwie zasadnicze części: dla operatorów oraz dla developerów. Operatorzy zajmują się instalacją, utrzymaniem i zarządzaniem konfiguracją Nomada natomiast deweloperzy skupiają się głównie na wdrażaniu aplikacji na klastrze Nomada i pisaniu zadań przy użyciu pliku jobspec. Nomad doskonale się skaluje – z dokumentacji wynika, że pojedynczy klaster może obsługiwać tysiące węzłów klienckich. Z mojego doświadczenia mogę dodać, że nie miałem problemów z utrzymaniem prawie tysiąca klientów na pojedynczym klastrze. Za chwilę wyjaśnię niektóre z ważniejszych słów kluczowych, z którymi musimy się zapoznać przy pracy z tym narzędziem.
Podzieliłem wpis na trzy zasadnicze części. Pierwsza z nich poświęcona będzie pracy operatorów Nomada czyli instalacji i konfiguracji serwerów i klientów. Następnie przedstawię najważniejsze elementy dla developerów, czyli wszystko co związane z tworzeniem i wdrażaniem jobów. W trzeciej części automatycznie stworzymy klaster Nomada i zdeployujemy na niego aplikację WorkoutRecorder składającą się z kilku współpracujących aplikacji.
Zachęcam do obejrzenia filmu, w którym Armon Dadgar, CTO HashiCorp bardzo rzeczowo przedstawia koncepcję Nomada oraz problemy, jakie chcieli rozwiązać za jego pomocą.
Zanim przejdziemy do szczegółów przedstawiam kilka podstawowych informacji. W niniejszym wpisie omawiać będę Nomada w wersji 1.6.0. Nie zostaną z kolei poruszone płatne opcje enterprise ani też rozwiązania multicluster.
Słowniczek pojęć
Poniżej wyjaśniam kluczowe nazwy, które warto znać pracując z Nomadem:
- Datacenter – zazwyczaj jest to zbiór członków klastra Nomada, którzy mają bardzo małe opóźnienia w przesyłaniu danych
- Region – podobnie jak w przypadku regionów w chmurach, zazwyczaj jest to jeden lub więcej datacenter zlokalizowanych geograficznie blisko siebie
- Node – jest to pojedyncza fizyczna lub wirtualna maszyna w klastrze nomada. Każdy node musi mieć uruchomionego agenta nomada
- Agent – jest to binarka nomada, która jest uruchomiona na każdym z członków klastra. Agnet może być uruchomiony w trybie serwer lub klient
- Serwer mode – odpowiedzialny jest za przechowywanie globalnego stanu klastra, zarządzanie członkami klastra, przydzielanie zadań i ich dalszą obsługą
- Klient mode – odpowiedzialny jest za uruchamianie zadań, ich monitoring oraz informowanie o dostępnych zasobach
- Jobspec – jest to plik w języku hcl, który zawiera wszystkie informacje niezbędne do uruchomienia danego zadania
- Task-group – jest to zbiór zadań, które muszą zostać uruchomione na tym samym kliencie nomada
- Task– najmniejsza jednostka pracy zarządzana przez nomada, m.in. definiujemy w niej konfigurację danego drivera
- Driver – określa sterownik użyty do uruchomienia Taska. Może być to np. Docker, java, raw-exec
- Allocation – alokacje są to mapowanie task-group do danego klienta
- Evaluation – określa jakie kroki musi podjąć Nomad by doprowadzić stan klastra do zadeklarowanego w definicji jobspec
To większość terminów, które warto znać pracując z Nomadem. Pozostałe można odnaleźć w dokumentacji.
Architektura
Architektura Nomada jest bardzo podobna do Consula. Klaster Nomada składa się z dwóch rodzajów członków: serwerów i klientów. Wiemy już, że klienci są odpowiedzialni za uruchamianie zadań i komunikację z serwerami. Z kolei serwer jest odpowiedzialny za zarządzanie całym klastrem, przydzielanie zadań i utrzymywanie zadanego stanu. W grupie serwerów występuje jeden lider, który jest wybierany spośród serwerów. Do niego przekierowywane są wszystkie żądania członków. To on replikuje dane do pozostałych serwerów, które są jego followersami.
Lider jest wybierany za pomocą elekcji (wykorzystując Consensus Protocol), co oznacza, że w momencie tworzenia klastra serwery komunikują się ze sobą po raz pierwszy i oddają głos na któryś z serwerów. Ten, który uzyska największą ilość głosów zostaje wybrany liderem. Podobnie jak w przypadku Vaulta i Consula, zaleca się, aby liczba serwerów Nomada wynosiła minimum 3, a dla środowisk produkcyjnych 5. W przypadku, gdy z jakiegoś powodu serwer, który jest liderem przestanie działać odbywa się ponowny proces elekcji.
Serwery w danym regionie komunikują się między sobą przy użyciu protokołów RAFT oraz SERF. Klienci do komunikacji z serwerami używają protokołu RPC. Informują między innymi o swojej obecności, stanie alokacji dla zadań itp. W przypadku łączenia kilku klastrów Nomada serwery komunikują się ze sobą za pomocą protokołu GOSSIP (SERF).
Oto uproszczony schemat architektury Nomada dla pojedynczego datacenter.
Instalacja i pierwsze uruchomienie
Nomada możemy zainstalować za pomocą menadżera instalacyjnego, co jest najłatwiejsze lub pobierając binarkę bezpośrednio ze strony HashiCorp. Instrukcję instalacji dla większości systemów znajdziemy w tym miejscu. Binarka zarówno dla serwerów oraz klientów jest taka sama, służy też jako CLI.
Nomada możemy uruchomić w trybie serwera i klienta, ale istnieje także inny tryb testowy, w którym Nomad spełnia obie powyższe funkcje, a mianowicie tryb dev. Tryb dev służy do testów i nie powinniśmy wykorzystywać tego trybu do standardowej pracy. Aby uruchomić agenta w trybie testowym wykonujemy komendę nomad agent -dev
. Jeśli chcemy uruchomić go w pozostałych trybach musimy dostarczyć plik konfiguracyjny nomad agent -config=<path_to_config_file>
Pliki konfiguracyjne są napisane w jezyku hcl. Poniżej przykład konfiguracji dla serwera.
datacenter = "dev-eu-central-1"
name = "nomad-server-1"
region = "eu-central-1"
data_dir = "/user/nomad/data"
# Addresses
bind_addr = "0.0.0.0"
advertise {
http = "10.0.102.173"
rpc = "10.0.102.173"
serf = "10.0.102.173"
}
ports {
http = 4646
rpc = 4647
serf = 4648
}
# Logging Configurations
log_level = "INFO"
# Server & Raft configuration
server {
enabled = true
bootstrap_expect = 3
encrypt = "ABCDEFGHIJKLMNOPRSTUWYZ"
server_join {
retry_join = ["provider=aws tag_key=nomad_cluster_id tag_value=eu-central-1"]
}
}
#Enable ACL system
acl {
enabled = true
}
# UI configuration
ui {
enabled = true
}
datacenter
określa, do której grupy przyporządkowany jest agent, name
to unikalna nazwa agenta z kolei region
podobnie jak datacenter
to sposób przyporządkowania agenta. W data_dir
określa się, gdzie na lokalnej maszynie swoje pliki stanu ma przechowywać agent. bind_addr
ustawia adres, który agent będzie wykorzystywać do usług sieciowych. W klamrze (stanza) advertise
określamy adresy IP, które są publikowane przez członka klastra jeśli chcemy się z nim komunikować za pomocą danego protokołu. W stanza ports
definiujemy porty nasłuchu dla danej komunikacji.
W klamrze server
za pomocą parametru enable = true
określamy, że agent będzie serwerem. Następnie wykorzystując atrybut bootstrap_expect
podajemy ilość serwerów, przy której rozpocznie się proces głosowania na lidera oraz encrypt
czyli klucz do szyfrowania protokołu gossip. W klamrze server_join
konfigurujemy sposób, w jaki agent znajdzie pozostałe serwery. Stanza ACL
jest podstawą do zapewnienia bezpieczeństwa klastra (za chwilę opowiem o tym więcej). UI
określa, czy agent ma zapewniać wygodny interfejs użytkownika w formie przeglądarki.
Poniżej przykład pliku konfiguracyjnego dla klienta.
datacenter = "dev-eu-central-1"
name = "nomad-client-1"
region = "eu-central-1"
data_dir = "user/nomad/data"
# Addresses
bind_addr = "0.0.0.0"
advertise {
http = "10.0.102.174"
rpc = "10.0.102.174"
serf = "10.0.102.174"
}
ports {
http = 4646
rpc = 4647
serf = 4648
}
# Logging Configurations
log_level = "INFO"
# Client Configuration
client {
enabled = true
server_join {
retry_join = ["provider=aws tag_key=nomad_cluster_id tag_value=eu-central-1"]
}
}
#Enable ACL system
acl {
enabled = true
}
Jedyne, co należy dopowiedzieć w tym wypadku, to stanza client
, która musi być ustawiona na true
, co sprawia, że nasz agent działa w trybie klienta. server_join
działa ideantycznie jak w poprzednim przykładzie. Istnieje kilka sposobów na odnalezienie pozostałych członków klastra. Powyższy przykład wykorzystuje tagi chmury AWS w celu zwrócenia listy adresów IP zawierających określone nazwy.
W tym miejscu znajdziemy wszystkie dostępne atrybuty jakie może przyjąć plik konfiguracyjny.
Boootstrap ACL
W Nomadzie system Access Control List (ACL) służy do wprowadzenia warstwy autentykacji i autoryzacji. Domyślnie, bez zbootstrapowanego systemu ACL każdy użytkownik, który ma dostęp do endpointów Nomada będzie mógł wykonać wszystkie operacje. ACL w Nomadzie działa podobnie jak w Consulu. Tokeny służą użytkownikowi lub programowi do interakcji z Nomadem. Możemy do nich przypisywać polityki, które określają, jakie akcje za ich pomocą możemy wykonać.
W celu uruchomienia ACL serwery Nomada muszą mieć ustawioną wartość stanzy acl
na enable = true
. Warto pamiętać, że jeśli nasze nody klienta nie będą miały włączonej tej opcji wówczas działanie danego węzła będzie nieprawidłowe.
Drugim krokiem do aktywacji systemu ACL zaraz po utworzeniu klastra i wybraniu lidera jest polecenie nomad acl bootstrap
. W wyniku tego polecenia zwrócony zostanie token global managment, który jest master tokenem i posiada wszystkie uprawnienia. Poniżej znajduje się przykładowy wynik tego polecenia.
Accessor ID = 5b7fd453-d3f7-6814-81dc-fcfe6daedea5
Secret ID = 9184ec35-65d4-9258-61e3-0c066d0a45c5
Name = Bootstrap Token
Type = management
Global = true
Policies = n/a
Create Time = 2023-12-11 17:38:10.999089612 +0000 UTC
Create Index = 7
Modify Index = 7
Z jego pomocą (tj. wartość Secret ID) możemy tworzyć kolejne tokeny. Od tej pory wszystkie interakcje z Nomadem, które nie zawierają tokena otrzymują tzw. Anonymous Token, który domyślnie nie posiada żadnych uprawnień.
Polityki są napisane w formie HCL (HashiCorp Configuration Language). Polityki odnoszą się do kilku głównych zasobów w Nomadzie, takich jak: node, agent, namespace, operator i kilka innych. Atrybut policy może przyjmować 4 wartości:
- read – pozwala tylko na odczyt
- write – pozwala na odczyt i modyfikacje
- deny – blokuje możliwość interakcji z danym zasobem, przydaje się gdy mamy wiele polityk i chcemy mieć pewność, że coś na pewno będzie niedostępne. Deny zawsze nadpisuje pozostałe reguły jeśli stoją z sobą w sprzeczności
- list – pozwala na wylistowanie zasobów bez możliwości podejrzenia szczegółów
Przykładowy fragment polityki:
namespace "default" {
policy = "read"
capabilities = ["read-logs"]
}
agent {
policy = "read"
}
Token, do którego zostanie przypisana powyższa polityka będzie w stanie sprawdzić informacje o agentach oraz kilka dodatkowych zasobów.
Wykorzystując CLI dodajemy tę politykę do nomada nomad acl policy apply -description "example policy" example example.policy.hcl
. By stworzyć token z tą polityką wykonujemy nomad acl token create -policy="example"
W powyższym fragmencie polityki w regule namespace "default"
dostrzegamy nie tylko atrybut policy, ale również atrybut capabilities
. Ten drugi umożliwia precyzyjne określenie uprawnień, znanych jako fine-grained, w przeciwieństwie do policy, która przyjmuje jedynie 4 opcje, określane jako coarse-grained. Aby zilustrować różnicę między coarse-grained, a fine-grained, skorzystam z fragmentu dokumentacji HashiCorp.
W przedstawionej powyżej tabeli zauważamy, że po lewej stronie mamy coarse-grained, który obejmuje wszystkie uprawnienia fine-grained po stronie prawej. Innymi słowy, obie poniższe reguły są sobie równe.
namespace "default" {
policy = "read"
}
namespace "default" {
capabilities = [
"list-jobs",
"parse-jobs",
"read-job",
"csi-list-volume",
"csi-read-volume",
"list-scaling-policies",
"read-scaling-policy",
"read-job-scaling"
]
}
To jedynie fragment dość obszernej tabeli. Jeśli zatem dostosowujecie własne uprawnienia wówczas zalecam zapoznanie się z pełną tabelą na stronie HashiCorp.
Dodatkowe informacje na temat Nomad ACL wraz z przykładami można znaleźć w oficjalnej dokumentacji.
Namespace
Namespace w nomadzie służy do segregacji jobów, allokacji, deploymentów, evaluacji i innych związanych z nimi zasobów. Jest to dość podobne do mechanizmu etykietowania (labels/tags), gdzie możemy przypisywać zadaniom odpowiednie namespace przyporządkowując je różnym zespołom w firmie. Dzięki temu zespoły te mogą korzystać z tego samego klastra Nomada nie wchodząc sobie wzajemnie w droge. Przypisanie odpowiednich tokenów z uprawnieniami do konkretnych namespace ułatwia zarządzanie i minimalizuje ryzyko popełnienia błędów.
Zanim wykorzystamy namespace w zadaniach musimy zdefiniować go przy użyciu nomad apply <namespace>
Nomad CLI
Nomad udostępnia obszerny interfejs wiersza poleceń (CLI). Do tej pory zapoznaliśmy się głównie z komendami operacyjnymi. W niniejszym rozdziale opiszę pozostałe najczęściej używane polecenia w Nomadzie. Przed rozpoczęciem pracy z Nomadem za pomocą CLI warto pamiętać o ustawieniu dwóch istotnych zmiennych środowiskowych: NOMAD_ADDR
oraz NOMAD_TOKEN
.
nomad status
– zwraca nazwy zadań występujące w klastrze i ich stan
nomad server members
– podstawowe informacje o serwerach
nomad operator raft list-peers
– informacje o serwerach i uprawnionych do głosowania
nomad node status -short <node_ID>
– informacje szczegółowe o danym węźle, obecne alokacje, obsługiwane sterowniki itp.
nomad operator metrics
– zwraca json ze szczegółowymi informacjami na temat klastra
nomad server force-leave
– wyjątkowo ważne polecenie z punktu widzenia prawidłowej pracy całego klastra. Polecenie ważne ze względu na to, że w momencie, w którym musimy wyłączyć serwer nomada w celu dokonania na nim jakiś zmian najpierw powinniśmy poinformować resztę klastra, że ten serwer zostaje zdjęty. Następnie należy przeprowadzić swoje prace i ponownie włączyć go do grupy
nomad job
– oferuje obszerną listę podkomend. Wydaje mi się, że są one na tyle klarowne, iż nie wymagają szerszego opisu.
Nomad Job
Przejdźmy teraz do części Nomada, która jest dedykowana dla deweloperów. W Nomadzie do wdrożenia aplikacji wykorzystujemy jeden szablon o nazwie job specification lub w skrócie jobspec, który piszemy w języku HCL. Jest to uniwersalny sposób definiowania joba, niezależny od rodzaju aplikacji czy zadania, jakie ma obsłużyć Nomad.
Jobspec jest bardzo obszernym plikiem konfiguracyjnym dlatego nie będę w stanie omówić dokładnie wszystkich jego elementów, ale postaram się opisać te najważniejsze. W innych przypadkach skieruję do dokumentacji. Ogólny schemat jobspec wygląda nastepująco:
job
\_ group
| \_ task
| \_ task
|
\_ group
\_ task
\_ task
Jobspec ma ściśle określoną strukturę. job
to główna stanza, w której umieszczamy informacje dotyczące rozproszenia, ograniczeń co do umiejscowienia zadania, metadane, przynależności do danego datacenter, regionu i wiele innych. Niektóre z atrybutów są opcjonalne, niektóre posiadają wartości domyślne, a część z nich możemy definiować w różnych miejscach jobspec.
Poniżej zamieszczam przykładowy jobspec. Poszczególne sekcje będę omawiał stopniowo w następnych podrozdziałach.
job "java-backend" {
region = "eu-central-1"
datacenters = ["dev-eu-central-1"]
type = "service"
constraint {
attribute = "${attr.kernel.name}"
value = "linux"
operator = "="
}
group "java-backend" {
count = 2
network {
port "http" {
to = 8080
}
}
task "java-backend" {
driver = "docker"
config {
image = "ghcr.io/red-devops/workoutrecorder-backend:1.0.0"
args = ["java", "-jar", "workoutrecorder-backend-1.0.0.jar"]
ports = ["http"]
}
resources {
cpu = "100"
memory = "256"
}
}
}
}
W sekcji group
możemy umieścić informacje dotyczące liczby zadań, jakie mają zostać uruchomione za pomocą tego jobspec, konfiguracji sieci, strategii wdrażania aplikacji, definicji zadań, integracji z Vault i Consul, reguł restartu zadań, oraz wielu innych ustawień. Szczegółowe informacje na temat tego bloku można znaleźć w tym linku.
Blok task
zawiera informacje dotyczące konfiguracji sterownika (drivera) czyli silnika używanego do uruchomienia aplikacji. Są w nim również zawartne definicje zmiennych środowiskowych dla aplikacji, pliki do pobrania za pomocą bloku artifact, szablony, przypisane zasoby takie jak CPU i pamięć, konfiguracje serwisów w Consulu (jeśli są używane), ustawienia logowania i wiele innych dostępnych opcji.
By wprowadzić zadanie do nomada wykonujemy zapisane poniżej polecenie i podajemy plik jobspec nomad run nomad-job.hcl
Rodzaje Jobów
W Nomadzie występuje kilka typów zadań. Job typu service przeznaczony jest dla zadań, które mają działać przez długi czas. Z kolei job typu batch służy do zadań, które istnieją krótko, wykonują konkretne operacje, po czym zostają zakończone. Joby typu batch dzielimy dodatkowo na parametrized i periodic (inna jego nazwa to Nomad cron job). Joby periodic służą do wykonywania określonego zadania co pewien czas, natomiast parametrized pozwalają na skonfigurowanie opcjonalnych danych wejściowych.
Istnieją również joby system
i sysbatch
, których szczegółowe omawianie wydaje mi się rzeczą zbędną. Warto pamiętać, że w przypadku tych jobów scheduler będzie przypisywał tylko jedno wystąpienie zadania do jednego klienta. Więcej szczegółów dotyczących tych typów zadań można znaleźć tutaj.
Parameterized job
Przykład service job pojawi się we wpisie jeszcze kilkukrotnie, dlatego zacznijmy od drugiego z kolei typu joba. Poniżej znajduje się konfiguracja zadania typu parametrized
, w którym mamy dostępne dwa parametry wejściowe dispatcher_emaile
i pager_emaile
, z czego pierwszy parametr jest wymagany.
job "parameterized-job" {
datacenters = ["*"]
type = "batch"
constraint {
attribute = "${attr.kernel.name}"
operator = "set_contains_any"
value = "darwin,linux"
}
parameterized {
meta_required = ["dispatcher_email"]
meta_optional = ["pager_email"]
}
group "group" {
task "task" {
driver = "docker"
config {
image = "busybox:1"
command = "/bin/sh"
args = ["-c", "cat local/template.out", "local/payload.txt"]
}
dispatch_payload {
file = "payload.txt"
}
template {
data = <<EOH
dispatcher_email: {{env "NOMAD_META_dispatcher_email"}}
pager_email: {{env "NOMAD_META_pager_email"}}
EOH
destination = "local/template.out"
}
}
}
}
Wdrażamy nasz job do klastra za pomocą nomad job parametrized_job.hcl
Po wykonaniu polecenia nomad status
widzimy, że nasze zadanie znajduje się w trybie running. Jednakże nie oznacza to jeszcze, że zadanie zostało wykonane. Dla typów batch znaczy to jedynie tyle, że zadanie jest zarejestrowane. Aby je uruchomić używamy polecenia: nomad job dispatch -meta dispatcher_email="test@gmail.com" -meta pager_email="test2@gmail.com" parametrized-job
. Zwróćmy uwagę na allocation id.
Po sprawdzeniu w UI Nomada logów dla tej alokacji zobaczymy, że parametry wejściowe zostały przekazane prawidłowo.
Warto zauważyć, że sekcja parameterized
posiada również atrybut o nazwie 'payload’, który umożliwia przekazanie danych o wielkości do 16 KiB. Więcej szczegółów można znaleźć w dokumentacji.
Shedulled job
W przypadku joba scheduled proces wygląda podobnie, ale nie podajemy argumentów wejściowych. Zamiast tego do jobspec dodajemy sekcję periodic
. Ze względu na obszerność wpisu ograniczę się tylko do teorii.
job "periodic-job" {
periodic {
crons = [
"*/5 * * *",
"*/7 * * *"
]
time_zone = "America/New_York"
}
...
}
Jak zwykle bardziej szczegółowe informacje znajdziemy w dokumentacji.
Zmienne i interpolacja
Nomad domyślnie udostępnia wiele zmiennych, takich jak datacenter
, region
, cpu.arch
, kernel.version
itp. Są to tzw. node attributes. Aby je wykorzystać w jobspec używamy składni ${variable_name}
, natomiast dla bloku template
będzie to {{env "variable_name"}}
. Oprócz node attributes istnieją także zmienne typu runtime environment variables, które są definiowane tylko raz, a mianowicie w momencie przypisania taska do danego węzła. Z tego względu nie można ich używać wcześniej np. w bloku constraints
.
Dostępnych jest wiele zmiennych. Dzielą się one na związane z node , job, network itp. Dokładne Informacje na ich temat znajdziemy tutaj.
Drivers
driver
jest jednym z najważniejszych elementów jobspec w Nomadzie. Służą one do wykonywania zadań oraz zapewniania izolacji zasobów. Nomad umożliwia tworzenie własnych driverów, ale dostępna jest również dość duża liczba wbudowanych. Najczęściej używane to Docker, Exec, Java, QEMU, Podman. Pełną listę można znaleźć tutaj.
Warto zaznaczyć, że aby wykorzystać dany sterownik nie wystarczy go jedynie umieścić w konfiguracji jobspec. Agent Nomada działający na danym węźle musi mieć również możliwość korzystania z danego narzędzia. Dostępne sterowniki można przeglądać za pomocą UI lub CLI.
Drivery wykorzystujemy na poziomie tasków.
task "task" {
driver = "docker"
config {
image = "busybox:1"
command = "/bin/sh"
args = ["-c", "cat local/template.out", "local/payload.txt"]
}
...
}
Przydzielanie zadań, Constraint i Affinity
W momencie wysłania joba do Nomada zachodzi proces jego ewaluacji i podejmowania decyzji, do którego klienta zostanie on przydzielony. Nomad pozwala na określenie dwóch rodzajów przypisań. Pierwszym z nich jest constraint
, które stanowi silne przywiązanie, oznaczające, że jeśli w klastrze nie zostanie znaleziony klient spełniający wymagania, to alokacja nie zostanie utworzona. Drugim rodzajem przypisania są tzw. affinity
, które jest miękkim przywiązaniem. Mówią one o tym, że dany job preferuje, aby klient posiadał określone cechy jednakże jeśli taki klient nie zostanie znaleziony wówczas można wybrać innego, który tych cech nie posiada.
Constraines tak samo jak affinities można zastosować na wszystkich trzech poziomach.
job "java-backend" {
affinity {
attribute = "${node.datacenter}"
value = "dev-eu-central-1"
weight = 100
}
group "java-backend" {
constraint {
attribute = "${attr.kernel.name}"
value = "linux"
operator = "="
}
...
task "java-backend" {
...
}
}
}
Dodatkowo, jak można zauważyć sekcja affinity
posiada atrybut weight
, który służy do przypisywania punktów klientom, w zależności od tego, jak bardzo nadają się do przyjęcia zadania. Jest to szczególnie przydatne w przypadku posiadania wielu affinity. Atrybut ten przyjmuje wartości od -100 do 100, gdzie wartości ujemne sprawiają, że affinity działa jak anty-affinity odejmując punkty określonym klientom. Dzięki temu możemy miękko ograniczać wybór hostów, których chcielibyśmy unikać dla danego zadania.
Powyższy blok constraint
określa, że tylko klienci posiadający system operacyjny Linux mogą być wybrani do obsługi tego zadania. Więcej na temat dystrybucji zadań znajdziemy w tym miejscu.
Spread
Warto wiedzieć, że domyślnie do przypisywania zadań Nomad używa strategii binpack. Polega to na tym, że orkiestrator stara się upchnąć jak najwięcej zadań na jednym kliencie, zanim zacznie wykorzystywać zasoby na następnym. To rozwiązanie może być problematyczne w kontekście odporności na awarie ponieważ utrata takiego przepakowanego klienta powoduje utratę dużej ilości zadań. Oczywiście możemy nadpisać to domyślne zachowanie, między innymi za pomocą atrybutu spread
, który przypisujemy na poziomie jobów i grup.
W poniższym przykładzie wykorzystuję zmienną dostępną dla instancji EC2 by równomiernie rozłożyć zadania dla trzech avbility zone.
spread {
attribute = "${platform.aws.placement.availability-zone}"
target "eu-central-1a" {}
target "eu-central-1b" {}
target "eu-central-1c" {}
}
W sekcji target
możemy użyć parametru weight
by określić w jaki sposób przypisywać zadania. Jeśli natomiast pominiemy ten atrybut Nomad równo potraktuje każdy target. Po szczegóły odsyłam do dokumentacji.
Network
W sekcji network
określamy m.in., które porty są używane przez zadania i w jakim trybie ma działać nasza sieć. W przypadku przypisywania portów do zadań w Nomadzie musimy pamiętać, że Nomad jest orkiestratorem, a nasze zadania są dynamicznie tworzone, usuwane i zastępowane. Stąd też nieefektywne byłoby statyczne przypisywanie portów. Zamiast tego Nomad wykorzystuje pulę portów, którą dynamicznie przypisuje do zadań. Są to porty z zakresu od 20 000 do 32 000. Najprościej będzie to zrozumieć na podstawie przykładów.
job "java-backend" {
...
group "java-backend" {
network {
port "http" { <----------------------
to = 8080
}
}
task "java-backend" {
driver = "docker"
config {
image = "ghcr.io/red-devops/workoutrecorder-backend:1.0.0"
args = [ "java", "-jar", "workoutrecorder-backend-1.0.0.jar" ]
ports = ["http"] <----------------------
}
...
}
}
}
W sekcji network definiujemy port o nazwie HTTP, który przekieruje żądania na port 8080. Następnie w konfiguracji zadania java-backend wykorzystujemy ten port. Gdy spojrzymy do interfejsu użytkownika Nomada zobaczymy, że klient, który otrzymał to zadanie ma adres IP 10.0.1.129 i przypisany do tego zadania dynamiczny port 21653, który odpowiada portowi 8080 kontenera.
Oczywiście możemy korzystać ze statycznego mapowania portów, ale w przypadku dynamicznych środowisk jest to bardzo nieefektywne i powinno być stosowane tylko w wyjątkowych sytuacjach, na przykład dla load balancerów. Statyczny przykład poniżej.
...
group "fabio" {
network {
port "lb" {
static = 9999
}
port "ui" {
static = 9998
}
}
...
Wyróżniamy następujące tryby pracy sieci: none
, bridge
, host
i cni
. Nie będę wchodził głębiej w ten element konfiguracji ze względu na długość wpisu, ale gorąco polecam obejrzenie filmu, który prosto opisuje networking Nomada. Zalecam też oraz przeczytanie tego materiału.
Template i artifact
Sam blok template
jest tak obszerny, że omówię tylko niezbędne jego elementy. Jak sama nazwa wskazuje, pozwala on na dynamiczne tworzenie plików z szablonów, ale nie tylko. Zazwyczaj będziemy go używać do generowania plików konfiguracyjnych dla aplikacji. Możemy definiować sekcję template na wszystkich trzech poziomach jobspec.
task "example-task" {
driver = "docker"
config {...}
template {
data = <<EOH
---
bind_port: {{ env "NOMAD_PORT_db" }}
scratch_dir: {{ env "NOMAD_TASK_DIR" }}
node_id: {{ env "node.unique.id" }}
EOH
destination = "local/file.yml"
}
...
Powyższy przykład ilustruje sposób, w jaki tworzymy dla naszego kontenera plik file.yml
z parami klucz-wartość. Wszystkie zmienne między {{ }}
są interpolowane. Jeśli dodamy do tego bloku atrybut env
o wartości true
to zmienne te będą dostępne jako zmienne środowiskowe.
Blok artifact
wykorzystujemy w celu pobierania danych zdalnie.
artifact {
source = "https://example.com/file.yml.tpl"
destination = "local/file.yml.tpl"
}
template {
source = "local/file.yml.tpl"
destination = "local/file.yml"
}
Wydaje mi się, że przykład nie jest zbyt skomplikowany. Po pobraniu pliku z zadeklarowanej strony wykorzystujemy go jako szablon w następnym kroku.
Blok template ma naprawdę potężne możliwości. Możemy go wykorzystać do integracji z narzędziami takimi jak Consul lub Vault by dynamicznie pobierać z nich konfigurację i sekrety. Możemy również zdefiniować zachowanie zadania, tak aby się restartowało lub uruchamiał się zadeklarowany skrypt w momencie wykrycia zmian w zmiennych użytych do renderowania szablonu. Więcej na ten temat znajdziemy w dokumentacji.
Volumes
Nie będę za bardzo się rozpisywał na temat Volumes, ponieważ nie miałem okazji intensywnie z nimi pracować. Ogólnie rzecz biorąc wszystko, co do tej pory opisałem dotyczyło aplikacji pracujących w trybie stateless. Nomad wspiera także rozwiązania i aplikacje działające w trybie statefull. Aby zdefiniować volume dostępne dla Nomada używamy w konfiguracji agenta bloku host_volume
, na przykład:
host_volume "mysql" {
path = "/opt/mysql/data"
read_only = false
}
Klaster widzi zdefiniowane w ten sposób dyski, co pozwala nam wykorzystywać te parametry do tworzenia constraint. Na przykład w momencie, gdy musimy przypisać zadanie wymagające dysków. Inaczej to wygląda gdy chcemy użyć Docker Volumes ponieważ są one zarządzane poza Nomadem. W takim przypadku musimy manualnie dodać odpowiednie metadane do klientów aby je oznaczyć, a następnie wykorzystać te metadane do przypisywania zadań.
Zalecam przejście tych ćwiczeń, jeśli chcesz poznać wszystkie najważniejsze opcje związane wykorzystaniem Nomada do zadań typu statefull.
Service
service
to kluczowy blok w konfiguracji odpowiadający za service discovery. Nomad tworzy dynamiczne środowiska, w których aplikacje pracują, powstając i zanikają dynamicznie. Adresy i porty tych aplikacji stale się zmieniają, dlatego tak ważne jest by szybko reagować na te zmiany i by aplikacje mogły wzajemnie siebie odnajdywać.
Blok service
pozwala obecnie na wykorzystanie dwóch providerów do rejestracji aplikacji: nomad
i consul
. Opcja pierwsza jest domyślna i dość ograniczona, nie wymaga od nas dodatkowego wysiłku. W przypadku korzystania z providera consul
konieczne jest posiadanie klastra Consula oraz jego agenta w parze z klientem Nomada.
task "angular-frontend" {
driver = "docker"
config {
image = "ghcr.io/red-devops/workoutrecorder-frontend:1.0.0"
ports = ["http"]
}
service {
name = "workoutrecorder-frontend"
provider = "consul"
port = "http"
check {
name = "app_health"
type = "tcp"
path = "/"
interval = "20s"
timeout = "5s"
}
}
...
}
W tym przypadku gdy aplikacja zostanie uruchomiona, Nomad będzie regularnie sprawdzał, czy działa ona prawidłowo, health-check ustawiamy w bloku check
. Musimy pamiętać, że agent Consula musi posiadać odpowiednie uprawnienia do rejestracji serwisu w klastrze. W UI Nomada powyższa usługa będzie wyglądała następująco:
W klastrze Consula zarejestrowana usługa będzie dostępna w takiej postaci.
Więcej na temat tego bloku w tym miejscu.
Integracja z HashiCorp Consul
Aby skorzystać z Consula przez klaster Nomada potrzebne jest by na danym nodzie był prawidłowo skonfigurowany agent Consula. Informacja o tym w jaki sposób zainstalować agenta jest dostępna w poprzednim z serii wpisie. Link został podany na początku wpisu.
Dodatkowo w pliku konfiguracyjnym agenta nomada musimy dodać blok consul
ze wskazaniem lokalizacji klastra (w tym wypadku lokalnego agenta) oraz token, którego będziemy używać do komunikacji.
# Consul integration configuration
consul {
address = "127.0.0.1:8500"
token = "<consul_token>"
}
Jak już wcześniej widzieliśmy dzięki temu będziemy mogli zarejestrować nasze usługi dynamicznie, ale także umożliwi to prace z K/V store Consula. Nomad zrobi to automatycznie co pokazuje poniższy przykład.
template {
data = <<EOH
SPRING_DATASOURCE_URL="jdbc:mysql://{{key "config/workoutrecorder/database-endpoint"}}/workoutrecorder?autoReconect=true"
EOH
destination = "secrets/file.env"
env = true
}
Po interpolacji, wartość tej zmiennej {{ key "config/workoutrecorder/database-endpoint" }}
będzie dynamicznie zastąpiona danymi z K/V store Consula.
Integracja z HashiCorp Vault
Integracja z Vaultem jest nieco bardziej wymagająca. W konfiguracji serwerów nomad musimy uwzględnić poniższy blok kodu:
# Vault integration configuration
vault {
enabled = true
task_token_ttl = "1h"
create_from_role = "nomad-cluster"
token = "<vault_token>"
address = "<vault_address>:8200"
}
Blok vault
zawiera informacje dotyczące adresu do komunikacji z klastrem Vault oraz tokenu wykorzystywanego do żądań. W konfiguracji określamy także role, z których możemy pobierać polityki do naszych zadań. Token, z którego korzysta serwer musi zawierać odpowiednie reguły (szczegóły w tym miejscu). Z uwagi na długość polityki poniżej widoczny jest tylko jej mały fragment.
...
path "auth/token/create/nomad-cluster" {
capabilities = ["update"]
}
path "auth/token/roles/nomad-cluster" {
capabilities = ["read"]
}
...
Deklaracja pozwala naszemu tokenowi tworzyć inne tokeny i przypisywać do nich polityki dostępne dla roli nomad-cluster
. W tym wypadku dostępne polityki dla roli nomad-cluster to fabio
i workoutrecorder
.
Blok vault
dla klienta nomada będzie wyglądał następująco:
# Vault integration configuration
vault {
enabled = true
address = "<vault_address>:8200"
}
W tym miejscu czeka nas najciekawsza część – aby móc skorzystać z dynamicznego wstrzykiwania sekretów do jobspec możemy wykorzystać poniższą metodę interpolacji.
vault {
policies = ["workoutrecorder"]
change_mode = "signal"
change_signal = "SIGUSR1"
}
template {
data = <<EOH
{{with secret "kv/data/workoutrecorder/db_workoutrecorder"}}
SPRING_DATASOURCE_USERNAME="{{.Data.data.username}}"
SPRING_DATASOURCE_PASSWORD="{{.Data.data.password}}"
{{end}}
EOH
destination = "secrets/file.env"
env = true
}
Po pierwsze musimy zdefiniować blok vault
, w którym określamy, jaką politykę potrzebujemy do pobrania danego sekretu. W ten sposób serwer Nomad wygeneruje token tylko dla konkretnego joba i sam będzie zarządzał jego czasem życia. Token ten będzie posiadał tylko politykę workoutrecorder
. Polityka ta musi być dostępna do przypisania z roli używanej przez serwery. Dzięki takiemu rozwiązaniu mamy bardzo bezpieczny system, w którym tokeny są automatycznie tworzone i odnawiane w zależności od wymagań.
with secret "kv/data/workoutrecorder/db_workoutrecorder"
sprawi, że ściągniemy z odpowiedniej ścieżki Vaulta sekret, który zostanie zwrócony w postaci json. Wykorzystując iterowanie po wartościach json możemy wybrać z niego odpowiednie dane {{ .Data.data.username }
} i {{ .Data.data.password }}
do użycia w naszym jobspec.
Starategie deploymentu aplikacji
Nomad oferuje różne strategie wdrażania aplikacji takie jak Rolling Updates, Blue/Green i Canary, z czego pierwsza z nich jest mechanizmem domyślnym. Nomad korzysta ze statusu job i health check (o ile jest zadeklarowany) aby określać, czy dane wdrożenie zakończyło się sukcesem. W Nomadzie możemy dodatkowo ustawić auto-rollback do poprzedniej działającej wersji w przypadku porażki deploymentu. Parametry te określamy za pomocą sekcji update
definiowanej na poziomie job i group.
update {
max_parallel = 3
min_healthy_time = "30s"
healthy_deadline = "2m"
}
Po pełny przegląd dostępnych atrybutów odsyłam do dokumentacji.
Warto wiedzieć
W poprzednich częściach wpisu mówiłem w jaki sposób powinno się postępować chcąc wprowadzić zmiany na serwerze Nomada i jak bezpiecznie usunąć go z klastra. W przypadku klienta Nomada nie jest to aż tak ryzykowne, ale musimy zachować pewną kolejność.
W pierwszym etapie musimy zablokować możliwość przypisywania nowych zadań do klienta za pomocą komendy nomad node eligibility -disable <node>
. Następnie jeśli agent posiada aktywne zadania należy przenieść je do innych dostępnych agentów używając komendy nomad node drain -enable <node>
. Kiedy wszystkie zadania zostaną przeniesione możemy bezpiecznie zakończyć proces agenta.
Monitoring
W Nomadzie mamy kilka sposobów monitorowania naszego klastra i poszczególnych aplikacji. Do sprawdzenia logów systemowych nomada możemy użyć polecenia nomad monitor
.
Konfiguracja Nomada umożliwia automatyczne przekazywanie logów do aplikacji specjalizujących się w monitorowaniu takich jak DataDog, Prometheus czy Circonus. Szczegóły w dokumentacji.
Nomad User Interface
Jeśli nasz klaster ma aktywny system ACL to aby móc używać UI musimy się do niego zalogować za pomocą tokena. Po przejściu na stronę główną zobaczymy następujący widok.
Klikamy hiperłącze token, które przeniesie nas do autentykacji za pomocą tokenu. Wprowadzamy nasz token i klikamy „Sign in with secret”.
Zostaniemy przeniesieni do strony głównej.
Tu możemy dowolnie eksplorować nasze zadania, allokacje, stany deploymentów oraz wiele innych parametrów. Mamy możliwość skorzystania z UI do przeglądu całego klastra w jednym miejscu, rozlokowania zadań między klientów, wglądu do przypisanych zasobów oraz całkowitej mocy dostępnej dla systemu.
W przypadku konkretnej alokacji możemy sprawdzić poziomy zużytych zasobów w czasie na prostym wykresie.
Możemy też sprawdzić logi poszczególnych aplikacji.
Nie sposób przedstawić wszystkie interesujące opcje, które są dostępne, ale zespół odpowiedzialny za interfejs użytkownika wykonał kawał dobrej roboty.
Noma-pack
Mimo, że omówiliśmy już wiele kluczowych aspektów związanych z Nomadem to sięgając po nieco bardziej zaawansowane tematy warto wspomnieć o narzędziu, które odgrywa kluczową rolę w naszym ostatecznym rozwiązaniu – a mianowicie nomad-pack. Zanim przejdziemy do ostatniej części wpisu wyjaśnię czym jest to narzędzie i jak można skutecznie je wykorzystać.
Nomad-pack nie jest niezbędny do pracy z Nomadem. Jest to pewna abstrakcja, która ułatwia pracę z jobspec, automatyzuje procesy i dodaje dodatkowe szablony. Podczas gdy powstawał ten artykuł najnowsza wersja nomad-pack
to 0.1.0.
Używając nomad-pack możemy w prosty sposób wdrażać popularne aplikacje, tworzyć własne często używane paczki konfiguracyjne oraz korzystać z paczek przygotowanych przez społeczność Nomada. W mojej opinii użytkowanie narzędzia jest bardzo proste. Materiały szkoleniowe znajdziemy tutaj.
Jak wspomniałem wcześniej nomad-pack dodaje pewną abstrakcję do jobspec czyniąc go szablonem. Dzięki temu możemy elastycznie wstrzykiwać do niego zmienne i całe bloki kodu korzystając między innymi z gotemplate, który jest używany do interpolacji. Taka konstrukcja sprawia, że możemy utworzyć kilka zestawów zmiennych dla różnych środowisk. Najłatwiej będzie zrozumieć jego działanie w praktycznym zastosowaniu.
Instalacja
Aby zainstalować nomad-pack pobieramy odpowiedni plik ZIP ze strony HashiCorp, rozpakowujemy go, a następnie dodajemy do ścieżki systemowej (PATH). Po wykonaniu tych czynności, używając polecenia nomad-pack
możemy zobaczyć dostępne opcje CLI.
Jobspec z użyciem nomad-pack
Podczas pierwszej inicjalizacji nomad-pack pobiera domyślny rejestr gotowych paczek z Nomad Pack Community Registry. To dosyć obszerna baza gotowych paczek co jest widoczne w poniższym fragmencie.
Chcąc stworzyć własną paczkę najlepszym rozwiązaniem jest użycie polecenia nomad-pack generate pack <pack_name>
. Nomad-pack stworzy dla nas strukturę folderów oraz wstępnie uzupełni najważniejsze pliki, dzięki czemu będziemy musieli dokonać tylko kilku niezbędnych zmian.
Najważniejszym plikiem w całej układance jest plik red_devops.nomad.tpl
. Nie wchodzę zbyt głęboko w szczegóły renderowania tego pliku bowiem zależy mi by zrozumieć koncepcję, w której nasz plik tpl
możemy dynamicznie uzupełniać danymi domyślnymi lub podawanymi za pomocą odpowiednich flag. Poniżej mamy przykład wygenerowanej paczki bez żadnych zmian.
Plik ze zmiennymi jest identyczny jak w przypadku deklaracji zmiennych w narzędziu Terraform. Poza pojedynczymi zmiennymi możemy wstrzykiwać do jobspec całe bloki kodu. Oto przykład z innej paczki.
Powyższe rozwiązanie daje dużą swobodę w konfiguracji aplikacji. Z pomocą flagi --var-file <path_to_configuration_file>
możemy dostarczać całe pliki zmiennych do naszego jobspec.
Nomad-pack CLI
W przeciwieństwie do CLI Nomada, CLI nomad-packa oferuje skromniejszy zestaw komend. Poniżej wymienionych zostało kilka najważniejszych opcji.
nomad-pack plan
– odpowiednik nomad job plan pozwalający na sprawdzenie czy nasz job zostanie prawidłowo przypisany do alokacji
nomad-pack render
– zwraca jobspec z uzupełnionymi szablonami co jest wyjątkowo przydatne przy debugowaniu naszej konfiguracjinomad-pack run
– odpowiada komendzie nomad job run, wdraża aplikację do klastra
nomad-pack destroy
– odpowiednik nomad job stop, który zatrzymuje dany job w klastrzenomad-pack status
– odpowiednik nomad job statusnomad-pack info
– zwraca dane o paczce takie jak metadane, zmienne i opisy
nomad-pack generate registry
– tworzy strukturę folderów pod repozytorium paczek
Automatyczny deployment HashiCorp Nomad
W tym miejscu należą się słowa uznania wszystkim, którzy dotarli do tego miejsca. Teraz przystąpimy do omówienia automatycznego wdrażania klastra Nomada z grupą serwerów i klientów.
Krótka przypominajka. Artykuł, który właśnie czytasz jest ostatnim z serii wpisów o narzędziach od HashiCorp. W tej serii stopniowo rozwijaliśmy nasz system, zaczynając od stworzenia infrastruktury za pomocą Terraform. Następnie instalowaliśmy serwer Vaulta, klaster Consula, a teraz dodamy ostatnią część układanki – orkiestrator Nomad.
UWAGA NA ZMIANY W REPOZYTORIACH !!!
Proszę zwrócić uwagę na daty commitów, które znajdziecie we wszystkich repozytoriach dostępnych na https://github.com/red-devops. Często korzystam z wcześniejszego kodu w kolejnych wpisach, dostosowując go do bieżących zadań. W związku z tym może się zdarzyć, że kod prezentowany w tym wpisie będzie różnił się od tego, który znajdziecie w najnowszym commicie repozytorium. Niemniej jednak wszystkie główne commity zostawiam, dlatego proszę porównywać daty publikacji wpisu z datami commitów.
Założenia i komunikacja
Klaster Nomada zostanie utworzony na trzech niezależnych hostach rozmieszczonych w trzech różnych AZ (Availability Zone) w regionie eu-central-1 w chmurze AWS. Dodatkowo utworzymy trzy hosty dla klientów Nomada, które będą placeholderami dla naszych aplikacji. Dwa z nich zostaną umieszczone w prywatnej podsieci na różnych strefach dostępności, gdzie będą utrzymywać aplikacje backend i frontend. Trzeci host będzie umieszczony w publicznej podsieci i będzie hostować Fabio, umożliwiając użytkownikom sieci publicznej korzystanie z naszej aplikacji.
Nomad będzie w pełni zintegrowany z Consulem. Na wszystkich węzłach Nomada zainstalujemy agenta Consula, który ułatwi tworzenie klastra Nomada oraz będzie dynamicznie rejestrował wszystkich agentów i aplikacje.
Poniżej przedstawiam uproszczony schemat struktury hostów wraz z komunikacją od klienta. Ze względu na przejrzystość rysunku nie zaznaczyłem kontenerów utrzymywanych przez klientów Nomada. Warto jednak zauważyć, że na kliencie publicznym występuje tylko Fabio jako load balancer (LB) i proxy do prywatnej podsieci. Na klientach prywatnych znajdują się kontenery z aplikacjami backendowymi i frontendowymi.
Na publicznym hoście Nomada będzie działał kontener Fabio, pobierający dane z klastra Consula i mapujący dostępne usługi. Dzięki temu za jego pośrednictwem użytkownik będzie mógł:
- Za pośrednictwem Fabio (1). Pobrać aplikację angularową z prywatnych klientów Nomada (2), którzy będą utrzymywać zarówno frontend jak i backend aplikacji
- Aplikacja angularowa korzystając z Fabio nawiąże komunikację z backendem, który będzie odpytywał bazę danych (3)
Etapy wdrażania
Aby prawidłowo wdrożyć cały system skorzystamy z poniższych repozytoriów:
Główne repozytorium terraform do stworzenia całej infrastruktury: Workout-recorder-nomad
Playbook ansibla instalujący HasiCorp Vault: Vault-server-ansible
Playbook ansibla instalującu HasiCorp Consul: Consul-server-ansible
Playbook ansibla instalującu HasiCorp Nomad( Serwery i Klijetów ): Nomad-cluster-ansible
Repo do releasu aplikacji backendowej: workoutrecorder-backend
Repo do releasu aplikacji frontendowej: workoutrecorder-frontend
Deployment aplikacji na klaster nomada: Nomad-Packs
Zaczynamy od momentu, w którym nasz github self-hosted runner jest gotowy do rozpoczęcia pracy. Sieć została utworzona, a niezbędne grupy zabezpieczeń są skonfigurowane. Aby uruchomić proces za pomocą agenta konieczne jest wysłanie commita, który go wywoła. Szczegóły i sposób wdrożenia infrastruktury zostały omówione w jednym z poprzednich wpisów dotyczących narzędzia HashiCorp Terraform w sekcji „Wdrażamy infrastrukturę”.
Gdy agent zarejestruje się w serwisie GitHub reszta procesu przebiega już automatycznie. Wystarczy wysłać commit na odpowiednią gałąź repozytorium Workout-recorder-nomad. Spowoduje to zbudowanie instancji Vaulta, a następnie klastra Consula. W dalszej kolejności wstanie baza danych, a na koniec kod Terraform stworzy instancje pod serwery i klientów Nomada. Skrypt User Data skonfiguruje wszystkie wcześniej wymienione hosty z pomocą playbooków ansibla.
Po poprawnej rejestracji klientów w klastrze Nomada skorzystamy z nomad-pack do wdrożenia trzech wcześniej wymienionych aplikacji.
Jedna z istotnych zmian dotyczy aplikacji. W poprzednim wpisie wdrażaliśmy aplikację uruchamiając ją bezpośrednio na instancjach EC2 w AWS. Tym razem dokonamy tego wykorzystując driver dockera w Nomadzie. Musimy zatem dodać Dockerfile do budowy obrazów dla backendu i frontendu. W przypadku Fabio skorzystamy z gotowego obrazu pobranego z Dockerhub. Szczegóły dotyczące procesu budowy znajdziecie tutaj:
- workoutrecorder-backend: release-docker-image.yaml
- workoutrecorder-frontend: release-docker-image.yaml
Obrazy są publicznie dostępne w repozytorium bloga na github, do którego link znajduje się poniżej:
red-devops/packages
Tworzymy infrastrukturę dla Nomada
Wypychamy trigger commit na branch feature, gdzie workflow GitHub sprawdza nasz kod pod względem poprawności kodu Terraform. Następnie tworzymy Pull Request (PR) do gałęzi dev aby utworzyć środowisko dev dla naszego systemu.
Na tym etapie pipeline, przy użyciu terraform plan analizuje, które zasoby zostaną utworzone, a następnie dodaje komentarz do PR dla każdej wdrażanej warstwy. Po zakończeniu planowania wszystkich warstw łączymy PR do gałęzi dev, co z kolei powoduje wywołanie komendy terraform apply.
Dla przypomnienia, w przypadku warstwy od bazy danych wzwyż możemy zauważyć, że terraform plan wyrzuca błędy. Dzieje się tak gdyż podczas pierwszego wdrażania warstwy nie istnieją jeszcze warstwy poprzednie. W kolejnych iteracjach sytuacja ta nie wystąpi. Działanie to jest to zamierzone. Polecenie terraform apply nie wyrzuci błędów.
Wszystkie instancje EC2 (Vault, Consul, Nomad) mają ustawione odpowiednie skrypty User Data, które pobierają repozytoria z kodem Ansible. Ten z kolei konfiguruje hosty. Proces trwa prawie 30 minut ponieważ musimy zachować odpowiednią kolejność uruchamiania aplikacji wspomagających i baz danych.
Podczas konfiguracji serwerów Nomada playbook Ansible bootstrapuje system ACL i dodaje Nomad Global Management Token do AWS Secret Manager. Wykorzystałem pewien trik aby sprawdzić, czy klaster Nomada został poprawnie uruchomiony. Stworzyłem bastion host (nie jest on dostępny w tym repozytorium) do tunelowania moich żądań z localhost do prywatnej podsieci AWS. Poniżej zamieszczam screeny pokazujące, że klaster utworzył się prawidłowo.
Node’y także zarejestrowały się poprawnie w klastrze Consula. Przy okazji warto wspomnieć, że w celu stworzenia klastra Nomada wykorzystałem Consula do wzajemnego odnalezienia serwerów. Dzięki temu nie musiałem umieszczać atrybutu server_join
w konfiguracji.
Zarówno serwery jak i klienci Nomada podłączyli się prawidłowo. Możemy zatem zdeployować nasze aplikacje.
Fabio jobspec
W pierwszej kolejności wypuścimy Fabio za pomocą nomad-pack. Poniżej znajduje się zrenderowany fragment jobspec.
job "fabio" {
...
type = "service"
constraint {
attribute = "${attr.kernel.name}"
value = "linux"
operator = "="
}
constraint {
attribute = "${meta.public}"
value = "true"
operator = "="
}
group "fabio" {
count = 1
network {
port "lb" {
static = 9999
}
port "ui" {
static = 9998
}
}
service {
name = "fabio"
tags = ["fabio"]
provider = "consul"
port = "lb"
check {
...
}
}
task "fabio" {
driver = "docker"
vault {
policies = ["fabio"]
...
}
config {
image = "ghcr.io/red-devops/fabio:1.0.0"
network_mode = "host"
ports = ["lb", "ui"]
mount {
type = "bind"
source = "local"
target = "/etc/fabio"
}
}
template {
data = <<EOH
registry.consul.token = {{ with secret "kv/data/consul/fabio_token"}}{{.Data.data.token}}{{end}}
registry.consul.register.enabled = false
EOH
destination = "local/fabio.properties"
}
...
}
}
}
Jobspec fabio działa w trybie service
i może zostać uruchomiona tylko na systemach Linux oraz klientach posiadających metadane public
na wartość true
. Jest to kluczowe ponieważ użytkownik będzie się komunikował z aplikacją wykorzystując publicznie dostępny host. Metadane public
zdefiniowałem w pliku konfiguracyjnym agenta Nomada.
...
# Client Configuration
client {
enabled = true
meta {
public = "true"
}
}
...
Ustawiamy stałe porty 9999
i 9998
ponieważ aplikacja Angular będzie z nich korzystać. Dodajemy blok service
aby Nomad zajął się rejestracją Fabio w Consulu. Aplikacja Fabio domyślnie zarejestrowałaby się sama jednakże pozostałe aplikacje rejestrują się za pomocą Nomada. W celu ujednolicenia tego elementu wyłączyłem tę opcję dla Fabio. Musiałem zatem ustawić właściwość registry.consul.register.enabled = false
.
By działać w sposób prawidłowy Fabio potrzebuje odpytywać Consula o serwisy i ich tagi, dlatego też do pliku konfiguracyjnego wstrzykuje token, który jest dynamicznie pobierany jako sekret z Vaulta. Nomad uruchamia fabio wykorzystując driver docker. Wartość pliku konfiguracyjnego jest do niego dostarczana za pomocą montowania (atrybut mount
).
Frontend jobspec
Poniżej zamieściłam zrenderowany jobspec dla zadania workoutrecorder_frontend:
job "workoutrecorder_frontend" {
...
constraint {
attribute = "${attr.kernel.name}"
value = "linux"
operator = "="
}
constraint {
attribute = "${meta.public}"
value = "false"
operator = "="
}
spread {
attribute = "${platform.aws.placement.availability-zone}"
target "eu-central-1b" {}
target "eu-central-1a" {}
}
group "angular-frontend" {
count = 2
network {
port "http" {
to = 80
}
}
service {
name = "workoutrecorder-frontend"
tags = ["frontend", "urlprefix-/"]
provider = "consul"
port = "http"
...
}
task "angular-frontend" {
driver = "docker"
config {
image = "ghcr.io/red-devops/workoutrecorder-frontend:1.0.0"
ports = ["http"]
}
...
}
}
}
Blok constraint
został już omówiony. W stanza spread
określamy jak wagowo nasze aplikacje mają być rozdzielane miedzy klientów. Następnie podajemy znane już atrybuty. Ważna jest sekcja tags
dla bloku service ponieważ dodaje ona do Consula tagi, które Fabio wykorzystuje do mapowań.
Backend jobspec
Jobspec dla aplikacji bakendowej jest nieco bardziej skomplikowany:
job "workoutrecorder_backend" {
...
group "java-backend" {
count = 2
network {
port "http" {
to = 8080
}
}
service {
name = "workoutrecorder-backend"
tags = [
"backend",
"urlprefix-/workout/all",
"urlprefix-/workout/add",
"urlprefix-/workout/update",
"urlprefix-/workout/delete"
]
provider = "consul"
port = "http"
check {
type = "http"
port = "http"
name = "app_health"
path = "/actuator/health"
interval = "20s"
timeout = "5s"
}
}
task "java-backend" {
driver = "docker"
config {
image = "ghcr.io/red-devops/workoutrecorder-backend:1.0.0"
args = ["java", "-jar", "workoutrecorder-backend-1.0.0.jar"]
ports = ["http"]
}
vault {
policies = ["workoutrecorder"]
change_mode = "signal"
change_signal = "SIGUSR1"
}
template {
data = <<EOH
{{with secret "kv/data/workoutrecorder/db_workoutrecorder"}}
SPRING_DATASOURCE_USERNAME="{{.Data.data.username}}"
SPRING_DATASOURCE_PASSWORD="{{.Data.data.password}}"
{{end}}
SPRING_DATASOURCE_URL="jdbc:mysql://{{key "config/workoutrecorder/database-endpoint"}}/workoutrecorder?autoReconect=true"
EOH
destination = "secrets/file.env"
env = true
}
...
}
}
}
constraint
i spread
są takie same jak w przypadku frontendu, więc je wykropkowałem. Tagi dla serwisów są bardziej rozbudowane ponieważ aplikacja udostępnia więcej endpointów, a każdy z nich musimy zmapować. W bloku template z Vaulta wstrzykujemy dane potrzebne do komunikacji z bazą danych, a z Consula jej adres. Dane te będą dostępne dla kontenera jako zmienne środowiskowe.
Deployment z użyciem nomad-pack
By wdrożyć aplikację na klaster nomada wykorzystamy przygotowany wcześniej workflow githuba dostępny w repozytorium Red-Devops-Nomad-Packs. Przechodzimy do zakładki Action i wdrażamy paczkę fabio
do klastra Nomada. Z listy rozwijanej wybieramy odpowiednią paczkę i wersję aplikacji, która zostanie użyta.
Po kilku sekundach nasz jobspec zostanie wysłany do Nomada. Dla dociekliwych dodałem maskowanie do NOMAD_TOKEN, więc nawet z opcją debug jego wartość nie pokaże się w logach.
Kolejnym krokiem jest wypuszczenie nowej wersji frontendu aplikacji workoutrecorder. Jest to konieczne ponieważ strona ma statycznie zapisany adres IP do LB, a musimy w niej uwzględnić nowy adres IP Fabio do przekierowywania żądań. Ten krok nie byłby konieczny gdybyśmy korzystali z systemu DNS. W tym poradniku nie korzystam z tego rozwiązania. Link bezpośredni do workflow github-a odpowiedzialnego za release obrazu workoutrecorder frontend.
Następnie postępujemy podobnie jak w przypadku fabio wdrażając paczki backend i frontend. Po kilku minutach powinniśmy zobaczyć widok zadań taki jak poniżej.
Dodam, że dopisek PACK obok nazwy zadania oznacza, że został on wdrożony z użyciem nomad-pack. W Consulu serwisy te będą wyglądały następująco (można zauważyć tagi przypisane do każdego z nich):
Dla publicznej instancji EC2 dodałem wyjątek pozwalający na komunikację na porcie 9999 i 9998. Drugi port pozwala na sprawdzenie UI Fabio. Endpointy zostały zmapowane prawidłowo. Przy okazji widzimy też, że posiadamy po dwa kontenery dla frontend i backend, do których ruch rozrzucany jest równomiernie.
Pamiętajmy, że w tle dla każdej aplikacji przez cały czas działają health-checks, dzięki którym mamy pewność, że żądania będą kierowane tylko do działających kontenerów. Jeśli dodamy do tego fakt, że instancje znajdują się w osobnych strefach dostępności (AZ) system staje się stabilny i dość odporny na awarie.
Po przejściu na port 9999 publicznego adresu IP klienta nomada w końcu zobaczymy naszą aplikację hostowaną na Nomadzie, która zapisuje wszystkie informacje w bazie danych AWS RDS.
Uwagi na koniec
W tym miejscu dobiega końca seria wpisów dotyczących narzędzi HashiCorp, których publikację zaplanowałem na ten rok. Dzięki wykorzystaniu Packer, Terraform, Vault, Consul i Nomad udało się zbudować platformę, która doskonale sprawdza się w utrzymaniu różnorodnych aplikacji i systemów. Nasze rozwiązanie jest skalowalne, elastyczne i dostosowane do nowoczesnych wymagań, co umożliwia łatwe rozwijanie i udoskonalanie.
Zauważcie, że musiałem pójść na pewne kompromisy w zakresie bezpieczeństwa oraz zastosować uproszczenia w mechanizmach działania. Wybór ten wynikał z faktu, że projekt był realizowany wyłącznie przeze mnie i w ramach mojego hobby.
Duża ilość wiedzy nie została tutaj zamieszczona, ponieważ zbyt odbiegała ona od głównego tematu poszczególnych wpisów. Mam jednak pomysł na kolejny artykułu, który nie skupi się już na samych narzędziach HashiCorp lecz na mechanizmach jakie zastosowałem w celu połączenia i zautomatyzowania całej platformy. Podzielę się także spostrzeżeniami dotyczącymi tego, co mogłoby być zrobione lepiej oraz wskażę obszary, w których konieczne są zmiany w kontekście implementacji na środowiska produkcyjne.
Podsumowanie
HashiCorp Nomad to jedno z najbardziej niedocenianych na rynku narzędzi do orkiestracji wyprzedzające swoją epokę. Mimo, że nie dorównuje popularnością Kubernetesowi to głęboko wierzę, że w nadchodzących latach ta przepaść będzie się zmniejszać.
W tym wpisie przekazałem dużo informacji na temat pracy operatora i developera Nomada. HashiCorp Nomad to prosty i uniwersalny system orkiestracji aplikacji i zarządzania zadaniami w klastrze. Pozwala na efektywne wdrażanie, skalowanie i utrzymanie aplikacji. Kluczowe cechy Nomada obejmują elastyczność, obsługę wielu rodzajów prac, integrację z innymi narzędziami HashiCorp takimi jak Consul i Vault.
Nomad umożliwia skalowanie aplikacji w sposób płynny, pozwala na zarządzanie cyklem życia jobów, a także monitorowanie i utrzymanie ich na różnych węzłach. Wdrażanie aplikacji jest ułatwione dzięki zastosowaniu deklaratywnego podejścia w konfiguracji. Narzędzie wspierające nomad-pack dodaje dodatkową warstwę abstrakcji ułatwiającą automatyzację i korzystanie z gotowych paczek konfiguracyjnych.
Podsumowując, HashiCorp Nomad stanowi potężne narzędzie do zarządzania aplikacjami w klastrze oferując elastyczność, skalowalność i bezpieczeństwo potrzebne do obsługi nowoczesnych systemów IT.