skip to content
Euller Peixoto

Full Stack Developer specjalizujący się w architekturach systemów i usług o wysokiej dostępności.

Skalowanie instancji Phoenix za pomocą Dockera i Libcluster

Skalowanie instancji Phoenix za pomocą Dockera i Libcluster

Wymagania wstępne

Upewnij się, że masz zainstalowane Erlang i Elixir na swoim komputerze. W tym poradniku używam następujących wersji:

Erlang/OTP 26 [erts-14.2.5.2] [source] [64-bit] [smp:8:8] [ds:8:8:10] [async-threads:1] [jit:ns]
Elixir 1.17.3 (skompilowane z Erlang/OTP 26)

Będziesz także potrzebować uruchomionego Dockera. Używam Docker Compose, więc upewnij się, że masz go również zainstalowanego.

Konfigurowanie środowiska

Na początek utworzymy dwie oddzielne instancje Phoenix:

mix phx.new first_instance --no-ecto
mix phx.new second_instance --no-ecto

Zanim uruchomimy instancje, musimy skonfigurować kilka plików.

Utwórz docker-compose.yml:

W katalogu głównym projektu utwórz plik docker-compose.yml:

version: "3.8"
services:
app:
container_name: ${APP_NAME}
build:
context: .
volumes:
- .:/app
environment:
- APP_NAME=${APP_NAME}
- APP_PORT=${APP_PORT}
- APP_PUBSUB=${APP_PUBSUB}
- APP_CLUSTER_COOKIE=${APP_CLUSTER_COOKIE}
networks:
- example_network
restart: "on-failure"
ports:
- "${APP_PORT}:${APP_PORT}"
networks:
example_network:
external: true

Upewnij się, że nazwa sieci jest taka sama dla wszystkich instancji.

Utwórz Dockerfile:

W katalogu głównym projektu utwórz plik Dockerfile:

FROM elixir:1.17.0-otp-27-alpine AS build
RUN apk add --no-cache build-base git
RUN apk add inotify-tools
WORKDIR /app
RUN mix local.hex --force && \
mix local.rebar --force
COPY . .
COPY entrypoint-dev.sh /usr/local/bin/entrypoint-dev.sh
RUN chmod +x /usr/local/bin/entrypoint-dev.sh
CMD ["/usr/local/bin/entrypoint-dev.sh"]

Utwórz entrypoint-dev.sh:

W katalogu głównym projektu utwórz plik entrypoint-dev.sh:

#!/bin/sh
cd /app
mix deps.get
mix deps.compile
PORT=$APP_PORT elixir --sname app@$APP_NAME --cookie $APP_CLUSTER_COOKIE -S mix phx.server

Utwórz .env:

W katalogu głównym projektu utwórz plik .env:

APP_NAME=przyklad_jeden
APP_PORT=4000
# Upewnij się, że te dwa pola są takie same dla wszystkich instancji
APP_PUBSUB=PrzykladApp.PubSub
APP_CLUSTER_COOKIE=przyklad_cookie

Edytuj plik dev.exs:

Aby umożliwić dostęp do obu instancji przez localhost, przejdź do pliku config/dev.exs i zmodyfikuj wiersz 12:

http: [ip: {127, 0, 0, 1}, port: 4000],
http: [ip: {0, 0, 0, 0}, port: System.get_env("APP_PORT")],

Uruchamianie instancji

Otwórz terminal w katalogu głównym projektu i utwórz sieć, która połączy kontenery:

Terminal window
docker network create example_network

Następnie uruchom kontenery za pomocą poniższego polecenia:

Terminal window
docker compose up -d --build

Teraz aplikacje powinny działać na portach określonych w pliku .env. W moim przypadku są dostępne pod:

Konfigurowanie Libcluster

Teraz skonfigurujemy klaster dla instancji.

Zainstaluj libcluster:

Na początek musimy zainstalować libcluster jako zależność. Aby to zrobić, dodaj go do pliku mix.exs:

defp deps do
[
...
{:libcluster, "~> 3.3"}
]
end

Konfiguracja topologii

Następnie skonfigurujemy topologię. W tym przykładzie użyjemy strategii Gossip, która automatycznie odkrywa węzły w sieci.

W pliku config/config.exs dodaj następujące linie na końcu, przed import_config:

