ElixirとPhoenix Frameworkで高並列Webアプリを構築する:セットアップから本番環境まで

Programming tutorial - IT technology blog
Programming tutorial - IT technology blog

なぜ一般的なWebスタックは高並列に弱いのか

こんな経験はないだろうか。Node.jsやRuby on Railsのアプリはステージングではなくるのにトラフィックがスパイクした途端、すべてが遅くなる。スレッドプールは枯渇し、メモリは増え続け、インフラチームはインスタンスを増やし始める。よくある解決策は「水平スケール」だが、それは根本的な問題を隠しているにすぎない。

真の原因は並列処理モデルにある。多くのWebフレームワークはすべてのリクエストでスレッドプールを共有している。高負荷時にはスレッドが空くまでリクエストがキューに積まれ、数千の同時接続を超えると、コンテキストスイッチのCPU消費が実際の処理を上回ってしまう。

Elixirはまったく異なるアプローチを採用している。WhatsApp、Discord、RabbitMQを支えるErlangと同じランタイムであるBEAM仮想マシン上で動作する。OSスレッドの代わりに、BEAMは軽量プロセスを使用する。それぞれがわずか1〜2KBのメモリしか使わず、数百万単位で起動できる。すべてのリクエストは独立したプロセスで処理されるため、ロックが必要な共有ミュータブル状態もなく、スレッドプールが枯渇することもない。

ElixirをベースとするWebフレームワーク、Phoenixはこの特性をそのまま継承している。有名なPhoenixのベンチマークでは、単一サーバーで200万の同時WebSocket接続を実現した。私自身、4vCPUのVPSで本番運用しているが、一般的なRailsやExpressなら限界を迎えるトラフィックスパイク時でも、レスポンスタイムはほとんど変化しなかった。

インストール:ElixirとPhoenixの環境構築

ErlangとElixirのインストール

ElixirはErlang/OTPを必要とするため、両方をインストールする必要がある。最もクリーンな管理方法は、両ランタイムを競合なく扱えるバージョンマネージャーasdfを使うことだ。

# asdf をインストール
git clone https://github.com/asdf-vm/asdf.git ~/.asdf --branch v0.14.0
echo '. "$HOME/.asdf/asdf.sh"' >> ~/.bashrc
echo '. "$HOME/.asdf/completions/asdf.bash"' >> ~/.bashrc
source ~/.bashrc

# Erlang と Elixir のプラグインを追加
asdf plugin add erlang https://github.com/asdf-vm/asdf-erlang.git
asdf plugin add elixir https://github.com/asdf-vm/asdf-elixir.git

# 依存パッケージをインストール(Ubuntu/Debian)
sudo apt-get install -y build-essential autoconf m4 libncurses5-dev \
  libwxwidgets-dev libgl1-mesa-dev libglu1-mesa-dev libpng-dev \
  libssh-dev unixodbc-dev xsltproc fop libxml2-utils

# 特定バージョンをインストール
asdf install erlang 26.2.5
asdf install elixir 1.16.3-otp-26
asdf global erlang 26.2.5
asdf global elixir 1.16.3-otp-26

インストールを確認する:

elixir --version
# Erlang/OTP 26 [erts-14.2.5] ...
# Elixir 1.16.3 (compiled with Erlang/OTP 26)

Phoenixのインストールと最初のアプリ作成

PhoenixはElixirの組み込みビルドツールmixを使う。npmやCargoのElixir版と考えればよい。

# Phoenix アプリケーションジェネレーターをインストール
mix archive.install hex phx_new

# 新しい Phoenix プロジェクトを作成
mix phx.new my_app --database postgres
cd my_app

このコマンドでPostgreSQLアダプター付きの完全なPhoenixプロジェクトが生成される。試しに使うだけなら--no-ectoでデータベースを省略できる。

Phoenix 1.7以降は自動ダウンロードされるスタンドアロンのesbuildバイナリが同梱されており、基本的なアセットコンパイルにNode.jsは不要だ。依存パッケージを取得してサーバーを起動しよう:

mix deps.get
mix ecto.create   # データベースを作成
mix phx.server    # 開発サーバーを起動

