Zero downtime bez Kubernetes - jak BEAM robi hot code reload

Piątek, 16:47. Znalazłeś buga w produkcji. Krytyczny - użytkownicy nie mogą zapłacić. Fix jest prosty, trzy linie kodu. Ale wdrożenie oznacza:

  • build (5 min)
  • deploy (3 min)
  • restart serwera (30 sekund niedostępności)
  • smoke testy (5 min)

I to pod warunkiem, że wszystko pójdzie gładko. W między czasie 47 użytkowników dostanie błąd 502. Trzech z nich otworzy ticket w support. Jeden napisze na Twittera.

A co by było, gdybyś mógł zmienić kod bez restartu? Użytkownicy kontynuują pracę, a Ty podmieniasz logikę pod nimi.

To nie jest magia. To jest hot code reload w BEAM.


Co to jest hot code reload

Większość runtime'ów wymaga restartu procesu, żeby załadować nowy kod. Python, Ruby, Node.js, Java, Go - wszyscy musisz ubić proces i postawić go od nowa.

BEAM (maszyna wirtualna Erlanga/Elixira) jest inna. Może ładować nowy kod w locie, podczas gdy stare procesy nadal działają.

Jak to działa

BEAM trzyma w pamięci dwie wersje kodu jednocześnie:

┌─────────────────────────────────────┐
│            Pamięć BEAM              │
├─────────────────┬───────────────────┤
│  Moduł v1       │  Moduł v2         │
│  (stary)        │  (nowy)           │
├─────────────────┼───────────────────┤
│  Procesy już    │  Nowe wywołania   │
│  działające     │  idą tutaj        │
└─────────────────┴───────────────────┘

Stare procesy kończą pracę na starej wersji. Nowe wywołania trafiają do nowej wersji. Gdy ostatni proces zakończy się na starej wersji - garbage collector ją usuwa.

Zero restartu. Zero zerwanych połączeń.


Przykład z życia: naprawa buga w LiveView

Masz formularz płatności z błędem w walidacji:

# payment_live.ex - WERSJA Z BUGIEM
def handle_event("validate", %{"amount" => amount}, socket) do
  # BUG: amount przychodzi jako string, porównanie z integerem zawsze false
  if amount > 100 do
    {:noreply, assign(socket, fee: 10)}
  else
    {:noreply, assign(socket, fee: 0)}
  end
end

Użytkownicy nie widzą opcji "przedpłać za grosze" bo warunek nigdy nie jest spełniony.

Fix w tradycyjnym stacku

  1. Napraw kod lokalnie
  2. Commit, push
  3. CI build (3-8 min)
  4. Deploy na staging
  5. Testy manualne
  6. Deploy na prod
  7. Restart serwera (downtime)
  8. Smoke testy

Czas: 30-60 minut. Downtime: 30 sekund - 2 minuty.

Fix z hot code reload

# payment_live.ex - WERSJA NAPRAWIONA
def handle_event("validate", %{"amount" => amount}, socket) do
  # FIX: konwersja string → integer
  amount = String.to_integer(amount)
  if amount > 100 do
    {:noreply, assign(socket, fee: 10)}
  else
    {:noreply, assign(socket, fee: 0)}
  end
end

W konsoli produkcji:

# Łączysz się z running node
iex --remsh myapp@prod-server --cookie secret

# Kompilujesz i ładujesz nowy kod
iex> c("payment_live.ex")
[PaymentLive]

# Gotowe. Nowy kod działa od razu.

Czas: 2-3 minuty. Downtime: 0.

Użytkownicy, którzy mieli otwarty formularz? Działają dalej. Nowi użytkownicy? Dostają naprawiony kod.


Kiedy to ma sens w praktyce

Hot code reload nie jest do wszystkiego. Ma swoje miejsce w hierarchii narzędzi.

Poziom 1: Hot patch w nagłej sytuacji

Krytyczny bug, który blokuje użytkowników. Nie masz czasu na pełny deploy. Fix jest mały i lokalizowany.

Przykłady:

  • błąd w warunku if/else
  • zła wartość domyślna
  • literówka w komunikacie
  • logika rate limitingu

Czas oszczędności: 20-50 minut.

Poziom 2: Tuning w czasie rzeczywistym

Zmieniasz parametry, które nie wymagają pełnego release'a.

# Konsola produkcji - zmiana konfiguracji bez restartu
iex> Application.put_env(:myapp, :max_connections, 500)
:ok