config :libcluster,
topologies: [
przyklad_gossip: [
strategy: Elixir.Cluster.Strategy.Gossip,
config: [
port: 45892,
if_addr: "0.0.0.0",
multicast_addr: "255.255.255.255",
broadcast_only: true
]
]
]

Jeśli chcesz mieć większą kontrolę nad węzłami, możesz również skorzystać z strategii EPMD. Upewnij się, że sprawdziłeś dokumentację libcluster w poszukiwaniu innych dostępnych opcji.

Inicjalizacja Libcluster

Teraz musimy aktywować Libcluster podczas uruchamiania aplikacji.

Dodaj poniższe linie do pliku lib/*_instance/application.ex:

def start(_type, _args) do
topologies = Application.get_env(:libcluster, :topologies) || []
children = [
FirstInstanceWeb.Telemetry,
{DNSCluster, query: Application.get_env(:first_instance, :dns_cluster_query) || :ignore},
{Phoenix.PubSub, name: FirstInstance.PubSub},
{Cluster.Supervisor, [topologies, [name: FirstInstance.ClusterSupervisor]]},
...

Sprawdzanie połączenia

Teraz zbudujemy ponownie instancje i sprawdzimy dzienniki. Uruchom poniższe polecenia w obu katalogach:

Terminal window
docker compose up -d --build
docker logs --follow nazwa_instancji

Kiedy obie instancje działają, w jednej z nich (zwykle tej, która uruchomiła się pierwsza) powinno pojawić się coś takiego:

Terminal window
[info] [libcluster:przyklad_gossip] connected to :app@przyklad_dwa

To oznacza, że nasze instancje zostały pomyślnie połączone i są gotowe do interakcji za pomocą PubSub!

Konfigurowanie PubSub

Teraz czas skonfigurować PubSub, aby umożliwić interakcję między instancjami.

W tym przewodniku rozdzielimy zadania na dwie instancje:

  • Instancja 1 będzie wysyłać wiadomości.
  • Instancja 2 będzie je odbierać.

Najpierw jednak musimy upewnić się, że obie instancje używają tego samego PubSub.

Aby to zrobić, otwórz plik config/config.exs w obu instancjach i zmodyfikuj wiersz 22 (lub miejsce, gdzie jest konfiguracja endpointu):

config :first_instance, FirstInstanceWeb.Endpoint,
...
pubsub_server: FirstInstance.PubSub,
pubsub_server: System.get_env("APP_PUBSUB") |> String.to_atom()
...

Następnie w pliku lib/*_instance/application.ex zaktualizuj funkcję start:

def start(_type, _args) do
topologies = Application.get_env(:libcluster, :topologies) || []
children = [
...
{DNSCluster, query: Application.get_env(:first_instance, :dns_cluster_query) || :ignore},
{Phoenix.PubSub, name: FirstInstance.PubSub},
{Phoenix.PubSub, name: System.get_env("APP_PUBSUB") |> String.to_atom()},
...

Ustawianie Nadawcy

Ten krok jest dość prosty; wszystko, co musimy zrobić, to wywołać funkcję PubSub.broadcast_from/3 z jakiegoś miejsca. Użyjemy do tego Phoenix LiveView.

Utwórz następujące pliki:

lib/first_instance_web/live/sender_live/index.ex:

defmodule FirstInstanceWeb.SenderLive.Index do
use FirstInstanceWeb, :live_view
alias Phoenix.PubSub
def mount(_params, _session, socket) do
{:ok, socket}
end
@impl true
def handle_event("send_signal", _params, socket) do
pubsub_identifier = System.get_env("APP_PUBSUB") |> String.to_atom()
pid = self()
PubSub.broadcast_from(pubsub_identifier, pid, "topic", %{message: "Wiadomość z Nadawcy"})
{:noreply, put_flash(socket, :info, "Sygnal wysłany")}
end
end

lib/first_instance_web/live/sender_live/index.html.heex:

<button phx-click="send_signal" class="bg-gray-200 px-4 py-2">Wyślij sygnał</button>

Teraz dodaj nową trasę na żywo w zakresie przeglądarki w lib/first_instance_web/router.ex:

scope "/", FirstInstanceWeb do
pipe_through :browser
get "/", PageController, :home
live "/sender", SenderLive.Index, :index
end

Odwiedź localhost:4000/sender i przetestuj kliknięcie przycisku. Jeśli wszystko jest skonfigurowane poprawnie, powinieneś zobaczyć komunikat o sukcesie.

Konfiguracja Odbiorcy

W tym samouczku zainicjalizuję subskrypcję PubSub wraz z aplikacją, aby uniknąć tworzenia innej strony. Dzięki PubSub możesz wysyłać wiadomości na każdą stronę, ale tylko użytkownicy połączeni z tą stroną otrzymają wiadomości. Inicjalizując PubSub z aplikacją, pozwala mi to używać go jak aplikacji do przesyłania wiadomości (np. RabbitMQ), umożliwiając interakcje z wydarzeniami na odbiorniku, niezależnie od tego, czy są połączeni użytkownicy.

Utwórz plik lib/second_instance/listener.ex z następującą zawartością:

defmodule SecondInstance.PubSubListener do
use GenServer
def start_link(_) do
GenServer.start_link(__MODULE__, %{}, name: __MODULE__)
end
@impl true
def init(state) do
pubsub_identifier = System.get_env("APP_PUBSUB") |> String.to_atom()
Phoenix.PubSub.subscribe(pubsub_identifier, "topic")
{:ok, state}
end
@impl true
def handle_info(%{message: message}, state) do
IO.puts("Otrzymano wiadomość: #{message}")
{:noreply, state}
end
@impl true
def handle_info(_, state) do
{:noreply, state}
end
end

Na koniec, w application.ex, dodaj odbiornik do listy dzieci:

def start(_type, _args) do
...
children = [
...
{Cluster.Supervisor, [topologies, [name: SecondInstance.ClusterSupervisor]]},
SecondInstance.PubSubListener,
...

Test końcowy

Świetnie! Teraz, gdy zakończyłeś konfigurację, system powinien działać zgodnie z oczekiwaniami:

  • Otwórz logi Dockera dla odbiornika (jak w moim przypadku example_two) za pomocą następującego polecenia:
Terminal window
docker logs --follow example_two
  • Przejdź do strony internetowej pierwszej instancji (np. example_one) w przeglądarce: localhost:4000/sender

  • Kliknij przycisk, aby wysłać sygnał. Za każdym razem, gdy klikniesz przycisk, powinieneś zobaczyć następujący komunikat w logach odbiornika:

Otrzymano wiadomość: Witaj z Nadawcy

To oznacza, że PubSub działa poprawnie, a instancje komunikują się za pośrednictwem LibCluster i Phoenix PubSub. Teraz twoje dwie instancje są gotowe do interakcji ze sobą, wysyłając i odbierając wiadomości w klastrze.

Możesz używać tej metody do różnych zastosowań, takich jak:

  • Powiadomienia w czasie rzeczywistym: Wysyłaj powiadomienia między różnymi węzłami w klastrze, na przykład informując użytkowników z różnych instancji o nowych wiadomościach, zdarzeniach systemowych lub aktualizacjach na żywo.

  • Rozproszone przetwarzanie zadań: Dziel zadania między wieloma węzłami do równoległego przetwarzania. Na przykład każda instancja może obsługiwać różne części dużego zbioru danych lub obciążenia, a węzły mogą koordynować za pomocą wiadomości.

  • Aplikacje czatu w wielu instancjach: Zbuduj system czatu, w którym użytkownicy połączeni z różnymi węzłami mogą nadal wymieniać wiadomości w czasie rzeczywistym, korzystając z PubSub do komunikacji między instancjami.

  • Aktualizacje na żywo w pulpitach nawigacyjnych: Jeśli masz wiele serwerów obsługujących różne strumienie danych, możesz wysyłać aktualizacje do pulpitów użytkowników w czasie rzeczywistym, nawet jeśli użytkownik jest połączony z innym węzłem niż ten, z którego pochodziły dane.

Używam tej konfiguracji z ponad 30 instancjami, z których żadna nie jest świadoma istnienia innych, ale wszystkie otrzymują zdarzenia z „centralnej” instancji.

Śmiało eksploruj swoje własne pomysły i dostosuj tę konfigurację do potrzeb swojego projektu!