Конфигурирование кластерных систем с помощью Sparky и Rakulang
В общем случае конфигурирование распределенных и кластерных систем — задача непростая. В общем случае, когды вы не используете системы типа кубернетес и вам нужен инструмент для обновления и/или конфигурирования сложной распределенной системы — Sparky может стать хорошим решеним, предоставляя ряд готовых примитивов, из которых, как из конструктора можно строить сложные конфигурационные сценарии. В этой статье я опишу несколько примеров, не претендующих на исчерпывающее раскрытие данной темы, но тем не менее показвающие возможности фреймворка.
Стэк
В качестве инструмента будем использовать Sparky, развернутый в кластерном режиме, сценарии конфигурирования будем писать на Rakulang — современном и мощном языке.
Базовая конфигурация
Для того что бы все работало необходимо сначала установить Sparky агенты на все настаиваемые ноды кластера и обеспечить сетевую связанность на уровне http/https протоколов

Sparky агенты (sparrow агент на диаграмме) принимают запросы извне и выполняют сценарии конфигурирования на нодах. При этом возможны различные паттерны — окрестратор или хореография, в зависимости от ваших целей и задач.
Хотя в целом процесc бутстрапа нод Sparky кластера — не сложный, для экономии времени — я не буду описывать его здесь, возможно, если будет интерес у читателя, напишу об этом отдельный пост
Целевая система
В качестве упрощенного примера системы допустим, что нам необходимо развернуть собранный бинарный файл web сервиса, запустить его как systemd службу и сделать простейших smoke тест запрос типа GET 127.0.0.1 на проверку что сервис запущен успешно. Повторюсь — это просто пример, реальные системы будут гораздо сложнее, но это не отменяет всю логику, связанную с обновлением кластера, описанную далее
Вот как будет выглядить сценарий разворачинвания целевой системы, если писать его для Sparky
#!rakulang
user "app";
directory "/var/apps/app/bin";
directory-delete "/var/apps/app/tmp";
directory-create "/var/apps/app/tmp";
bash "wget {config()} -O app.tar.gz", %(
:cwd,
:description,
);
bash "tar -xzf app.tar.gz && cp app ../bin/", %(
:cwd,
);
# install systemd script for some service and reload systemd daemon
systemd-service "long-dream", %(
:user,
:workdir,
:command
);
# start service
service-start "long-dream";
# GET 127.0.0.1 test
bash "curl -fsD - http://127.0.0.1", %(
:description,
);
Кейсы
В качестве примеров расмотрим ряд типовых кейсов. Я не претендую здесь на то что попаду на сто процентов под все реальные бизнес кейсы, но из моего опыта все более или менее можно свести к небольшому набору типовых сценариев.
Для всех кейсов будем применять паттерн орекстратор, когда вся логика обхода нод кластера определяется в сценарии основой оркестрирующей ноды, где решаются вопросы с повторным выполненим сценариев на нодах, порядком обхода нод, финальными отчеатми так далее. Конечные ноды просто конфигурируют сами себя по запросу от оркестратора, при этом код сценария остается тот же, просто используется ветвление по признаку ноды (смотрите далее как )