# Zmiana log level dla konkretnego modułu
iex> Logger.configure(level: :debug)
:ok

Przykłady:

  • zmiana poziomu logowania dla debugu
  • zwiększenie timeout'u
  • włączenie feature flagi

Poziom 3: Pełne upgrade bez downtime

BEAM wspiera appup files - instrukcje jak przeprowadzić upgrade z wersji A do B bez zatrzymania systemu.

# appup example - instrukcje upgrade'u
{'myapp', '1.2.0', '1.1.0', [
  % wersja 1.1.0 → 1.2.0
  {load_module, 'PaymentLive'},
  {update, 'PaymentWorker', {advanced, []}},
  % wersja 1.2.0 → 1.1.0 (rollback)
  {load_module, 'PaymentLive'},
  {update, 'PaymentWorker', {advanced, []}}
]}.

To pozwala na:

  • zmianę struktury procesów
  • migrację stanu między wersjami
  • rollback w przypadku problemów

Używane przez: Ericsson (telekomunikacja), WhatsApp (miliardy połączeń).


Jak to porównać do innych technologii

AspektNode.js/Python/GoKubernetes rolling deployBEAM hot reload
Restart wymaganyTakTak (ale graceful)Nie
DowntimeTakMinimalny (nowe pody)Zero
Zerwane połączeniaTakTak (pod rotation)Nie
Stan w pamięciUtracanyUtracanyZachowany
Czas wdrożeniaMinutyMinutySekundy
Złożoność setupuNiskaWysokaŚrednia
RollbackNowy deployNowy deployNatychmiastowy

Kubernetes rolling deployment - standard industryjny

Kubernetes robi "graceful" deployment:

  1. Nowy pod ze świeżym kodem
  2. Nowe requesty idą do nowego poda
  3. Stary pod dostaje SIGTERM
  4. Czeka na zakończenie requestów
  5. Stary pod jest ubijany

To działa, ale:

  • WebSocket connections są zrywane - user musi się połączyć ponownie
  • Stan w pamięci jest tracony - cache, sesje, drafty
  • Minima 2x zasoby - potrzebujesz miejsce na stary i nowy pod

BEAM hot reload

  1. Ładujesz nowy kod
  2. Stare procesy kończą na starej wersji
  3. Nowe wywołania idą do nowej wersji
  4. Zero dodatkowych zasobów

Połączenia WebSocket żyją. Stan w pamięci żyje.


Przykład praktyczny: GenServer ze stanem

Masz proces, który trzyma stan w pamięci - np. licznik requestów:

defmodule RequestCounter do
  use GenServer

  def start_link(_), do: GenServer.start_link(__MODULE__, 0, name: __MODULE__)

  def increment, do: GenServer.cast(__MODULE__, :increment)
  def get, do: GenServer.call(__MODULE__, :get)

  # Callbacks
  def handle_cast(:increment, count), do: {:noreply, count + 1}
  def handle_call(:get, _from, count), do: {:reply, count, {:noreply, count}}
end

Działa w produkcji, licznik ma wartość 15 847.

Teraz chcesz dodać histogram - rozkład requestów per godzina:

defmodule RequestCounter do
  use GenServer

  def start_link(_), do: GenServer.start_link(__MODULE__, {0, %{}}, name: __MODULE__)

  def increment, do: GenServer.cast(__MODULE__, :increment)
  def get, do: GenServer.call(__MODULE__, :get)

  # Zmieniony stan: {count, histogram}
  def handle_cast(:increment, {count, histogram}) do
    hour = DateTime.utc_now().hour
    new_histogram = Map.update(histogram, hour, 1, &(&1 + 1))
    {:noreply, {count + 1, new_histogram}}
  end

  def handle_call(:get, _from, state), do: {:reply, state, {:noreply, state}}

  # HOT CODE RELOAD: konwersja starego stanu na nowy
  def code_change(_old_vsn, old_state, _extra) do
    # Stary stan to integer, nowy to tuple {int, map}
    {:ok, {old_state, %{}}}
  end
end

Kluczowa funkcja: code_change/3. Mówi BEAM jak przekształcić stan ze starej wersji na nową.

# W konsoli produkcji
iex> :sys.suspend(RequestCounter)
:ok
iex> :sys.change_code(RequestCounter, RequestCounter, 0, [])
:ok
iex> :sys.resume(RequestCounter)
:ok

# Stan = {15847, %{}} - licznik zachowany, histogram startuje

Licznik 15 847 zachowany. Zero downtime. Stan zmigrowany.


