Internal Development Platform z narzędziami HashiCorp

Internal Development Platform z narzędziami HashiCorp

Cześć! Dzisiejszy wpis będzie podsumowaniem dużej części materiałów z zeszłego roku, a dokładnie serii dotyczącej narzędzi HashiCorp. Zamiast jednak analizować poszczególne narzędzia, skoncentruję się na IDP i jego kluczowych elementach. Na początku omówię istotę tej platformy i opowiem kiedy warto ją budować. Przeanalizuję również techniczne aspekty naszego rozwiązania wyjaśniając, dlaczego zdecydowaliśmy się na określone podejścia. Ponadto przyjrzę się błędom, które popełniliśmy w trakcie tworzenia tego projektu oraz możliwościom ich poprawy. Zapraszam!

Internal Development Platform – IDP

IDP czyli Internal Developer Platform to rodzaj platformy będącej usługą (PaaS), którą tworzy zespół platform inżynierów (PE). Usługa skierowana jest zazwyczaj do deweloperów wewnątrz firmy mająca na celu przyspieszenie procesu tworzenia oprogramowania, utrzymanie infrastruktury, zapewnienie przestrzegania dobrych praktyk oraz lepszy podział odpowiedzialności, a także zmniejszenie obciążenia dla deweloperów.

IDP tworzy abstrakcję nad wykorzystywanymi zasobami takimi jak chmury publiczne dostarczając deweloperom wygodne UI lub API. Deweloper nie musi znać wszystkich usług, które są wykorzystywane w ramach danego rozwiązania. Zespół PE wykorzystując swoją wiedzę administracyjną przygotowuje elastyczne rozwiązania dla deweloperów.

Jak łatwo zauważyć budowa IDP nie zawsze ma sens. Miałem okazję być członkiem zespołu Platform Engineering, który wspierał zespoły deweloperskie w jednym z większych europejskich banków. W takim przypadku IDP było idealnym rozwiązaniem, ponieważ istniało wiele zespołów, co umożliwiło standaryzację pewnych rozwiązań. Jednak dla małych firm lub produktów może to być zbyt skomplikowane.

Myślę, że to wystarczy aby zrozumieć dla kogo ma sens budowa IDP i jaki jest jego cel. Jest to szeroki temat i aby omówić go dokładnie należałoby poruszyć kwestię wprowadzenia metodologii DevOps do zespołów, problemy jakie to stworzyło oraz wpływ zespołów PE na ilość devopsów w zespołach developerskich. W dzisiejszym wpisie skupimy się jednak wyłącznie na IDP.

IDP z narzędziami HashiCorp

Zanim przejdę do omawiania poszczególnych elementów IDP, które zbudowaliśmy przedstawię kilka założeń. Ponieważ na przestrzeni czasu nasze IDP ewoluowało będę omawiał ostatnią wersję z wpisu na temat HashiCorp Nomad. Poniżej zamieszczam linki do wszystkich repozytoriów, które będą uczestniczyć w procesie wdrażania IDP.

Omówię kolejne etapy, począwszy od momentu, w którym za pomocą kodu Terraform tworzymy pierwszą warstwę oraz miejsce przechowywania stanu Terraform, aż do momentu, w którym nasz klaster Nomada jest gotowy do przyjmowania zadań do wdrożenia. Wyjaśnię, jak po utworzeniu poszczególnych instancji odbywa się proces konfiguracji.

W trakcie wyjaśniania poszczególnych rozwiązań, przedstawię alternatywne podejścia do niektórych z nich. Wyjaśnię, dlaczego zastosowałem określony mechanizm, a także jak należy to zrobić w środowisku produkcyjnym.

Lista repozytoriów bioracych udzial w budowie IDP:
Infrastruktura: https://github.com/red-devops/Workout-recorder-nomad
Konfiguracja Nomada: https://github.com/red-devops/Nomad-cluster-ansible
Konfiguracja Consula: https://github.com/red-devops/Consul-server-ansible
Konfiguracja Vaulta: https://github.com/red-devops/Vault-server-ansible
Budowa obrazu agenta GitHub: https://github.com/red-devops/Packer-guide

