mybook

ロードバランシング — トラフィックを分散する

渋滞する交差点

3台のECSタスクが立ち上がった。しかし、ハルトには新たな疑問があった。

「どのサーバーにどのリクエストを送ればいい?ユーザーはどのIPアドレスにアクセスすればいい?」

これを解決するのがロードバランサーだ。ロードバランサーは交通整理の警察官のように、来たリクエストを適切なサーバーへ振り分ける。

Loading diagram...

L4 vs L7 — どのレイヤーで分散するか

ロードバランサーを語るうえで外せない概念が OSIモデル だ。ネットワーク通信を7層に分けて定義したこのモデルのどの層で処理するかによって、ロードバランサーの機能と性能が大きく変わる。

OSI参照モデルとロードバランサーの関係

OSI層名称扱う情報代表プロトコル
L7アプリケーション層HTTPヘッダー、URL、CookieHTTP/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のロードバランサー比較

種類レイヤー用途特徴
ALBL7 (HTTP/HTTPS)Webアプリ全般パスベース・ホストベースルーティング、WebSocket対応
NLBL4 (TCP/UDP)gRPC、ゲーム、低レイテンシ静的IP、超高スループット、TLSパススルー
CLBL4/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で捌いている。

Loading diagram...
# 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.comapp.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

デプロイの手順は以下だ。

  1. Blue環境(現行) にトラフィックが流れている
  2. Green環境(新バージョン) を起動し、ALBのヘルスチェックが通るまで待つ
  3. ALBのListenerをGreenTargetGroupに切り替える(数秒で完了)
  4. 問題があれば即座に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
end

Redisでセッション管理(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
end

NLBを使うケース — 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: 10

Route53フェイルオーバー設定

リージョン障害に備えて、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.CanonicalHostedZoneID

EchoTaskの完成した構成

ハルトはALBを導入し、Blue-GreenデプロイとRoute53フェイルオーバーも整えた。

Loading diagram...

レスポンスタイムは正常に戻った。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のヘルスチェック付きフェイルオーバーでリージョン障害にも対応する