2026-07-25 00:54:25
負荷分散をネットワークからDBまで全部つなげて整理してみた
「負荷分散どうする?」って話、けっこうふわっと語られがちだなぁと最近思ってます。ロードバランサ置けばいいんでしょ、とか、サーバー増やせばいいんでしょ、とか。自分も昔はそのくらいの理解でした。
でも実際に向き合ってみると、負荷分散って一個のテクニックじゃなくて、リクエストが流れていく道すじ(ネットワーク → インフラ → アプリ → DB)の各層で、それぞれ全然違う手段で分散させるものなんですよね。しかもこれらがちゃんと繋がってて、上流でサボると下流が死ぬ、みたいな関係になってる。
最近このあたりを頭の中で整理する機会があったので、上から下まで一気に、なるべく繋げて書いてみようと思います。正直かなり長くなりますが、通しで読むと「あー、全部つながってるんだ」ってなるはずなので、よければお付き合いください。
そもそも負荷はどこにかかるのか
まず全体像から。ユーザーのリクエストって、こんな順番で流れていきます。
ユーザー
↓ ① DNS解決(名前 → IP) ← ネットワークレベル
↓ ② ロードバランサ ← インフラレベル
↓ ③ アプリサーバー(Rails等) ← アプリケーションレベル
↓ ④ データベース ← DBレベル
負荷分散の話って、この各段階でやることが違うんです。①は「そもそもどのデータセンターに向かわせるか」、②は「1つのリージョンの中で複数サーバーにどう配るか」、③は「1リクエストあたりの重さをどう減らすか」、④は「最後まで残るボトルネックをどう捌くか」。
面白いのが、下に行くほど分散が難しくなるってところ。アプリサーバーは台数を足せば割と簡単にスケールするんですが、DBは「はい10台に増やしました」ってわけにいかない。なので上流で頑張ってDBに負荷を届かせない、という発想がめちゃくちゃ大事になってきます。
じゃあ上から順に見ていきます。
ネットワークレベル:そもそも届かせない
一番ユーザーに近い、リクエストが最初にぶつかる層です。ここでの主役はDNSとCDNとAnycast。
DNSベースの負荷分散は、名前解決の段階で「どのIPを返すか」を状況に応じて出し分ける手法です。一番原始的なのがDNSラウンドロビンで、1つのドメインに複数のAレコード(IP)を登録して、応答するたびにIPを回転させる。タダ同然で実装できるし地理分散もできるんですが、致命的な弱点があって、それは後半のCloudflareのところでガッツリ書きます。
CDNとエッジも忘れちゃいけなくて、これは個人的には「最強の負荷分散」だと思ってます。だって静的コンテンツ(画像・JS・CSS・動画)をエッジで返しちゃえば、そもそもオリジンサーバーにリクエストが届かないわけですよ。分散する以前に、負荷そのものが発生しない。最近はエッジコンピューティング(Cloudflare WorkersとかLambda@Edge)で動的処理の一部までエッジに逃がせるので、ここでどれだけ削れるかがけっこう効きます。
あとAnycastね。同じIPアドレスを世界中の複数拠点から広告して、BGPの経路制御で「ネットワーク的に一番近い」拠点に自動で吸い込む仕組み。CDNとかDNSサービス(8.8.8.8とか)の基盤になってる技術です。これはDDoS吸収にも効いて、攻撃トラフィックが各拠点に散るので一箇所が潰れにくい。
インフラレベル:ロードバランサの中身
次がロードバランサ層。ここが「負荷分散」って言われて多くの人がイメージするやつです。
ロードバランサにはL4とL7があります。L4(AWSのNLBとか)はIPとポートだけ見て振り分ける。中身のHTTPは見ないぶん超高速で、数百万コネクションとか捌けます。L7(ALBとかNginx、Envoy)はHTTPの中身(URLパス、ヘッダ、Cookie)まで見て振り分けられる。「/api/* はAPIサーバー群へ、/img/* は画像サーバー群へ」みたいなパスベースルーティングができたり、SSL/TLS終端をLBで肩代わりしてバックエンドを軽くしたりできます。
で、この振り分けアルゴリズムが意外と奥深い。
- ラウンドロビン:順番に均等配分。サーバー性能が同じ前提
- least connections:接続数が一番少ないサーバーへ。処理時間にばらつきがあるときに強い
- ハッシュ(IP/URL):同じキーは同じサーバーへ。キャッシュ効率とセッション維持に効く
- power of two choices:ランダムに2台選んで空いてる方へ。全台監視しなくてもleast conn級の効果が出るやつで、これ賢いなぁと思います
そして負荷分散の最大の落とし穴がセッションの扱い。複数サーバーに散らすと「ログイン状態をどのサーバーが持ってるの?」問題が発生します。スティッキーセッション(同じユーザーを同じサーバーに固定)で逃げる手もあるけど、そのサーバーが落ちたらセッション消えるし負荷も偏る。結局ステートレス化(セッションをRedisとかJWTに逃がして、どのサーバーでも処理できるようにする)が水平スケールの大前提になります。ここ、あとでRailsの話でもう一回出てきます。
アプリケーションレベル:分散する前に減らす
サーバーの台数を増やすだけじゃ限界があって、そもそも1リクエストあたりの負荷を減らす/逃がす工夫が要ります。
一番効くのはやっぱりキャッシュ。計算結果やDB結果をRedis/Memcachedに持たせて、同じ処理を何度もやらない。ただしキャッシュには罠があって、人気データのキャッシュが同時に切れて一斉にDBへ殺到する「キャッシュスタンピード(サンダリングハード)」ってやつ。これは確率的早期再計算とかstale-while-revalidateで防ぎます。あと「キャッシュの無効化」はコンピュータサイエンスの2大難問のひとつと言われるくらい難しい。何をいつ捨てるか、ほんと悩ましい。
もうひとつ大事なのが非同期化。メッセージキュー(SQS、Sidekiqとか)で重い処理を後回しにして、リクエストには「受け付けました」を即返す。実処理はワーカーが裏で消化する。これ、キューが緩衝材になってスパイクを吸収してくれる(load levelingって言います)ので、急な負荷の波にめっちゃ強くなる。メール送信とか画像変換とか集計は、同期処理から切り離しちゃうのが定石です。
あとは流量制御ですね。レートリミットで暴れるユーザーを抑える、サーキットブレーカで下流が不調なら早めに諦めて連鎖障害を防ぐ、過負荷時には優先度の低いリクエストをわざと捨てる(ロードシェディング)。このへんは「全部受け止めようとして共倒れする」のを防ぐ発想で、地味だけど本番運用だと効いてきます。
DBレベル:本数 × 重さ × 回数で考える
さて、ここからが本題というか、一番語りたかったところ。DBの負荷軽減です。
DBの負荷って、僕はこういう掛け算で捉えると腑に落ちました。
DBの総負荷 ≒ クエリの本数 × 1本あたりの重さ × 実際にDBへ届く回数
↑ N+1問題 ↑ インデックス ↑ キャッシュ
この3つを左から順に潰していく。順番にも意味があるので、繋げて説明します。
① まず「本数」を減らす — N+1問題
一覧を出すときの定番の罠。ブログ記事10件と、それぞれの著者名を出すとします。
-- 1回: 記事一覧
SELECT * FROM posts LIMIT 10;
-- N回: 各記事ごとに著者(10回!)
SELECT * FROM users WHERE id = 1;
SELECT * FROM users WHERE id = 2;
...1 + N = 11回。100件なら101回。これがN+1問題です。恐ろしいのは、1本1本は一瞬で終わること。だから開発中の少ないテストデータでは全然気づかなくて、本番でデータ量とアクセスが増えた瞬間にドカンとくる。僕もこれで痛い目みたことあります。
なんで本数がそんなに効くかというと、クエリには毎回「固定オーバーヘッド」があるから。アプリ↔DBの通信の往復、パース、コネクション取得。1回の往復が1msでも100回で100ms。速いクエリを大量に投げるより、まとめて1回のほうが圧倒的に軽いんですよね。
対策はJOINで1本にまとめるか、Railsなら includes みたいなEager Loading。裏で WHERE id IN (...) の2本に集約してくれます。ここを101本から2本に減らせるので、削減効果の桁が一番デカい。だから最初に手をつけるべきなんです。
② 次に「1本の重さ」を減らす — インデックス
本数を絞ったら、残った各クエリを速くします。インデックスがないと、DBは条件に合う行を探すのにテーブル全行を上から舐める(フルテーブルスキャン)。100万行なら100万行チェック。
インデックスは本の巻末索引と同じで、「emailで引く用」の索引を別に持っておいて、そこを辿って目的の行に一気にジャンプする。O(n) が O(log n) になるので、行数が増えるほど差が絶望的に開きます。
勘所はいくつかあって、WHERE・JOIN・ORDER BYで使う列に張る、特にJOINの結合キー(外部キー)は必須。ここで①と繋がるんですが、せっかくN+1をJOINにまとめても、結合キーにインデックスがなかったら今度はそのJOINが重くなる。だから①と②はセットなんですよね。
複合インデックスは順序が命で、(a, b) は WHERE a=? には効くけど WHERE b=? 単独には効かない。電話帳が姓→名の順で並んでるのと同じ理屈です。あと必ずEXPLAINで実行計画を見て、Seq Scan(フルスキャン)が出てないか、想定した索引が使われてるか確認する。推測じゃなく計測、これ大事。
ただインデックスはタダじゃなくて、書き込み(INSERT/UPDATE/DELETE)のたびに索引も更新するので書き込みが遅くなるし、ストレージも食う。だから「全列に張る」は間違いで、読み取りパターンに合わせて必要な分だけ、が正解です。
③ 最後に「届く回数」を減らす — キャッシュ
①②で速いクエリを作った。でも、一番速いクエリって、実行しないクエリなんですよ。同じ結果を何度もDBに聞くのは無駄なので、手前で覚えておく。これがCache-Asideパターンで、まずRedisを見て、あればDBに行かず即返す、なければDBに行って結果をキャッシュに保存する。
ここで順番の意味がまた効いてきます。遅いクエリをそのままキャッシュするのは悪手なんです。キャッシュが切れた瞬間(MISS)に、最適化してない重いクエリが一斉にDBへ飛ぶ。さっき言ったキャッシュスタンピードですね。つまりキャッシュは、①②で十分速くしたクエリの上に載せて初めて安全。土台(インデックス)がないままキャッシュで蓋をすると、蓋が外れたときにDBが死ぬ。
だから「N+1 → インデックス → キャッシュ」の順番なんです。キャッシュはあくまで最後の砦であって、下手なクエリの言い訳にしちゃいけない。この3つを上から順にやると、本数 × 重さ × 回数 の掛け算が層ごとに小さくなって、DB負荷が乗算的に落ちていく、というのが全体像です。
あと本数・重さ・回数の話とは別に、読み取りが多いならリードレプリカでマスタから読み取りを逃がす、書き込みが本当にスケールしないならシャーディングでデータを分割する、みたいな構造的な手も残ってます。ただシャーディングはシャードをまたぐJOINや集計がつらくなるので、できれば①②③とレプリカで粘りたいところ。
Railsをどこで動かす? EC2 vs ECS
ここからはインフラの話に戻って、具体的にRailsをどう載せるか。EC2とECSでやり方がけっこう違うので整理します。
その前に、Railsで負荷分散を考えるときに絶対おさえるべき式があって、それがこれ。
1インスタンスのDBコネクション数 = Pumaのworkers × threads
例えば workers 2 × threads 5 = 10コネクション/インスタンス。これを20台にスケールしたら200コネクション。RDSの max_connections を超えるとアプリが could not obtain a connection で死にます。「水平スケールする = DBコネクションが台数倍される」、これがEC2でもECSでも共通の最大の落とし穴。ここ、あとでちゃんと手当てします。
EC2 + Auto Scaling Group
構成はシンプルで、ALB → ターゲットグループ → Auto Scaling Group(EC2複数台)。各EC2にRailsを常駐させておく。
スケールの仕組みはLaunch Template(Railsが動くEC2の設計図)とAuto Scaling Group(min/max/desired台数とスケール条件)とALB(ヘルスチェックで正常な台数だけに振り分け)の組み合わせ。CPU 60%を維持するようにターゲット追跡でスケール、みたいなやつです。このへんは前にAuto Scalingの記事でも書いたので、そっちも見てもらえると。
Railsの載せ方は2方式あって、ひとつはGolden AMI方式。Railsインストール済みのAMIを事前に焼いておいて、スケールアウト時はそこから起動するだけ。起動が速くて再現性が高い。もうひとつは起動時プロビジョニングで、素のAMIから起動して git pull → bundle install → assets:precompile。これは起動が遅い(数分)ので、急なスパイクに間に合わないリスクがある。
EC2の弱点は正直けっこうあって、スケールが遅い(OS起動 + Pumaのウォームアップで数分)から閾値に余裕を持たせて早めに増やす必要があって、結果として常に余分な台数を抱えることになりコストがかさむ。粒度も「EC2 1台」単位で粗い。あとOSパッチとかミドルウェア管理とかデプロイ整備とか、運用が重い。
ECS(Fargate)
一方ECSは、RailsをDockerイメージにしてコンテナ単位でスケールします。Fargateならサーバー(EC2)の管理すら要らない。
主要コンポーネントはTask Definition(Railsコンテナの設計図=EC2でいうLaunch Template)、Service(Taskを常に何個動かすか保証してくれる)、Service Auto Scaling(タスク数を増減)。スケール条件はCPUだけじゃなく ALBRequestCountPerTarget(1タスクあたりのリクエスト数)が使えるのが地味に嬉しくて、これはCPUに出にくいI/O待ち中心のRailsアプリと相性がいいんです。
ECSの強みはとにかくスケールが速いこと。コンテナ起動は数十秒だし、assets:precompile もイメージビルド時に終わってるから起動=即稼働。スパイク追随性が高いので余剰台数を減らせてコスト効率がいい。デプロイも安全で、新イメージでTask Definitionのリビジョンを上げてローリングアップデート(無停止)やBlue/Greenが標準で使えるし、ロールバックはリビジョンを戻すだけ。「ローカルで動いた=本番で動く」の再現性も高い。
注意点はDocker化のコスト(Dockerfile整備、イメージビルドのCI、ECR運用)と、Fargateは規模によっては割高になること。あとコンテナは使い捨てなのでローカルディスクに依存できない=ステートレス化が必須。ファイルアップロードはS3、ログはstdout/CloudWatchへ。
結局どっち?
今からRailsを新しく載せるなら、僕はECS(Fargate)を第一候補にするかなぁと思ってます。スケールの速さ・デプロイの安全さ・運用の軽さがRailsのスケールアウト戦略とよく噛み合う。EC2は既存資産があるとか、特殊なOS/パッケージ依存があるとか、そういう事情があるときの選択肢、という感じ。
EC2でもECSでも刺さるRails固有の宿題
ここ大事なんですが、インフラをどっちにしても別途手当てが要るものがあります。これを忘れると「台数増やしたのにDBで詰まる」という典型的な失敗になる。
まずさっきのDBコネクション爆発。RDS Proxy を挟んでコネクションをプーリング・多重化する。ECSはスケールが速くて台数変動が激しいぶん、EC2よりRDS Proxyの重要度が高いです。
次にDBマイグレーション。ローリングやオートスケール環境だと新旧コードが同時に動く瞬間があるので、rails db:migrate はデプロイパイプラインで1回だけ(ECSならマイグレーション専用のワンショットTaskを1本流す)。しかもスキーマ変更は後方互換を守る。カラム削除はいきなりやらず「使うのをやめる → あとで消す」の2段階にする。新旧コードが共存しても壊れないように、ってやつです。
あとバックグラウンドジョブ(Sidekiq)はWebと負荷特性が違うので、別のASG/別のECS Serviceに分けて独立にスケールさせる。ジョブが溜まったらキュー長でWorkerだけ増やす。混ぜると「Webは暇なのにジョブが詰まる」みたいになってスケールが最適化できない。
そしてヘルスチェック用エンドポイント。ALBが振り分け対象を判定するのに /up(Rails 7.1以降)を用意する。アプリ生存だけ見るか、DB接続まで見るかは要検討で、DBダウン時に全台unhealthyで全滅、みたいなことも起こりうるので設計が要ります。
最後にステートレス化。セッションはRedisへ、アップロードはActive Storage + S3へ。これができてないと台数増やしてもセッション切れやファイル欠損が起きる。ECSでは必須、EC2でも水平スケールの前提です。
DNSベース負荷分散とCloudflare Load Balancing
さて最後、一番上流に戻ります。ここまでは基本1つのリージョンの中の話でしたが、複数リージョン・複数データセンターをまたぐ分散はDNSの層でやります。
DNSベース負荷分散は「名前解決の段階で行き先を決める」ものなんですが、実は構造的な弱点を2つ抱えてます。これを理解すると、なぜCloudflareみたいなサービスが要るのかが腑に落ちる。
ひとつめがTTL問題。DNS応答にはTTL(キャッシュ有効期間)があって、リゾルバやOS、ブラウザが結果をキャッシュする。だからサーバーが今ダウンしても、TTLが300秒なら最大5分間、ユーザーは死んだIPにアクセスし続ける。しかもTTLを無視するリゾルバも実在するので、TTLを短くしてもキャッシュが残ってフェイルオーバーが遅れる。DNSベースが「厳密なリアルタイム切替」に向かない最大の理由です。
ふたつめがヘルスチェックが本来ないこと。素のDNSラウンドロビンは「そのIPが生きてるか」を知らないので、ダウンしたサーバーのIPも平然と返し続ける。結果、一部のユーザーがエラーになる。
で、Cloudflare Load Balancing(やRoute 53)は、この弱点を克服したものだと理解するとスッキリします。
Cloudflareの構成
Cloudflare LBはこんな階層構造になってます。
Load Balancer(app.example.com)
├─ Pool(オリジンのグループ。例: 東京プール / 大阪プール)
│ └─ Origin(実サーバー = ALBやECSのエンドポイント)
└─ Monitor(各Originを定期的に叩くヘルスチェック)
Originが実際のバックエンド(さっきの話でいう東京リージョンのALBとか)、Poolがそれをまとめたグループ(だいたいリージョン単位)、Monitorがヘルスチェックです。このMonitorが賢くて、Cloudflareの世界中のデータセンターからチェックできるので、「東京からは見えるけど北米から見えない」みたいな部分障害まで検知できる。これでヘルスチェックがないという弱点を潰してます。
プロキシモードが肝
Cloudflareには2つの動作モードがあって、ここがTTL問題への強さを分けます。
プロキシモード(オレンジクラウド)だと、ユーザーは常にCloudflareのAnycast IPに接続する(DNS応答は固定IP)。で、オリジンの選択はエッジ側でリアルタイムにやるので、DNSキャッシュの影響を受けずにフェイルオーバーが即座に効く。ここで前半のAnycastが効いてきて、世界中どこからでも最寄りエッジに吸い込まれて、DDoS吸収・TLS終端・キャッシュ・WAFも全部同時に効く。実質グローバルなL7ロードバランサとして機能するわけです。
もう一方のDNS-onlyモード(グレークラウド)は、Cloudflareが経路に入らず純粋なDNSベース負荷分散として振る舞う。これはTTL問題が残るので、プロキシを通せない特殊要件向け。
つまりCloudflare LBの真価はプロキシモードにあって、「DNSベースの弱点(TTL)を、Anycastプロキシで経路ごと持つことで克服した」のが本質なんですよね。ここが「単なるDNS」や「Route 53」との決定的な違いです。
ステアリングとヘルスチェックの粒度
振り分けの戦略(ステアリング)もいくつかあって、優先順位で上から使うFailover、地域→プールを手動マッピングするGeo Steering、実測RTTが最小のプールへ自動で送るDynamic (Latency) Steeringあたりがよく使われます。Origin単位で重み付けもできるので、「新リージョンにまず10%だけ流す」みたいなカナリアもできる。
ヘルスチェックはパスや期待ステータスコード、レスポンスボディの文字列、間隔、リトライ回数まで細かく設定できて、ここでさっきのRailsの /up エンドポイントが活きてきます。アプリ生存だけじゃなくDB接続まで見るヘルスチェックにすれば、DBダウン時にそのプールごと切り離せる。全部つながってるでしょ。
全部つなげた図
Rails on ECSをマルチリージョンで冗長化する例を、ここまでの話を全部乗せて書くとこうなります。
Cloudflare Load Balancer (app.example.com)
Anycast / プロキシモード / Dynamic Latency Steering
│
┌─────┴─────┐
[東京 Pool] [大阪 Pool]
Monitor:/up Monitor:/up
Origin=東京ALB Origin=大阪ALB
│ │
ECS(Rails)×N ECS(Rails)×N
│ │
RDS Proxy RDS Proxy
│ │
RDS(primary) RDS(replica)
平常時はDynamic Steeringで各ユーザーを最速リージョンへ、東京が障害ればMonitorが検知して大阪へほぼ即時フェイルオーバー(プロキシモードだからTTL待ちなし)。ただDBはリージョンをまたぐと整合性の設計(書き込みをどっちに寄せるか、レプリケーションの方向をどうするか)が別途必要になってきて、ここが一番設計判断の重いところ。ここまで来るとまた別の記事が一本書けちゃうので、今回はここまでにしておきます。
おわりに
というわけで、ネットワークからDBまで一気に駆け抜けました。長かった。
書きながら改めて思ったのは、負荷分散って結局全部つながってるんだなぁということ。CDNやキャッシュで上流でリクエストを削って、N+1とインデックスでDBの負荷そのものを減らして、それでも足りないぶんをロードバランサとオートスケールで台数に分散して、リージョンをまたぐところはDNS/Cloudflareで捌く。そして水平スケールの前提としてステートレス化があって、その代償としてDBコネクションが台数倍されるからRDS Proxyで受け止める。どこか一箇所だけ頑張ってもダメで、全体を貫く原則(分散する前に減らす、ステートレス化、ボトルネックは下流に移動する)を意識しないと、結局どこかで詰まる。
自分の中でも「なんとなく知ってる」を「つながった形で説明できる」に持っていけた気がして、書いてよかったなぁと思ってます。もし今まさにシステムの負荷対策で悩んでる人がいたら、「今どの層が一番つらいのか」を切り分けるところから始めてみると、手をつける順番が見えてくるかもしれません。よければ参考にしてみてください。
これはclaude codeで生成しました。