Kiedy NIE używać hot code reload

Mimo że to potężne narzędzie, nie jest odpowiedzią na wszystko.

Zmiany w schema bazy danych

Hot code reload nie rusza bazy. Jeśli zmieniasz strukturę tabeli - potrzebujesz migracji, które mogą wymagać locków.

# To wymaga migracji DB - hot reload nie pomoże
defmodule User do
  schema "users" do
    field :email, :string
    field :phone, :string  # NOWE POLE - potrzebna migracja
  end
end

Zmiany w zależnościach

Nowa biblioteka? Zaktualizowany Elixir? Nowa wersja Phoenixa?

# To wymaga restartu - nowe zależności
def deps do
  [
    {:phoenix, "~> 1.7"},      # zmiana na 1.8
    {:ecto_sql, "~> 3.10"},    # nowa wersja
  ]
end

Kompleksowe refaktoryzacje

Jeśli zmieniasz interfejsy między 20 modułami - hot reload staje się ryzykowne. Łatwo o niespójność.

Reguła: jeśli zmiana dotyka >3 plików lub zmienia interfejsy - użyj normalnego deployu.

Brak testów

Hot patch bypassuje CI/CD. Jeśli nie masz pewności, że kod działa - nie rób hot reload.


Strategia deploymentu w BEAM

Większość projektów używa mieszanego podejścia:

┌─────────────────────────────────────────────────────────┐
│                    Typ zmiany                           │
├─────────────────────┬───────────────────────────────────┤
│ Mały fix            │ Pełny feature / release           │
│ (1-2 pliki)         │ (wiele plików, zależności)        │
├─────────────────────┼───────────────────────────────────┤
│ Hot patch           │ Standard deploy                   │
│ (nagłe przypadki)   │ (CI/CD, testy, staging)           │
├─────────────────────┼───────────────────────────────────┤
│ Czas: minuty        │ Czas: 20-60 minut                 │
│ Downtime: 0         │ Downtime: 0 (rolling)             │
└─────────────────────┴───────────────────────────────────┘

Workflow produkcyjny

  1. Normalne zmiany → CI/CD → rolling deploy (dla nowych wersji zależności)
  2. Krytyczne hotfixy → hot reload → potem commit do repozytorium
  3. Tuning runtime → konsola iex → trwałe w configu w następnym release

Tabela porównawcza: hot reload vs standard deploy

CzynnikHot Code ReloadStandard Deploy
Czas wdrożenia1-5 minut15-60 minut
Downtime00 (rolling) lub minimalny
Zerwane połączenia0Tak (WebSocket)
Stan w pamięciZachowanyUtracony
Testy automatycznePominiętePrzechodzą
RollbackNatychmiastowyNowy deploy
Zmiany w zależnościachNieTak
Zmiany w bazieNieTak
Złożoność zmianyMałaDowolna
RyzykoŚrednie (brak CI)Niskie (z CI)

Ericsson i historia hot reload

Hot code reload nie jest nowym featurem. Jest częścią DNA BEAM od 1986 roku.

Ericsson budował systemy telekomunikacyjne, gdzie:

  • 99.9999999% uptime był wymagany (max 31 ms rocznie downtime)
  • systemy działały latami bez restartu
  • aktualizacje musiały być bez przerwy

Rozwiązanie: maszyna wirtualna, która wspiera hot reload natywnie.

AXD301 - switch Ericssona:

  • 2 milionów linii kodu Erlanga
  • 99.9999999% availability
  • Upgrade w locie, bez zrywania połączeń głosowych

To jest powód, dla którego WhatsApp (19 miliardów wiadomości dziennie) wybrał Erlanga/BEAM. Nie dla szybkości kodu, ale dla niezawodności runtime'u.


Podsumowanie

Hot code reload w BEAM to nie "nice to have". To architekturalna różnica.

W tradycyjnym stackuW BEAM
Bug fix = deploy = restartBug fix = reload = kontynuacja
Stan w pamięci = traconyStan w pamięci = zachowany
WebSocket = zrywaneWebSocket = żywe
Rollback = nowy deployRollback = sekunda

Reguła: BEAM daje Ci opcję, której inni nie mają. Możesz z niej korzystać lub nie - ale warto wiedzieć, że istnieje.


Jeśli chcesz zobaczyć jak hot code reload działa w praktyce i jak wdrożyć strategię zero-downtime w Twoim systemie, porozmawiajmy - pokażemy demo na żywo i omówimy, czy i kiedy to ma sens w Twoim przypadku.