Перейти к основному содержимому

WebSocket за Application Load Balancer в Yandex Cloud

· 7 мин. чтения

Как и обычно туториал я начну с небольшого кода приложения, которое мы будем использовать для наглядности. Код ни разу не 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 контейнера, чтобы запустив код клиента в несколько потоков, вы могли убедиться, что соединения обрабатываются разными серверами.

client.ts
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.