Infrastruktura

Zaskoczę Was ponieważ pierwszym elementem, który omówię, nie będzie implementacja kodu Terraform, lecz sposób, w jaki za pomocą narzędzia Packer stworzyliśmy obraz agenta GitHub.

Spersonalizowane obrazy maszyn

Wybrałem agenta GitHub, ponieważ całe repozytorium kodu znajduje się właśnie na tej platformie. Dodatkowo, nie chciałem dodawać zbyt wielu narzędzi pomocniczych, ponieważ ten wpis tworzony jest hobbystycznie, a seria miała skupić się na narzędziach HashiCorp. Oczywiście można by wybrać inne narzędzia, takie jak GitLab, TeamCity, itp., jednak w moim przypadku miałem gotowe UI out of the box, dlatego zdecydowałem się na integrację z GitHub.

Drugą kwestią, na którą zwróciłem uwagę było wykorzystanie agenta w celu zwiększenia bezpieczeństwa. Między innymi do procesu wdrażania wykorzystałem GitHub Actions i miałem do wyboru skorzystanie z gotowego agenta udostępnionego i skonfigurowanego przez GitHuba lub stworzenie własnego.

Jedną z zalet posiadania własnego agenta jest jego nielimitowany czas pracy. Oznacza to, że w przeciwieństwie do standardowych GitHub-hosted runners, które posiadają limit czasu wykorzystania na miesiąc, nasz własny agent pracuje bez ograniczeń czasowych. Oczywiście musimy opłacać infrastrukturę, na której jest uruchomiony ale jest to koszt platformy. Kolejną korzyścią jest fakt, że nasz agent znajduje się w prywatnej podsieci, co oznacza, że nie musimy otwierać naszej VPC na dodatkowe publiczne adresy IP. Nie musimy też udostępniać poświadczeń do konta AWS dlatego, że nasz agent działa wewnątrz chmury. Wystarczy nadać odpowiednie role na poziomie instancji.

Packer

W przypadku Packera wykorzystałem go tylko do stworzenia obrazu dla agenta GitHub, jednakże należałoby go również wykorzystać do przygotowania domyślnych obrazów maszyn wirtualnych i zainstalowania na nich oprogramowania wymaganego od razu po uruchomieniu. Osobiście tego nie zrobiłem. Zawsze przy tworzeniu instancji EC2 korzystałem z bloku kodu data, aby pobrać obraz AMI Ubuntu z katalogu AWS. Takie rozwiązanie zmniejszyłoby ilość kodu potrzebnego do zdefiniowania AMI AWS za każdym razem, ale z drugiej strony, gdybyśmy mieli własne AMI, musielibyśmy ponosić koszty związane ze składowaniem.

Wiecej na teamt automatycznej budowy obazów z użcyiem Packer, oraz instrukcji jak zbudowałem self-hosted-runner do GitHub znajdziecie w tym wpisie: HashiCorp Packer – Automatyzacja Budowy Obrazów

Terraform

Do stworzenia infrastruktury naszego IDP użyliśmy narzędzia Terraform. Zasoby infrastrukturalne posiadają wiele elementów zależnych od siebie, które muszą być wdrażane w określonej kolejności. W naszym przypadku, aby to osiągnąć, pogrupowaliśmy kod Terraform w osobne foldery i ponumerowaliśmy je, aby wiedzieć, które z nich muszą być wdrożone jako pierwsze.

Wcześniej rozmawialiśmy o self-hosted runnerze umieszczonym w naszej chmurze. Zauważmy, że jest to etap 140-cicd-agent, dopiero etapy od 150 wzwyż mogą zostać automatycznie wdrożone przez tego agenta. Wcześniejsze etapy należy wykonać ręcznie korzystając z komendy terraform apply. W przypadku etapu 110-terraform-state musimy najpierw zakomentować blok backend w plikach konfiguracyjnych terraform. Jest to konieczne ponieważ będziemy tworzyć miejsce do przechowywania stanów terraform. Po utworzeniu backendu możemy przenieść stan do nowego miejsca i kontynuować bez zmian w konfiguracji.

Przytoczę teraz kilka uwag odnośnie struktury folderów, którą zastosowałem. Można zauważyć, że każdy z folderów posiada swój własny folder vars, a w nim znajduje się pusty plik o nazwie all.vars. Ten plik dodano z myślą o przyszłości, abyśmy mogli umieścić tam wspólne zmienne dla różnych grup konfiguracyjnych terraform, na przykład nazwy niestandardowe lub określone statyczne identyfikatory niektórych zasobów. Podobnie dla wszystkich folderów mamy globalny folder nadrzędny vars, w którym znajduje się plik o nazwie common.vars zawierający informacje wspólne dla wszystkich grup.

W obecnym projekcie może to być nieco nadmiarowe, ale taka struktura katalogów daje nam dużą elastyczność w dodawaniu kolejnych bloków kodu. GitHub runner wywołuje poszczególne bloki kodu w sposób, który wymaga obecności nawet pustych plików ze zmiennymi. W przeciwnym razie pojawi się błąd o ich braku.

Spotkałem się z dwoma podejściami do tworzenia konfiguracji Terraform dla wielu środowisk. Jedno z nich jest nieco prostsze i łatwiejsze w manipulacji, ale jednocześnie bardziej podatne na brak spójności. Polega ono na tym, że każde środowisko ma swoją własną grupę folderów konfiguracyjnych, podobnie jak pokazano poniżej.

Drugie podejście, nieco bardziej skomplikowane, polega na wykorzystaniu terraform workspace do rozdzielenia stanów Terraform między różnymi środowiskami. Jest to zdecydowanie preferowane rozwiązanie gdy mamy pełną automatyzację.

Kolejną kwestią jest sposób wykorzystania GitHub Actions do wykonywania kodu Terraform. Wykorzystałem funkcję matrix, aby wykonać terraform plan/apply dla każdego z folderów. W przypadku mojego skromnego projektu jest to w porządku, jednakże duże projekty powinny precyzyjniej wskazywać konfiguracje terraform do wykonania.

Przechodzimy teraz do jednego z większych grzechów popełnionych tutaj – wykorzystałem terraform plan do umieszczenia planu w komentarzu do Pull Request. Powinienem jednak dodać flagę -out=output_plan, aby zapisać plan, a następnie wykonać terraform apply, który dokładnie użyje tego pliku. W moim przypadku tego nie zastosowałem, dlatego jeśli infrastruktura zmieni się między wykonaniem terraform plan a apply, wówczas zmieniona infrastruktura będzie inna niż zadeklarowana w Pull Request co jest dużym błędem.

Starałem się tak dzielić konfiguracje Terraform, aby nazwy folderów odpowiadały dostawcom usług. Zazwyczaj w pliku main.tf znajdowały się zasoby lub moduły odnoszące się do AWS. Ponadto, jeśli tworzyłem polityki, które zazwyczaj zajmują sporo linijek, umieszczałem je w odpowiednich folderach, takich jak consul.tf, vault.tf lub iam.tf.

Terraform Providers

Jeśli chodzi o dostawców usług, których użyłem podczas budowy IDE, są to:
aws – do budowy infrastruktury
vault – do tworzenia sekretów kv, polityk, roli i tokenów
consul – do tworzenia parametrów, polityk ACL i tokenów
random – do wygenerowania hasła dla bazy danych
null – do wyzwolenia funkcji lambdy po utworzeniu bazy danych
local – do uzupełniania szablonów skryptów
time – by mieć pewność, że warstwa następna stworzy się po zakończeniu tworzenia poprzedniej

Automatyczna konfiguracja bazy danych