http://localhost:4000にアクセスするとPhoenixのウェルカムページが表示される。ファイルの変更はライブリロードで自動反映される。

設定:高並列に対応したチューニング

プロセスモデルを理解する

設定を変更する前に、まずプロセスモデルを理解しておきたい。Phoenixでは各HTTPリクエストが専用のElixirプロセスを生成する。これはOSスレッドではなく、BEAMスケジューラがすべてユーザースペースで管理するため、カーネルが関与しなくても同時に数十万のプロセスを維持できる。

本番環境の設定はconfig/runtime.exsに記述する。このファイルはコンパイル時ではなく起動時に評価されるため、環境変数が正しく機能する:

import Config

config :my_app, MyAppWeb.Endpoint,
  http: [
    port: String.to_integer(System.get_env("PORT") || "4000"),
    # 高トラフィック向けにアクセプター数を増やす
    transport_options: [num_acceptors: 100]
  ],
  cache_static_manifest: "priv/static/cache_manifest.json"

WebSocket並列処理のためのPhoenix Channels設定

ChannelsこそElixirの並列処理モデルが真価を発揮する場所だ。接続中の各クライアントはそれぞれ独自のプロセスで動作する。50,000人のアクティブユーザーがいるチャットルームは、それぞれが独立した状態を持つ50,000個のプロセスを意味する。最小限のChannel実装は以下のとおりだ:

# lib/my_app_web/channels/room_channel.ex
defmodule MyAppWeb.RoomChannel do
  use Phoenix.Channel

  def join("room:" <> room_id, _params, socket) do
    {:ok, assign(socket, :room_id, room_id)}
  end

  def handle_in("new_msg", %{"body" => body}, socket) do
    broadcast!(socket, "new_msg", %{body: body})
    {:noreply, socket}
  end
end

ソケットモジュールに紐付ける:

# lib/my_app_web/channels/user_socket.ex
defmodule MyAppWeb.UserSocket do
  use Phoenix.Socket

  channel "room:*", MyAppWeb.RoomChannel

  def connect(_params, socket, _connect_info), do: {:ok, socket}
  def id(_socket), do: nil
end

EctoによるDBコネクションプールの設定

Elixirより先にボトルネックになるのはデータベースだ。EctoはDBConnectionライブラリを通じてこれを処理し、永続的なデータベース接続のプールを維持する。config/runtime.exsでプールをチューニングしよう:

config :my_app, MyApp.Repo,
  url: System.get_env("DATABASE_URL"),
  pool_size: String.to_integer(System.get_env("POOL_SIZE") || "20"),
  queue_target: 5000,   # 接続待ちタイムアウト(ミリ秒)
  queue_interval: 1000  # プールヘルスチェックの間隔

適切な初期値として、pool_sizeはvCPU数×2〜4に設定しよう。4コアのサーバーなら10〜16から始め、実際の飽和状況を見ながら調整する。PostgreSQLには接続数の上限(デフォルト100)があるため、複数アプリインスタンスで合計が超えないよう注意が必要だ。

本番リリースの設定

Mix releasesはアプリケーションを自己完結型のバイナリにパッケージ化する。デプロイ先のサーバーにElixirのインストールは不要で、リリース成果物をコピーして実行するだけだ。

# リリースをビルド
MIX_ENV=prod mix deps.get --only prod
MIX_ENV=prod mix compile
MIX_ENV=prod mix assets.deploy
MIX_ENV=prod mix release

リリースは_build/prod/rel/my_app/に生成される。このディレクトリをサーバーにコピーして起動する:

PORT=4000 DATABASE_URL=postgres://... SECRET_KEY_BASE=... \
  _build/prod/rel/my_app/bin/my_app start

systemdで管理する場合は/etc/systemd/system/my_app.serviceを作成する:

[Unit]
Description=MyApp Phoenix サーバー
After=network.target

[Service]
User=deploy
WorkingDirectory=/opt/my_app
EnvironmentFile=/opt/my_app/.env
ExecStart=/opt/my_app/bin/my_app start
ExecStop=/opt/my_app/bin/my_app stop
Restart=on-failure
RestartSec=5

[Install]
WantedBy=multi-user.target

動作確認とモニタリング