Итак, рассмотрим некоторые кейсы
Обновление всех нод, с повторным обновлением упавших (retry)
Так как мы используем Sparky, все сводится к тому что мы используем уже готовый job API для взаимодействия с нодами и запуска задач через установленные агенты:
if tags() eq "main" {
# spawns nodes jobs
use Sparky::JobApi;
my @jobs;
for config()<> -> $host {
my $j = Sparky::JobApi.new: :api("https://{$host}:4000");
$j.queue({
description => "node job",
tags => %(
:stage,
),
});
}
@jobs.push($j);
say "queue node job, ",$j.info.raku;
# none blocking asynchronous wait till
# all jobs are finished
my $supply = supply {
while True {
for @jobs -> $j {
my $status = $j.status;
emit %( job => job, status => $status );
done if $status eq "FAIL" or $status eq "OK";
# sleep so not overload nodes and orchestrator
# with http requests
sleep(5)
}
}
}
# blocks here till all jobs are finished or timeouted
$supply.tap( -> $v {
say "job finished ", $v..info;
if $v ne "OK" { # retry job if fails
$v.queue({
description => "retry node job",
tags => %(
:stage,
),
});
}
});
} elsif tags() eq "child" {
# node job here
user "app";
directory "/var/apps/app/bin";
directory-delete "/var/apps/app/tmp";
directory-create "/var/apps/app/tmp";
bash "wget {config()} -O app.tar.gz", %(
:cwd,
:description,
);
bash "tar -xzf app.tar.gz && cp app ../bin/", %(
:cwd,
:description,
);
# install systemd script for some service and reload systemd daemon
systemd-service "long-dream", %(
:user,
:workdir,
:command
);
# start service
service-start "long-dream";
# GET 127.0.0.1 test
bash "curl -fsD - http://127.0.0.1", %(
:description,
);
}
Комментарии к сценарию
вся логика построена на тэге stage, изначально, когда сценарий запускается со стартового нода оркестратора stage=main, означающий что нужно отрабатывать логику самого оркестратора (со строки 1 по 53), при запуске сценария на удаленной ноде тэг выставляется в stage=child, что приводит к тому что на самих нодах запускается логика конфигурирования ноды (строки 53 — 88)
в строке 10 создается обьект для запуска джобы на ноде, параметр api — крайне важен — он указывает на то, что джоба будет запущена на агенте, запущенном на удаленном хосте-ноде, используется протокол https, можно также использовать http
в строках с 25 по 43 — ждем пока все запущенные на нода джобы отработают либо завешатся по таймаутам, данный код — неблокирующий и выполняется в параллельном треде
в строке 47 собираем окончательно статусы всех дожобов и для тех джобов, которые завершились с ошибками — перезапускаем их — строка 50
массив нод задается через файл config.raku который нужно поместить в корень проекта на ноде-оркестраторе, с которой все запускается, вот примерный вид:
%(
distro => "http://nexus.local/apps/app-0.0.2.tar.gz",
distro_previous => "http://nexus.local/apps/app-0.0.1.tar.gz",
hosts => [
"192.168.0.1",
"192.168.0.2",
"192.168.0.3",
"192.168.0.4",
# etc
],
)
на данный момент реализована примитивная логика одиночного retry, но не сложно добработать сценарий, что бы например запускать retry несколько раз с задержкой, для этого нужно использовать рекурсию на уровне самих джобов (переменная count — строка 51, 53, 55 — передается в новый джоб как атрибут-тэг — используется для выхода их рекурсии — при достижение значения 2)
if tags() eq "main" {
# spawns node jobs
use Sparky::JobApi;
my @jobs;
for tags() ?? [ tags()] !! config()<> -> $host {
my $j = Sparky::JobApi.new: :api("https://{$host}:4000");
$j.queue({
description => "host job",
tags => %(
:stage,
),
});
}
@jobs.push($j);
say "queue host job, ",$j.info.raku;
# none blocking asynchronous wait till
# all jobs are finished
my $supply = supply {
while True {
for @jobs -> $j {
my $status = $j.status;
emit %( job => job, status => $status );
done if $status eq "FAIL" or $status eq "OK";
# sleep so not overload nodes and orchestrator
# with http requests
sleep(5)
}
}
}
# blocks here till all jobs are finished or timeouted
$supply.tap( -> $v {
say "job finished ", $v..info;
my $count = tags() ?? tags().Int !! 0;
$count++;
if $v ne "OK" && $count <= 2 { # retry job if fails up 2 times
my $j = Sparky::JobApi.new; # recursive run on the same node (orchestrator)
$j.queue({
description => "host job",
tags => %(
:stage,
:$count,
),
});
}
});
} elsif tags() eq "child" {
# ... node job here, code is the same
}
Rolling updates
Ролинг апдейты подразумевают последовательное обновление ноды за нодой, и при ошибках обновления — откат к старой версии и прерывание деплоймента. Данную логику не сложно релизовать слегка модифицировав изначальный сценарий:
if tags() eq "main" {
# spawns nodes jobs
use Sparky::JobApi;
my @jobs;
for config()<> -> $host {
my $j = Sparky::JobApi.new: :api("https://{$host}:4000");
$j.queue({
description => "my spawned job",
tags => %(
:stage,
),
});
@jobs.push($j);
say "queue spawned job, ",$j.info.raku;
# none blocking asynchronous wait till
# job is finished
my $supply = supply {
while True {
for @jobs -> $j {
my $status = $j.status;
emit %( job => job, status => $status );
done if $status eq "FAIL" or $status eq "OK";
# sleep so not overload nodes and orchestrator
# with http requests
sleep(5)
}
}
}
# blocks here till all job is finished or timeouted
$supply.tap( -> $v {
say "job finished ", $v..info;
if $v ne "OK" { # rollback to previous version if job fails
$j.queue({
description => "my spawned job",
tags => %(
:stage,
:distro(config()),
),
});
# обработать статус отката упавшей ноды
# код - здесь пропущен для кратости
# и выйти
last;
}
});
} # next host
} elsif tags() eq "child" {
# read version from job tag parameter or from default config
my $distro = tags() || config();
# ... node job here, code is the same
}
Canary релизы
Канареечные релизы (обновления части нод и перевод части трафика на них, с откатом на старую версию в случае проблем) реализуются на базе двух предыдущих сценариев (код будет очень похожим) с той лишь разницей что будет обновлятся только част нод из массива config ()
Blue / Gree деплойменты
Аналогичным образом реализуется стратегия поддержки двух независимых кластеров и обновлении одного кластера целиком с последующим переключеним трафика на балансировщике, нужно всего-лишь расширить структуру config ()
%(
distro => "http://nexus.local/apps/app-0.0.2.tar.gz",
distro_previous => "http://nexus.local/apps/app-0.0.1.tar.gz",
hosts => [
%( :host<192.168.0.1>, :blue ),
%( :host<192.168.0.2>, :green ),
%( :host<192.168.0.3>, :blue ),
%( :host<192.168.0.4>, :green ),
# etc
],
)
Обход нод в определенном порядке
Обход нод при обновлении в определенном порядке реализуется по тому же принципу что и в blue/green деплоймент сценарии, достаточно просто ввести любые дополнительные атрибурты и учитывать их при обходе, например:
config.raku:
%(
distro => "http://nexus.local/apps/app-0.0.2.tar.gz",
distro_previous => "http://nexus.local/apps/app-0.0.1.tar.gz",
hosts => [
%( :host<192.168.0.1>, :database ),
%( :host<192.168.0.2>, :database ),
%( :host<192.168.0.3>, :backend ),
%( :host<192.168.0.4>, :backend ),
# etc
],
)
Далее в сценарии — простейший grep и/или sort:
# обходим базы данных вначале ...
for config()<>.sort{ $^a <=> $^b } -> $host {
}
Заключение
Как я надеюсь, вы увидели в этой статье — Sparky — черезвычайно гибкий и эффективный фреймворк для конфигурирования сложных распределенных систем, буду признателен за вопросы и комментарии.
Нераскрытые темы
Что я еще не раскрыл. Дайте знать, если заинтересовало — возможно опишу в следующих обзорах:
Рекурсивные джобы (частично раскрыто в этой статье)
Готовый DSL для написания Sparky сценариев
ООП интерфейс к Sparky сценариям
Web UI для запуска джобов с настраиваемыми параметрами и отчетами
ACL для доступа к запуску джобов через UI
Передача файлов и состояний/данных между нодами
Использование шаблонизаторов для деплоймента конфигурационных файлов
Аутентефикация с поддержкой локальных пользовтелей или протокола OAUTH2
Динамический bootstrap (установка агентов) новых нод кластера
Расширение DSL на стандартных языках разработки (aka Sparrow плагины), включая Bash, Python, Perl, Ruby, Golang
Создание репозиториев собственных Sparky плагинов