Warto zwrócić uwagę na konfigurację bazy danych, o której dotychczas nie pisałem, ponieważ skupiłem się na narzedziach HashiCorp i chciałem uniknąć dodatkowego mieszania tematów. Samo stworzenie bazy danych nie było problemem. Skorzystałem z gotowego modułu AWS RDS. Dodatkowo za pomocą modułu random utworzyliśmy użytkownika admin i użytkownika aplikacji, których dane zapisaliśmy w Vault i wykorzystaliśmy do konfiguracji tego modułu. Większym wyzwaniem okazało się dostosowanie bazy danych do potrzeb naszej aplikacji.

Rozwiązałem ten problem poprzez stworzenie funkcji lambda za pomocą Terraform. Funkcja ta wykonuje kod Pythona, który za pomocą skryptu SQL konfiguruje bazę danych.

CREATE DATABASE IF NOT EXISTS ${db_name};
CREATE TABLE IF NOT EXISTS `${db_table_name}` (
  `id` bigint NOT NULL,
  `calories` int DEFAULT NULL,
  `comments` varchar(255) DEFAULT NULL,
  `date` date DEFAULT NULL,
  `distance` double DEFAULT NULL,
  `time` time DEFAULT NULL,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE USER IF NOT EXISTS '${db_workoutrecorder_username}'@'%' IDENTIFIED BY '<db_workoutrecorder_password>';
GRANT ALL PRIVILEGES ON ${db_name}.* TO '${db_workoutrecorder_username}'@'%';
CREATE TABLE IF NOT EXISTS hibernate_sequence (`next_val` BIGINT);
INSERT INTO hibernate_sequence VALUES (1);

Do powyższego skryptu wstrzykujemy zmienne używając zasobu local_file.

resource "local_file" "render_db_script" {
  content = templatefile(
    "${path.module}/templates/template_mysql_setup_script.sql",
    {
      db_name                     = local.db_name
      db_table_name               = local.db_table_name
      db_workoutrecorder_username = local.db_workoutrecorder_username
      db_workoutrecorder_password = "${random_password.db_workoutrecorder_password.result}"
    }
  )
  filename = "${path.module}/lambda_script/mysql_setup_script.sql"

  depends_on = [
    module.db
  ]
}

Kod python, który wykonuje skrypt SQL.

import pymysql
import boto3
import hvac
import json
from botocore.exceptions import ClientError

region          = '${region}'
sql_file        = 'mysql_setup_script.sql'
database_name   = '${db_name}'
db_endpoint     = '${db_endpoint}'
vault_endpoint  = '${vault_endpoitn}'

admin_secret_path           = '${db_admin_secret_path}'
workoutrecorder_secret_path = '${db_workoutrecorder_secret_path}'


def get_vault_token():
    session = boto3.session.Session()
    client = session.client(
        service_name='secretsmanager',
        region_name=region
    )

    try:
        get_secret_value_response = client.get_secret_value(
            SecretId='cicd-vault-${environment}-token'
        )
    except ClientError as e:
        raise e

    secret_string = get_secret_value_response['SecretString']
    secret_dict = json.loads(secret_string)
    
    return secret_dict['cicd-token']

def get_vault_secrets(vault_token, vault_endpoint, secret_path):
    client = hvac.Client(
        url=vault_endpoint, 
        token=vault_token
        )
    secrets = client.secrets.kv.v2.read_secret_version(
        path=secret_path, 
        mount_point='kv'
        )
    return secrets

def parse_sql(sql_content):
    sql_statements = sql_content.split(';')
    sql_statements = [stmt.strip() for stmt in sql_statements if stmt.strip()]
    return sql_statements

def render_sql_file_conntent():
    vault_token = get_vault_token()
    with open(sql_file, 'r') as file:
        sql_content = file.read()
    workoutrecorder_secrets = get_vault_secrets(
        vault_token, 
        vault_endpoint, 
        workoutrecorder_secret_path
    )
    updated_sql_content = sql_content.replace(
        '<db_workoutrecorder_password>', 
        workoutrecorder_secrets['data']['data']['password']
    )
    return updated_sql_content

def setup_db(event, context):
    vault_token = get_vault_token()
    admin_secrets = get_vault_secrets(vault_token, vault_endpoint, admin_secret_path)
    admin_username = admin_secrets['data']['data']['username']
    admin_password = admin_secrets['data']['data']['password']
    
    conn = pymysql.connect(host=db_endpoint, user=admin_username, passwd=admin_password, db=database_name, connect_timeout=5)
    stmts = parse_sql(render_sql_file_conntent())
    with conn.cursor() as cursor:
        for stmt in stmts:
            cursor.execute(stmt)
        conn.commit()

Pisząc w skrócie, skrypt pobiera token Vaulta z AWS Secret Manager, następnie odpytuje Vaulta o poświadczenia do bazy danych, które zostały wcześniej zapisane przez Terraform. Następnie, używając tych poświadczeń, wykonuje skrypt SQL. Może się wydawać, że ten skrypt jest nieco przekombinowany dla tego małego projektu, ale dla większych rozwiązań jest to zdecydowanie lepsze i bardziej elastyczne podejście. Dodatkowo, jest to również tańsze wyjście niż korzystne tylko z AWS Secret Manager.

Wszystkie zależności do wykonania tego skryptu umieściłem w tzw. lambda layers

module "lambda_layer_local" {
  source = "git::https://github.com/terraform-aws-modules/terraform-aws-lambda.git"

  create_layer = true

  layer_name               = "${local.lambda_function_name}-python-layer"
  description              = "Lambda layer for python"
  compatible_runtimes      = ["python3.9"]
  compatible_architectures = ["x86_64"]

  source_path = "${path.module}/lambda_script"
}

Na sam koniec pozostaje wywołanie lambdy z użyciem zasobu null_ressorces.

resource "null_resource" "invoke_lambda" {
  provisioner "local-exec" {
    command = "aws lambda invoke --function-name ${module.db_setup_lambda.lambda_function_name} --region ${var.region} response.json"
  }
  depends_on = [
    module.db_setup_lambda
] }

Policy as code

Przy tworzeniu IDE starałem się umieszczać wszystkie uprawnienia w kodzie Terraform, co umożliwia nam określenie w prosty sposób jakie polityki są przypisane do poszczególnych zasobów takich jak instancje EC2, funkcje lambda, tokeny czy agenci. Dzięki zastosowaniu podejścia Policy as Code, czyli polityk w postaci kodu możemy zdefiniować zbiór reguł i polityk bezpieczeństwa bezpośrednio w naszym kodzie infrastrukturalnym. Jest to nie tylko efektywne podejście do zarządzania uprawnieniami, ale także zapewnia spójność i powtarzalność w procesie wdrażania i zarządzania infrastrukturą. Korzystając z tego podejścia możemy łatwo śledzić i zarządzać politykami, a także automatyzować ich wdrożenie i egzekucję.

resource "aws_iam_role" "consul_server_role" {
  name = "${local.name}-role"

  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Action = "sts:AssumeRole"
        Effect = "Allow"
        Sid    = "RoleForEC2"
        Principal = {
          Service = "ec2.amazonaws.com"
        }
      },
    ]
  })
}

resource "aws_iam_policy_attachment" "ec2_policy_attachment" {
  name       = "ec2-read-only-policy-attachment"
  policy_arn = "arn:aws:iam::aws:policy/AmazonEC2ReadOnlyAccess"
  roles      = [aws_iam_role.consul_server_role.name]
}

resource "aws_iam_policy_attachment" "secrets_manager_policy_attachment" {
  name       = "secrets-manager-read-write-policy-attachment"
  policy_arn = "arn:aws:iam::aws:policy/SecretsManagerReadWrite"
  roles      = [aws_iam_role.consul_server_role.name]
}

resource "aws_iam_instance_profile" "consul_server_profile" {
  name = "${local.name}-profile"
  role = aws_iam_role.consul_server_role.name
}
resource "vault_policy" "workoutrecorder" {
  name = "workoutrecorder"

  policy = <<EOF

    path "kv/data/workoutrecorder/*" {
      capabilities = ["read"]
    }
  EOF
}

resource "consul_acl_token" "nomad_server" {
  description = "Token for Nomad Server"
  policies    = ["${consul_acl_policy.nomad_server.name}"]
}

resource "consul_acl_policy" "nomad_server" {
  name        = "nomad-server"
  description = "Policy for Nomad Server"
  rules       = <<-RULE
    agent_prefix "" {
      policy = "read"
    }

    node_prefix "" {
      policy = "read"
    }

    service_prefix "" {
      policy = "write"
    }

    acl = "write"
    RULE
}