LiveDashboardによる組み込みオブザーバビリティ

PhoenixにはPhoenix.LiveDashboardが付属している。プロセス数、メモリ使用量、ETSテーブルの内容、データベースクエリのメトリクスをリアルタイムで表示するモニタリングUIだ。ルーターに追加しよう:

# lib/my_app_web/router.ex
if Mix.env() == :dev do
  import Phoenix.LiveDashboard.Router

  scope "/" do
    pipe_through :browser
    live_dashboard "/dashboard", metrics: MyAppWeb.Telemetry
  end
end

本番環境では認証で保護すること。内部システムの状態が外部に公開されてしまう。

負荷テストの実施

リリース前に負荷テストを必ず実施しよう。k6はWebSocket中心のアプリに適しており、すっきりとしたスクリプトAPIを持つ:

# k6 をインストール(最新手順は k6.io/docs/get-started/installation を参照)
sudo gpg -k
sudo gpg --no-default-keyring --keyring /usr/share/keyrings/k6-archive-keyring.gpg \
  --keyserver hkp://keyserver.ubuntu.com:80 --recv-keys C5AD17C747E3415A3642D57D77C6C491D6AC1D69
echo "deb [signed-by=/usr/share/keyrings/k6-archive-keyring.gpg] https://dl.k6.io/deb stable main" \
  | sudo tee /etc/apt/sources.list.d/k6.list
sudo apt-get update && sudo apt-get install k6

# シンプルな HTTP 負荷テストを実行
k6 run --vus 1000 --duration 30s script.js

HTTPテスト用の基本的なk6スクリプト:

import http from 'k6/http';
import { sleep } from 'k6';

export const options = {
  vus: 1000,
  duration: '30s',
};

export default function () {
  http.get('http://localhost:4000/api/health');
  sleep(1);
}

BEAMレベルのプロセス監視

多くのプラットフォームでは、実行中のアプリケーション内部を把握するために外部APMツールが必要だ。BEAMではその必要がない。IExを使って本番ノードに直接接続できる:

# 実行中のリリースに接続
_build/prod/rel/my_app/bin/my_app remote

IExシェルからライブシステムをクエリできる:

# 実行中の全プロセス数を取得
length(Process.list())

# メモリ使用量を確認
:erlang.memory()

# 現在処理中のリクエスト数を確認
:sys.get_state(MyApp.Repo)

# 接続中の WebSocket チャンネルを確認
Phoenix.PubSub.list_subscriptions(MyApp.PubSub)

エージェントもサイドカーも不要。ランタイム自体が検査可能なため、本番問題のデバッグが格段に楽になる。

本番環境で監視すべき指標

アプリが稼働したら追跡する価値のあるメトリクスをいくつか挙げる:

  • プロセス数:安定していることが理想。増え続ける場合はプロセスリークの兆候で、どこかで終了しないプロセスが生成されている。
  • メッセージキューの長さ:キューが長いプロセスはボトルネックになっている。LiveDashboardで視覚的に確認できるため、発見しやすい。
  • DBコネクションプールの飽和:ログにqueue_targetタイムアウトが出る場合、プールが小さすぎるかクエリが遅すぎる。pool_sizeを増やす前に両方を確認しよう。
  • ノードごとのメモリ:BEAMのメモリ使用量は一般的に予測可能だ。急激な増加は、状態を際限なく積み上げるGenServerなど、状態を蓄積し続けるプロセスが原因であることが多い。

BEAMの「クラッシュさせてよい」という哲学はここで真価を発揮する。スーパーバイザーツリーが失敗したプロセスを自動的に再起動する。Node.jsのプロセスをダウンさせるような一時的なエラーもElixirでは伝播しない。スーパーバイザーが影響を受けたプロセスを再起動する間も、アプリケーションの残りの部分は稼働し続ける。

Go、Python、Node.jsで並列処理のバグと格闘してきたなら、Elixirは習得コストに見合う価値がある。アクターモデルを理解するには1〜2週間かかるかもしれない。しかし一度理解すれば、並列処理がランタイムの後付けではなく第一級の関心事として設計されたシステムで開発することで、サーバーの作り方に対する考え方が根本から変わるだろう。

Share: