ロードバランシング — トラフィックを分散する
渋滞する交差点
3台のECSタスクが立ち上がった。しかし、ハルトには新たな疑問があった。
「どのサーバーにどのリクエストを送ればいい?ユーザーはどのIPアドレスにアクセスすればいい?」
これを解決するのがロードバランサーだ。ロードバランサーは交通整理の警察官のように、来たリクエストを適切なサーバーへ振り分ける。
L4 vs L7 — どのレイヤーで分散するか
ロードバランサーを語るうえで外せない概念が OSIモデル だ。ネットワーク通信を7層に分けて定義したこのモデルのどの層で処理するかによって、ロードバランサーの機能と性能が大きく変わる。
OSI参照モデルとロードバランサーの関係
| OSI層 | 名称 | 扱う情報 | 代表プロトコル |
|---|---|---|---|
| L7 | アプリケーション層 | HTTPヘッダー、URL、Cookie | HTTP/HTTPS |
| L6 | プレゼンテーション層 | 暗号化・圧縮 | TLS/SSL |
| L4 | トランスポート層 | ポート番号、TCPセッション | TCP/UDP |
| L3 | ネットワーク層 | IPアドレス | IP |
L4ロードバランサー(NLB) はTCP/UDPレベルで動作する。HTTPの中身は見ず、送信元/宛先IPとポートだけでルーティングを決める。処理がシンプルなぶん高速で、超低レイテンシが求められる場面に向いている。
L7ロードバランサー(ALB) はHTTPの中身まで見てルーティングを決める。URLパス・HTTPヘッダー・Cookieを使った高度な振り分けが可能だ。Webアプリには通常こちらを使う。
L4 ロードバランサーが見るもの:
送信元IP: 203.0.113.5
宛先IP: 10.0.1.100
ポート: 443
→ これだけでルーティングを決める
L7 ロードバランサーが見るもの:
GET /api/tasks HTTP/1.1
Host: echotask.example.com
Cookie: session_token=xxxx
→ HTTPの中身を解析してルーティングを決める
AWSのロードバランサー比較
| 種類 | レイヤー | 用途 | 特徴 |
|---|---|---|---|
| ALB | L7 (HTTP/HTTPS) | Webアプリ全般 | パスベース・ホストベースルーティング、WebSocket対応 |
| NLB | L4 (TCP/UDP) | gRPC、ゲーム、低レイテンシ | 静的IP、超高スループット、TLSパススルー |
| CLB | L4/L7 | 旧世代システム | 非推奨。新規利用は避ける |
EchoTaskのRailsアプリにはALBが最適だ。ただし、後述するgRPC通信が加わった場合はNLBも検討対象になる。
Nginxをフロントに置くパターン
AWSのALBだけでなく、NginxをGoアプリやRailsアプリの前段ロードバランサーとして使う構成もよく見かける。オンプレや小規模構成、ALBとの二段構えで使う場面だ。
Nginxのupstream設定
# /etc/nginx/nginx.conf
http {
# バックエンドサーバー群の定義
upstream echotask_backend {
least_conn; # 最少コネクション方式
server app1.internal:3000 weight=3;
server app2.internal:3000 weight=3;
server app3.internal:3000 weight=1; # スペックが低いサーバー
keepalive 32; # バックエンドへの持続接続数
keepalive_requests 100;
keepalive_timeout 60s;
}
server {
listen 80;
server_name echotask.example.com;
location / {
proxy_pass http://echotask_backend;
proxy_http_version 1.1;
# keepaliveを使うために必須
proxy_set_header Connection "";
# 実クライアントIPをバックエンドに伝える
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header Host $host;
proxy_connect_timeout 5s;
proxy_read_timeout 60s;
}
# ヘルスチェック用エンドポイント(Nginxが直接返す)
location /nginx-health {
access_log off;
return 200 "healthy\n";
add_header Content-Type text/plain;
}
}
}keepalive 32 の設定が重要だ。Nginxからバックエンドへの接続をTCP接続ごとに新規作成するのではなく、最大32本のコネクションを使いまわす。TCPハンドシェイクのコストが削減され、高負荷時のレイテンシが改善する。
weight パラメーターで各サーバーの重みを指定できる。スペックが高いサーバーに重みを高く設定すれば、比例した量のリクエストが流れる。
Golangでのヘルスチェックエンドポイント
Railsアプリがメインだが、EchoTaskでは非同期処理の一部をGoのマイクロサービスに切り出した。GoサービスにもALBのヘルスチェックに応答するエンドポイントが必要だ。
// cmd/server/main.go
package main
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"log"
"net/http"
"os"
"time"
_ "github.com/lib/pq"
"github.com/redis/go-redis/v9"
)
type HealthResponse struct {
Status string `json:"status"`
Checks map[string]string `json:"checks"`
Timestamp time.Time `json:"timestamp"`
}
var (
db *sql.DB
rdb *redis.Client
)
func healthHandler(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 3*time.Second)
defer cancel()
checks := make(map[string]string)
status := "ok"
// データベース疎通確認
if err := db.PingContext(ctx); err != nil {
checks["database"] = fmt.Sprintf("error: %s", err.Error())
status = "degraded"
} else {
checks["database"] = "ok"
}
// Redis疎通確認
if _, err := rdb.Ping(ctx).Result(); err != nil {
checks["redis"] = fmt.Sprintf("error: %s", err.Error())
status = "degraded"
} else {
checks["redis"] = "ok"
}
resp := HealthResponse{Status: status, Checks: checks, Timestamp: time.Now().UTC()}
w.Header().Set("Content-Type", "application/json")
if status != "ok" {
w.WriteHeader(http.StatusServiceUnavailable)
}
json.NewEncoder(w).Encode(resp)
}
func main() {
var err error
db, err = sql.Open("postgres", os.Getenv("DATABASE_URL"))
if err != nil {
log.Fatal(err)
}
db.SetMaxOpenConns(25)
db.SetMaxIdleConns(5)
rdb = redis.NewClient(&redis.Options{Addr: os.Getenv("REDIS_URL")})
mux := http.NewServeMux()
mux.HandleFunc("/health", healthHandler)
log.Fatal(http.ListenAndServe(":8080", mux))
}/health に対してALBが30秒ごとにリクエストを送り、200が返れば正常・503が返れば異常と判定する。DBやRedisが落ちたときに自動でトラフィックを停止できる。
ロードバランシングアルゴリズム
「振り分け方」にはいくつかの戦略がある。ALBはラウンドロビンを基本とし、Nginxは設定で選択できる。
| アルゴリズム | 動作 | 適した場面 |
|---|---|---|
| ラウンドロビン | 順番に各サーバーへ割り当て | スペックが均一でリクエスト処理時間も一定 |
| 最少コネクション | 接続数が最も少ないサーバーへ | 処理時間にばらつきがある(重い処理と軽い処理が混在) |
| IPハッシュ | クライアントIPで同じサーバーに固定 | セッションをサーバー内に持つ構成(非推奨) |
Nginxでの設定は upstream ブロックの先頭に least_conn; または ip_hash; を書くだけで切り替えられる。EchoTaskではRedisでセッションを外部化したため、ラウンドロビンで十分だ。
ALBの詳細設定 — パスベースルーティング
ALBの強みはHTTPの中身を見てルーティングを変えられる点だ。EchoTaskでは以下のように複数のサービスを1つのALBで捌いている。
# CloudFormation — パスベースルーティング設定(抜粋)
Listener:
Type: AWS::ElasticLoadBalancingV2::Listener
Properties:
LoadBalancerArn: !Ref LoadBalancer
Port: 443
Protocol: HTTPS
Certificates:
- CertificateArn: !Ref ACMCertificate
DefaultActions:
- Type: forward
TargetGroupArn: !Ref WebTargetGroup
# /api/* → APIサービス
ApiListenerRule:
Type: AWS::ElasticLoadBalancingV2::ListenerRule
Properties:
ListenerArn: !Ref Listener
Priority: 10
Conditions:
- Field: path-pattern
Values: ["/api/*"]
Actions:
- Type: forward
TargetGroupArn: !Ref ApiTargetGroup
# /admin/* → 社内IPのみ許可
AdminListenerRule:
Type: AWS::ElasticLoadBalancingV2::ListenerRule
Properties:
ListenerArn: !Ref Listener
Priority: 20
Conditions:
- Field: path-pattern
Values: ["/admin/*"]
- Field: source-ip
SourceIpConfig:
Values: ["203.0.113.0/24"]
Actions:
- Type: forward
TargetGroupArn: !Ref AdminTargetGroup
WebTargetGroup:
Type: AWS::ElasticLoadBalancingV2::TargetGroup
Properties:
Name: echo-task-web
Port: 3000
Protocol: HTTP
VpcId: !Ref VPC
TargetType: ip
HealthCheckPath: /health
HealthCheckIntervalSeconds: 30
HealthyThresholdCount: 2
UnhealthyThresholdCount: 3パスベースに加えて、Host ヘッダーを見る「ホストベースルーティング」も使える。api.echotask.com と app.echotask.com を1台のALBで捌くような構成が可能だ。
ALBアクセスログの分析 — S3 + Athena
本番環境ではアクセスログを保存して分析することが不可欠だ。ALBはアクセスログをS3に自動出力できる。
S3バケットへのアクセスログ出力設定
# S3バケットを作成し、ALBのアクセスログを有効化
aws s3 mb s3://echo-task-alb-access-logs --region ap-northeast-1
# ALBのアクセスログをS3バケットに出力する設定
aws elbv2 modify-load-balancer-attributes \
--load-balancer-arn arn:aws:elasticloadbalancing:ap-northeast-1:123456789012:loadbalancer/app/echo-task-alb/xxxx \
--attributes \
Key=access_logs.s3.enabled,Value=true \
Key=access_logs.s3.bucket,Value=echo-task-alb-access-logs \
Key=access_logs.s3.prefix,Value=echo-task
# S3バケットポリシー(ALBがPutObjectできるよう許可)
aws s3api put-bucket-policy \
--bucket echo-task-alb-access-logs \
--policy '{
"Statement": [{
"Effect": "Allow",
"Principal": {"Service": "delivery.logs.amazonaws.com"},
"Action": "s3:PutObject",
"Resource": "arn:aws:s3:::echo-task-alb-access-logs/*"
}]
}'AthenaでALBアクセスログを分析する
テーブルを作成したら、以下のSQLで問題の切り分けができる。
-- 直近1時間でレスポンスタイムが1秒超のリクエストを調べる
SELECT
time,
client_ip,
request,
target_processing_time,
elb_status_code
FROM alb_access_logs
WHERE
target_processing_time > 1.0
AND time >= date_format(date_add('hour', -1, now()), '%Y-%m-%dT%H:%i')
ORDER BY target_processing_time DESC
LIMIT 50;
-- 5xxエラーの発生状況を1分ごとに集計
SELECT
date_trunc('minute', from_iso8601_timestamp(time)) AS minute,
COUNT(*) AS error_count
FROM alb_access_logs
WHERE elb_status_code >= 500
GROUP BY 1
ORDER BY 1;
-- IPアドレス別リクエスト数(DDoS兆候の検出)
SELECT
client_ip,
COUNT(*) AS request_count
FROM alb_access_logs
WHERE time >= date_format(date_add('minute', -10, now()), '%Y-%m-%dT%H:%i')
GROUP BY client_ip
HAVING COUNT(*) > 1000
ORDER BY request_count DESC;INFO
Athenaのコスト最適化
ALBアクセスログをAthenaで分析するとき、パーティションを切ることでスキャン量を減らしてコストを削減できる。PARTITIONED BY (year STRING, month STRING, day STRING) を設定し、クエリ時に WHERE year='2024' AND month='01' AND day='15' のように絞り込む。ログ量が多い場合は必須のテクニックだ。
Blue-Greenデプロイ — TargetGroupの切り替え
ALBのTargetGroupを2系統用意することで、ダウンタイムゼロのデプロイが実現できる。
Blue-Greenデプロイの仕組み
# CloudFormation — Blue/Green用のTargetGroupを2つ定義
BlueTargetGroup:
Type: AWS::ElasticLoadBalancingV2::TargetGroup
Properties:
Name: echo-task-blue
Port: 3000
Protocol: HTTP
VpcId: !Ref VPC
TargetType: ip
HealthCheckPath: /health
HealthCheckIntervalSeconds: 15
HealthyThresholdCount: 2
GreenTargetGroup:
Type: AWS::ElasticLoadBalancingV2::TargetGroup
Properties:
Name: echo-task-green
Port: 3000
Protocol: HTTP
VpcId: !Ref VPC
TargetType: ip
HealthCheckPath: /health
HealthCheckIntervalSeconds: 15
HealthyThresholdCount: 2デプロイの手順は以下だ。
- Blue環境(現行) にトラフィックが流れている
- Green環境(新バージョン) を起動し、ALBのヘルスチェックが通るまで待つ
- ALBのListenerをGreenTargetGroupに切り替える(数秒で完了)
- 問題があれば即座にBlueに戻す
# Green環境のECSサービスを新バージョンで更新
aws ecs update-service \
--cluster echo-task \
--service echo-task-green \
--task-definition echo-task:42
# ヘルスチェックが通るまで待機
aws ecs wait services-stable \
--cluster echo-task \
--services echo-task-green
# ALBのListenerをGreenに切り替え
aws elbv2 modify-listener \
--listener-arn arn:aws:elasticloadbalancing:ap-northeast-1:123456789012:listener/app/echo-task-alb/xxxx/yyyy \
--default-actions Type=forward,TargetGroupArn=arn:aws:elasticloadbalancing:ap-northeast-1:123456789012:targetgroup/echo-task-green/zzzz
echo "Green環境への切り替え完了"
# 問題発生時はBlueに即座に戻す
# aws elbv2 modify-listener ... TargetGroupArn=<BlueのARN>WARNING
Blue-Green切り替え直後の注意
TargetGroupを切り替えた直後は、古いBlue環境への接続がまだ生きている。ALBのドレイニング期間(デフォルト300秒)が終わるまでBlue環境を落とさないこと。deregistration_delay.timeout_seconds を短くすれば切り替え完了を早められるが、長時間の処理(ファイルアップロードなど)が途中で切れるリスクがある。
スティッキーセッションの罠とJWT/Redis代替
「同じユーザーが毎回同じサーバーに行くほうがよいのでは?」
ハルトはスティッキーセッションを検討したが、これは危険な選択だった。
スティッキーセッションを有効にすると、特定のユーザーが特定のサーバーに固定される。サーバーが増減するたびに既存ユーザーのセッションが切れる。特定サーバーに負荷が偏り、水平スケールの恩恵が失われる。
WARNING
スティッキーセッションはバンドエイドに過ぎない
スティッキーセッションはステートフルなアーキテクチャの応急処置だ。根本的にはセッションを外部ストアに移すべき。スティッキーセッションを使い続けると、サーバー増減のたびに既存ユーザーのセッションが切れ、クレームにつながる。
JWTによるステートレス認証
セッションをサーバーに持たない設計に変える。JWTを使えばサーバー側にセッション情報を保持する必要がなくなる。
# Gemfile
gem 'jwt'
# app/services/token_service.rb
class TokenService
SECRET_KEY = Rails.application.credentials.jwt_secret_key
EXPIRY = 24.hours
def self.encode(payload)
payload[:exp] = EXPIRY.from_now.to_i
JWT.encode(payload, SECRET_KEY, 'HS256')
end
def self.decode(token)
decoded = JWT.decode(token, SECRET_KEY, true, algorithm: 'HS256')
decoded[0].with_indifferent_access
rescue JWT::ExpiredSignature
raise AuthenticationError, 'Token expired'
rescue JWT::DecodeError
raise AuthenticationError, 'Invalid token'
end
end
# app/controllers/sessions_controller.rb
class SessionsController < ApplicationController
def create
user = User.find_by(email: params[:email])
unless user&.authenticate(params[:password])
return render json: { error: 'Invalid credentials' }, status: :unauthorized
end
token = TokenService.encode({ user_id: user.id, email: user.email })
render json: { token: token, expires_in: TokenService::EXPIRY.to_i }
end
end
# app/controllers/concerns/authenticatable.rb
module Authenticatable
extend ActiveSupport::Concern
included do
before_action :authenticate_user!
end
def authenticate_user!
header = request.headers['Authorization']
token = header&.split(' ')&.last
raise AuthenticationError, 'No token provided' unless token
@current_user_payload = TokenService.decode(token)
rescue AuthenticationError => e
render json: { error: e.message }, status: :unauthorized
end
endRedisでセッション管理(JWTのブラックリスト)
JWTはステートレスなので「ログアウトしても有効期限まで使える」問題がある。Redisでブラックリストを管理することで解決する。
# app/services/token_blacklist_service.rb
class TokenBlacklistService
def self.revoke(token, expiry)
jti = TokenService.decode(token)[:jti]
Redis.current.setex("blacklist:#{jti}", expiry.to_i, '1')
end
def self.revoked?(token)
jti = TokenService.decode(token)[:jti]
Redis.current.exists?("blacklist:#{jti}")
end
end
# app/controllers/sessions_controller.rb(ログアウト)
def destroy
token = request.headers['Authorization']&.split(' ')&.last
if token
payload = TokenService.decode(token)
remaining = payload[:exp] - Time.current.to_i
TokenBlacklistService.revoke(token, remaining)
end
head :no_content
endNLBを使うケース — gRPCと低レイテンシ要件
EchoTaskにタスク通知機能が追加された。モバイルアプリとのリアルタイム通信にgRPCを採用することになり、ここでNLBの出番が来た。
gRPCはHTTP/2の双方向ストリーミングを使う。ALBもgRPCをサポートするが、以下の場面ではNLBが有利だ。
- TLSパススルー: NLBはTLSを終端せずバックエンドまでそのまま通す。gRPCのmTLS(相互TLS認証)に必要
- 超低レイテンシ: L4処理のみでオーバーヘッドが小さい。金融系や高頻度通信に向く
- 静的IPアドレス: NLBには固定IPが割り当てられる。ファイアウォールルールにIPを指定するパートナー連携に必須
# NLB + gRPC用 TargetGroup
NotificationNLB:
Type: AWS::ElasticLoadBalancingV2::LoadBalancer
Properties:
Name: echo-task-nlb
Type: network
Scheme: internal
Subnets: [!Ref PrivateSubnet1, !Ref PrivateSubnet2]
NLBListener:
Type: AWS::ElasticLoadBalancingV2::Listener
Properties:
LoadBalancerArn: !Ref NotificationNLB
Port: 50051
Protocol: TCP
DefaultActions:
- Type: forward
TargetGroupArn: !Ref GRPCTargetGroup
GRPCTargetGroup:
Type: AWS::ElasticLoadBalancingV2::TargetGroup
Properties:
Name: echo-task-grpc
Port: 50051
Protocol: TCP
VpcId: !Ref VPC
TargetType: ip
HealthCheckProtocol: TCP
HealthCheckIntervalSeconds: 10Route53フェイルオーバー設定
リージョン障害に備えて、Route53のヘルスチェック付きフェイルオーバーを設定する。東京が落ちたときに大阪へ自動切り替えする構成だ。
# Route53 ヘルスチェック + フェイルオーバー
HealthCheckTokyo:
Type: AWS::Route53::HealthCheck
Properties:
HealthCheckConfig:
Type: HTTPS
FullyQualifiedDomainName: echo-task-alb-tokyo.ap-northeast-1.elb.amazonaws.com
Port: 443
ResourcePath: /health
RequestInterval: 30
FailureThreshold: 3 # 3回連続失敗でフェイルオーバー発動
DNSPrimary:
Type: AWS::Route53::RecordSet
Properties:
HostedZoneId: !Ref HostedZone
Name: echotask.example.com
Type: A
SetIdentifier: primary-tokyo
Failover: PRIMARY
HealthCheckId: !Ref HealthCheckTokyo
AliasTarget:
DNSName: !GetAtt TokyoLoadBalancer.DNSName
EvaluateTargetHealth: true
HostedZoneId: !GetAtt TokyoLoadBalancer.CanonicalHostedZoneID
DNSSecondary:
Type: AWS::Route53::RecordSet
Properties:
HostedZoneId: !Ref HostedZone
Name: echotask.example.com
Type: A
SetIdentifier: secondary-osaka
Failover: SECONDARY
AliasTarget:
DNSName: !GetAtt OsakaLoadBalancer.DNSName
EvaluateTargetHealth: true
HostedZoneId: !GetAtt OsakaLoadBalancer.CanonicalHostedZoneIDEchoTaskの完成した構成
ハルトはALBを導入し、Blue-GreenデプロイとRoute53フェイルオーバーも整えた。
レスポンスタイムは正常に戻った。1台のサーバーが落ちても、残り2台で処理が続く。Blue-Greenデプロイにより、デプロイ中もユーザーへの影響はゼロだ。
しかし、次の問題が見えてきた。
「3台のRailsサーバーが、全部同じ1台のPostgreSQLに接続している。データベースがボトルネックになりそうだ。」
次の章では、データベース設計の基礎を学ぶ。
INFO
この章のキーポイント
- L4(NLB)はTCP/UDPレベルの高速処理、L7(ALB)はHTTPの中身を見た高度なルーティング
- NginxをフロントのLBとして使う場合は
upstream+keepaliveの設定が重要 - GoのヘルスチェックエンドポイントはDBとRedisの疎通を確認して503を返す
- ALBのアクセスログをS3に保存し、AthenaでSQLクエリを使って分析する
- Blue-Greenデプロイは2つのTargetGroupを切り替えることでダウンタイムゼロを実現する
- スティッキーセッションは使わず、JWTとRedisでステートレスな認証を実装する
- gRPCや低レイテンシ要件にはNLB、WebアプリにはALBを選ぶ
- Route53のヘルスチェック付きフェイルオーバーでリージョン障害にも対応する