W projektach dla branży finansowej takie uprawnienia często są zarządzane przez zespoły IAM, a dostęp do takich polityk jest zastrzeżony. Wiedza na temat tego, co dany zasób może wykonywać powinna być ściśle strzeżona i udostępniana przez zespoły IAM jedynie w uzasadnionych przypadkach. Jest to istotne dla zapewnienia bezpieczeństwa i integralności infrastruktury zwłaszcza w przypadku skomplikowanych i skalowalnych projektów.

Konfiguracja z użyciem Ansible

Przejdźmy teraz do drugiego głównego elementu naszego rozwiązania. Po stworzeniu odpowiedniej infrastruktury w naszym przypadku opartej głównie na instancjach EC2 konieczne jest ich skonfigurowanie do wykonywania konkretnych czynności.

Do konfiguracji hostów wykorzystywałem między innymi playbooki Ansible’a oraz skrypty bash. W ramach konfiguracji mieliśmy stworzyć 3 główne komponenty:

  • Serwer Vaulta ( niestety tylko jedna instancja, więc nie pełnoprawny klaster )
  • Klaster Consula – 3 instancje, prawidłowy minimalny klaster
  • Klaster Nomada – 3 instancje, prawidłowy miminalny klaster

W przypadku konfiguracji Vaulta i Consula pisząc playbooka Ansible’a wykorzystałem głównie Ansible tasks. Dopiero przy konfiguracji klastra Nomada w pełni skorzystałem z ról Ansible’a. Teraz, będąc bogatszym o to doświadczenie wiem, że zawsze lepiej jest od razu używać ról niż zadań, ale więcej na ten temat za chwilę.

Ansible inventory

Konfigurację Ansible’a stworzyłem w taki sposób, że playbooki mogą być uruchamiane z plikami inventory na dwa sposoby. Pierwszy sposób polega na uruchomieniu playbooka Ansible’a lokalnie podczas wywołania przez skrypt user-data.sh instancji EC2.

Drugi sposób polega na wykorzystaniu GitHub Actions pipeline oraz dynamicznego inventory w celu znalezienia odpowiednich hostów już po utworzeniu instancji.

To podejście umożliwia wykonywanie czynności serwisowych takich jak aktualizacja certyfikatów, konfiguracje, dodawanie funkcjonalności itd. Dużą zaletą jest to, że korzystamy z tego samego playbooka, więc mamy tylko jedno miejsce do ewentualnych zmian i nie musimy się martwić o szukanie adresów IP, ponieważ dynamiczne inventory znajdzie je za nas.

Dodatkowo możemy nieco ulepszyć nasz potok GitHuba, umożliwiając podawanie flagi -t (tags) podczas wykonywania playbooka. Dzięki temu możemy precyzyjnie egzekwować, które zadania zostaną wykonane z danego playbooka.

Ansible playbook

Tak jak wcześniej wspomniałem, wszystkie zadania Ansible’a powinny być zapisane w rolach, tak jak zostało to zrobione w tym repozytorium: https://github.com/red-devops/Nomad-cluster-ansible

Możemy tu zauważyć, że instancje Nomada zawsze będą wykonywały role prerequisites oraz consul, natomiast nomad-server i nomad-client są uwarunkowane zmiennymi. Jest to o wiele bardziej przejrzyste niż pisanie dziesiątek czy setek linii kodu i szukanie, które zadania mają się wykonać dla danego przypadku. Role nam to grupują w logiczne elementy.

Poza standardowymi zadaniami playbooka takimi jak tworzenie struktury folderów, tworzenie linuksowych użytkowników i nadawanie odpowiednich uprawnień często korzystałem z modułów template do wypełniania szablonowych skryptów, które następnie były wykonywane za pomocą modułów command.

