WebSocket за Application Load Balancer в Yandex Cloud
Как и обычно туториал я начну с небольшого кода приложения, которое мы будем использовать для наглядности. Код ни разу не production ready, а скорее минимально необходимый для демонстрации.
Код примера
И так код сервера на TypeScript с использованием Socket.io. На голых веб-сокетах мне лень писать.
import * as express from "express";
import {createServer} from "http";
import * as os from "os";
import {Server} from "socket.io";
const PORT = parseInt(process.env.PORT, 10);
const app = express();
const httpServer = createServer(app);
const io = new Server(httpServer);
const clients = {}
io.on("connect", (socket) => {
console.log(`connect ${socket.id}`);
clients[socket.id] = 0;
socket.on("ping", (cb) => {
clients[socket.id] += 1
console.log(`ping client ${socket.id}, msg number ${clients[socket.id]}`);
cb();
});
// Что наглядно убедиться, что мы подключились к разным бэкендам пошлем после коннекта hostname.
// Так как приложение будет бежать в контейнере, тут вы полчучите строку типа `9852d1c3d57c`.
socket.emit("severInfo", os.hostname())
socket.on("disconnect", () => {
console.log(`disconnect ${socket.id}`);
});
});
// Этот эндпоинт добавлен просто для того чтобы на него можно повесть health check балансера
app.get("/", (req, res) => {
res.status(200).send({"ping": "OK"});
})
httpServer.listen(PORT)
Базовый пример я расширил эндпоинтом, который пригодится нам для проверки живости приложения нашим балансером.
Еще одно дополнение — это отправка сообщения с hostname контейнера, чтобы запустив код клиента в несколько потоков, вы могли убедиться, что соединения обрабатываются разными серверами.
import {io} from "socket.io-client";
const {URL} = process.env
const socket = io(URL, {
transports: ["websocket"]
});
socket.on("connect", () => {
console.log(`connect ${socket.id}`);
});
socket.on("disconnect", () => {
console.log(`disconnect`);
});
socket.on("severInfo", (...args) => {
console.log(`severInfo`, args);
});
function ping() {
const start = Date.now();
socket.emit("ping", () => {
console.log(`pong (latency: ${Date.now() - start} ms)`);
});
setTimeout(ping, 2000 - Math.random() * 1000)
}
ping();
Код клиента я тоже постарался оставить минималистичным и лишь постарался добавить джиттер к отправке ping
, чтобы они не слали все разом и было чуть поинтереснее.
Дальше в репозитории вы сможете в корне найти docker-compose.yml
, который вам пригодится для локального запуска и отладки примера, если вы захотите с ним поиграть и внести какие-то изменения.
Создание инфраструктуры
Можно было бы наделать много красивых скриншотов, но они бы устарели, когда UI перерисуют. Со скринов не удобно копировать инфу, сложно понять, что важно, а где остались дефолтные значения. Поэтому я решил не заморачиваться этим, а сразу написать пример Terraform рецепта для развертывания. Ссылка на код будет ниже, а пока по шагам разберем как и зачем мы создаем ресурсы.
Сеть
Балансировать трафик мы будем сразу на 3 зоны присутствия. Поэтому подготовим сеть и по подсети в каждой AZ.
resource "yandex_vpc_network" "ws" {
name = "ws"
}
resource "yandex_vpc_subnet" "ws-subnet-a" {
name = "ws-subnet-a"
zone = "ru-central1-a"
network_id = yandex_vpc_network.ws.id
v4_cidr_blocks = ["10.240.1.0/24"]
}
resource "yandex_vpc_subnet" "ws-subnet-b" {
name = "ws-subnet-b"
zone = "ru-central1-b"
network_id = yandex_vpc_network.ws.id
v4_cidr_blocks = ["10.240.2.0/24"]
}
resource "yandex_vpc_subnet" "ws-subnet-c" {
name = "ws-subnet-c"
zone = "ru-central1-c"
network_id = yandex_vpc_network.ws.id
v4_cidr_blocks = ["10.240.3.0/24"]
}
Сервисный аккаунт
Этот аккаунт понадобится нам, для того чтобы передать его в Instance group, а она при помощи него могла управлять пересозданием VM.
resource "yandex_iam_service_account" "ig_sa" {
name = "ig-sa"
description = "service account to manage ig"
}
resource "yandex_resourcemanager_folder_iam_binding" "sabind" {
folder_id = "${var.folder_id}"
role = "editor"
members = [
"serviceAccount:${yandex_iam_service_account.ig_sa.id}",
]
}
Instance group
Зачем? Ну теоретически, можно создать все виртуальные машины. Но тогда нам бы пришлось отдельно создавать ресурс целевой группы, руками наполнять его. А так потом было бы сложнее обновлять эти машины, так как пришлось бы делать это на каждой по очереди.
Перед заданием IG определим два источника данных: образContainer Optimized Image из Маркетплейса и шаблон конфига cloud init’а.
data "yandex_compute_image" "container-optimized-image" {
family = "container-optimized-image"
folder_id = "standard-images"
}
data "template_file" "cloud_init" {
template = "${file("cloud-init.tpl.yaml")}"
vars = {
ssh_key = "${file(var.public_key_path)}"
}
}
В instance group’е много параметров, значения которых можно посмотреть в документации, я же хотел обратить внимание на следующие:
resource "yandex_compute_instance_group" "ws-ig" {
name = "ig-ws"
service_account_id = yandex_iam_service_account.ig_sa.id
instance_template {
boot_disk {
mode = "READ_WRITE"
initialize_params {
image_id = data.yandex_compute_image.container-optimized-image.id
...
}
}
network_interface {
subnet_ids = [
yandex_vpc_subnet.ws-subnet-a.id,
yandex_vpc_subnet.ws-subnet-b.id,
yandex_vpc_subnet.ws-subnet-c.id,
]
}
metadata = {
docker-compose = file("docker-compose.yaml")
user-data = "${data.template_file.cloud_init.rendered}"
serial-port-enable = 1
}
}
...
allocation_policy {
zones = ["ru-central1-a", "ru-central1-b", "ru-central1-c"]
}
application_load_balancer {
target_group_name = "ws-tg"
}
}
И так по порядку:
- на строке
3
передаем service account, о котором я писал выше ; - на
15-17
строках указываем, что будем использовать подсети во всех AZ; - передаем содержимое файла
docker-compose.yaml
в ключ метаданных ВМ(с.23
). Агент стоящий на машинах сможет его вычитать и на основе все описанные в файле контейнеры. В ключuser-data
передается отрендеренный шаблон для cloud init, куда подставляется публичный ключ, чтобы у вас была возможность зайти на созданные ВМ. - отдельно указываем на
32
строке, что IG должна разворачивать инстансы во всех трех доступных AZ. - ну и последнее, но очень важное в нашем примере, указать, что IG должна автоматически все создаваемые инстансы добавить в целевую группу L7-балансировщика (с.
36
).
Application Load Balancer
Теперь можем перейти к ресурсам непосредственно относящимся к ALB. И начнем с описания группы бэкендов.
resource "yandex_alb_backend_group" "ws-backend-group" {
name = "ws-backend-group"
http_backend {
name = "ws-http-backend"
weight = 1
port = 8080
target_group_ids = [yandex_compute_instance_group.ws-ig.application_load_balancer.0.target_group_id]
load_balancing_config {
panic_threshold = 50
}
healthcheck {
timeout = "1s"
interval = "1s"
healthcheck_port = 8080
http_healthcheck {
path = "/"
}
}
}
}
Как вы могли заметить мы указываем id целевой группы, которую нам автоматически предсоздала IG.
Также обратите внимание на настройки проверки живости наших машин за балансером. Тут мы указываем порт и путь эндпоинта, которы доопределяли в коде сервера специально для этих целей.
Еще я думал, что для нормального балансирования веб-сокетов необходимо настроить режим балансировки в значение Maglev hashing, так как только он по идее обеспечивает консистентное детерминированное распределение запросов по бэкендам, но оказалось что, для session affinity не надо ничего настраивать: WebSocket после апгрейда забирает себе соединение (или h2 стрим) и общение идёт с бекендом выбранным с самого начала.
Теперь надо описать роутер.
resource "yandex_alb_http_router" "ws-router" {
name = "ws-http-router"
}
И виртуальные хосты входящие в него.
resource "yandex_alb_virtual_host" "ws-virtual-host" {
name = "ws-virtual-host"
http_router_id = yandex_alb_http_router.ws-router.id
route {
name = "socketio"
http_route {
http_route_action {
backend_group_id = yandex_alb_backend_group.ws-backend-group.id
timeout = "3s"
upgrade_types = ["websocket"]
}
}
}
}
Как видно они ссылаются и на роутер, и на группы бэкендов. В хостах как раз и описываются все правила роутинга. Но так как у нас довольно простой пример, все что мы укажем тут — возможность апгрейда соединения до websocket.
Вот почти и всё. Нам осталось только создать сам балансер, передав в него параметры зон развертывания, роутера и порта на котором слушать трафик, чтобы передавать в этот роутер.
resource "yandex_alb_load_balancer" "ws-balancer" {
name = "ws-alb"
network_id = yandex_vpc_network.ws.id
allocation_policy {
location {
zone_id = "ru-central1-a"
subnet_id = yandex_vpc_subnet.ws-subnet-a.id
}
location {
zone_id = "ru-central1-b"
subnet_id = yandex_vpc_subnet.ws-subnet-b.id
}
location {
zone_id = "ru-central1-c"
subnet_id = yandex_vpc_subnet.ws-subnet-c.id
}
}
listener {
name = "socket-io"
endpoint {
address {
external_ipv4_address {
}
}
ports = [8080]
}
http {
handler {
http_router_id = yandex_alb_http_router.ws-router.id
}
}
}
}
Готово. Теперь можно выполнить команду terraform apply
и подождать пока все ресурсы создадутся. Это может занять минуть 5–10.
Полный код примера на GitHub.