Wydaje mi się, że podana ilość informacji na temat wykorzystania Ansible’a w tym projekcie jest wystarczająca. Dla zainteresowanych odsyłam do mojego dedykowanego wpisu:
https://red-devops.pl/ansible-skuteczne-i-proste-konfigurowanie-infrastruktury/

Skrypty

Jeśli chodzi o wykorzystane skrypty w tej infrastrukturze było ich dość sporo. Część z nich była podobna, a część zupełnie unikatowa ze względu na specyfikę danego zadania. Na przykład inicjalizacja serwera Vaulta wymagała nieco więcej pracy i weryfikacji niż inne operacje.

W skryptach starałem się wybierać jak najprostsze rozwiązania, ale nie zawsze udawało mi się to osiągnąć. Chciałbym jednak pokazać poniższy przykład, gdzie tworzyłem za pomocą skryptu politykę dla serwera Vault.

Pragnę zwrócić uwagę na to, że w tym przypadku CLI Vault było dostępne na hoście, ponieważ binarka serwera jest również binarką CLI. Dlatego zamiast tworzyć rozbudowane request HTTP za pomocą curl, aby utworzyć politykę skorzystałem z prostego i intuicyjnego interfejsu CLI.

W kolejnym przykładzie mamy host Consula, na którym nie jest zainstalowany CLI Vault ponieważ jest to zbędne z punktu widzenia codziennej pracy. Wykonujemy zatem pojedyncze zwykłe zapytanie curl do Vaulta w celu pobrania sekretu. Dzięki temu unikamy zaśmiecania instancji niepotrzebnym oprogramowaniem/ binarkami, które jest wymagane tylko raz.

Ogólnie rzecz biorąc nie jestem zbyt zadowolony ze skryptów użytych w tym projekcie, ale wzmianka na ten temat pod koniec.

Deployment oprogramowania

Ostatnią krótką rzeczą, na którą chciałbym zwrócić uwagę jest fakt, że po stworzeniu orkiestratora Nomad nasza platforma IDP jest gotowa, ale nadal nie działa na niej nic konkretnego. Potrzebujemy sposobu na wdrażanie oprogramowania. W moim rozwiązaniu ponownie wykorzystałem pipeline GitHuba, który korzystając z Nomad Pack wdraża oprogramowanie na klaster.

Link do repozytorim Nomad Pack: https://github.com/red-devops/Nomad-Packs

Innym ciekawym rozwiązaniem byłoby wykorzystanie Octopus Deploy, które posiada interesujące funkcje pod względem kontroli wypuszczonych wersji.

Greatest sins

Zanim przejdę do podsumowania może kogoś nurtuje pytanie, dlaczego zdecydowałem się na system Ubuntu zamiast wykonać wszystko w architekturze serverless lub całkowicie na kontenerach. Wybrałem te środowiska z dwóch powodów. Pierwszy jest taki, że miałem z nimi najwięcej doświadczenia. Drugi powód to fakt, że wiele projektów nadal wymaga operowania na systemach Linux. Choć zwiększyło to złożoność projektu chciałem, aby odzwierciedlał on przynajmniej w pewnym stopniu to, co spotykamy w większości przypadków. Oczywiście chciałbym, aby wszystkie projekty mogły korzystać z takich rozwiązań jak serverless lub konteneryzacji, ale jeszcze długo nie będzie to powszechne.

Na zakończenie tego dość obszernego wpisu chciałem poruszyć kwestię błędów, które popełniłem oraz omówić, jak powinno to wyglądać w prawdziwym projekcie. Ze względu na charakter hobbystyczny tego wpisu zdecydowałem się na wiele ułatwień, które nie sprawdziłyby się w komercyjnym projekcie. Jednakże są także pewne grzechy, które nawet pomimo hobbystycznego charakteru nie powinny wyglądać tak jak wyglądają. Poniżej kilka uwag.

Duże błędy

Brak wykorzystania opcji terraform plan -out – . Ta kwestia została już wcześniej omówiona.

Brak konsystencji w skryptach bash – podczas tworzenia skryptów nie zastosowałem powtarzalnego schematu co sprawiło, że skrypty różnią się między sobą, są mało czytelne, brak kodów wyjścia. Jest to jeden z podstawowych błędów.

Brak pełnoprawnego klastra Vaulta – tworzenie tylko jednej instancji Vaulta nie zapewnia odporności na awarie. Warto byłoby dodać co najmniej dwie kolejne instancje, aby stworzyć pełnoprawny klaster.

W niektórych playbookach Ansibla zastosowano zadania zamiast ról.

Niedopuszczalne dla komercyjnych projektów

Bardziej granularny sposób wdrażania infrastruktury – zamiast uruchamiać za każdym razem terraform apply na wszystkich katalogach przy każdej zmianie, powinniśmy uruchamiać tylko moduły Terraform odpowiedzialne za konkretne komponenty.

Certyfikaty i zabezpieczenie infrastruktury encrypt in transit – wydaje mi się, że ten punkt jest self explained. W żadnej z konfiguracji Vaulta, Consula czy Nomada nie ustawiliśmy certyfikatów do szyfrowania połączeń ponieważ nie było to konieczne dla pokazania funkcjonalności tych narzędzi.

Dodatkowe zabezpieczenia w skryptach przed operacjami jednorazowymi – posiadamy kilka skryptów, które są kluczowe dla konfiguracji. Powinny one zawierać na początku warunki sprawdzające, czy te klastry nie są już bootstraped. W naszym przypadku nie dodałem sprawdzeń, ponieważ wielokrotnie stawiałem te klastry i wygodniej było mi testować bez tych zabezpieczeń.

Wszystkie repozytoria prywatne, nie publiczne – wydaje mi się, że ten punkt także jest zrozumiały. W celach edukacyjnych repozytoria są publiczne. W projektach komercyjnych muszą być prywatne, co wymusza dodatkowe mechanizmy uwierzytelniania w skryptach by pobrać kod. Obecnie skrypty user-data.sh bezkarnie pobierają kod z GitHuba.

Można by było umieścić więcej zależności w Packerze i utworzyć dedykowany obraz IAM pod linuksa. Bardzo oszczędnie podszedłem do tworzenia obrazów. Wiele zależności moglibyśmy i powinniśmy umieścić w obrazach VM.

Podsumowanie

Podsumowując ten wpis przypomnę, że przedstawiłem w nim proces tworzenia Internal Development Platform przy użyciu różnych narzędzi i technologii, takich jak Terraform, Ansible oraz skrypty bashowe. Opisałem jak zbudowałem infrastrukturę, skonfigurowałem hosty oraz wdrożyłem kluczowe komponenty takie jak Vault, Consul i Nomad. Omówiłem również użyteczność GitHub Actions w procesie deploymentu i zarządzania infrastrukturą.

Wprowadzając IDP miałem na celu przyspieszenie developmentu, ustandaryzowanie rozwiązań oraz poprawę efektywności pracy zespołów deweloperskich. Przedstawiłem wiele decyzji projektowych jak również błędów i niedociągnięć, które warto byłoby poprawić w prawdziwym projekcie.

Choć projekt był hobbystyczny to wiele występujących w nim rozwiązań można by z powodzeniem zastosować w projektach komercyjnych, takich jak bardziej granularne zarządzanie infrastrukturą, uwzględnienie bezpieczeństwa infrastruktury, czy też pełniejsze wykorzystanie narzędzi takich jak Ansible Role. Pomimo wyboru systemu Ubuntu i braku skupienia na rozwiązaniach typu serverless czy konteneryzacji projekt ten miał na celu odzwierciedlenie typowych wyzwań, na jakie mogą trafić zespoły platform engineering w pracy.

Jeśli macie jakieś pytania lub potrzebujecie pomocy zapraszam do kontaktu na linkedin lub mailowo: jaroslaw.czerwinski.it@gmail.com. Do usłyszenia! 🙂

Comments are closed.