AWS ALB＋Lambda メンテナンスモード実装


## 目次
- [概要](#概要)
- [基本的な仕組み](#基本的な仕組みの説明)
- [主要コンポーネント詳細](#主要コンポーネント詳細)
- [動作フロー](#動作フロー)
- [実装のポイント](#実装のポイント)
- [トラブルシューティング](#トラブルシューティング)

## 概要

### メンテナンスモードとは？

メンテナンスモードは、システムのメンテナンス中に一般ユーザーのアクセスを制限し、特定のIPアドレスからのアクセスのみを許可する機能です。この機能により、以下のことが実現できます：

- 🔒 一般ユーザーへのメンテナンス画面表示
- ✅ 管理者の作業用IPアドレスからの通常アクセス維持
- ⏰ 設定された期間のみの制御
- 🔄 複数のアプリケーション（CS/CL/Tool）への個別適用

Cloudformation を利用すればIaCでコード管理もできます。

### なぜ必要か？

1. システムの安全な更新作業の実施
2. ユーザーへの明確な情報提供
3. メンテナンス中の意図しないアクセスの防止
4. 作業者の通常アクセス確保

### 対象アプリケーションの説明

メンテナンスモードは以下の3つのタイプのアプリケーションそれぞれに対して個別に制御可能です：

1. **CS (Customer Service) - エンドユーザー向けサイト**
   - 一般ユーザー向けのメインサイト
   - サービスの主要機能を提供する公開Webサイト
   - 最も多くのユーザーが利用するため、メンテナンス時の影響が大きい
   - 24時間365日のサービス提供が期待される

2. **CL (Client) - ビジネスユーザー向けサイト**
   - 取引先・パートナー企業向けの専用サイト
   - 情報登録・管理などのビジネス機能を提供

3. **Tool - 管理者向けサイト**
   - システム運用管理者向けの内部ツール
   - システム設定や各種管理機能を提供

これらのサイトは異なるユーザー層を持つため、メンテナンス時の対応も個別に検討する必要があります。例えば：
- CSは一般ユーザーへの影響を最小限にするため、深夜帯でのメンテナンスを推奨
- CLは利用企業の業務時間外での実施を考慮
- Toolは運用管理業務への影響を見ながら柔軟に対応

この構成は様々な業種・業態のWebサービスに適用可能です：
- ECサイトと出店者向け管理画面
- 教育サービスと講師用管理システム
- 予約サービスと加盟店舗向けシステム
など

## 基本的な仕組みの説明

### 全体アーキテクチャ

```mermaid
graph TD
    A[ユーザーアクセス] --> B[Application Load Balancer]
    B --> C{IP制限の確認}
    C -->|許可IP| D[通常アプリケーション]
    C -->|その他IP| E[メンテナンス画面]
    F[AWS Systems Manager] --> G[Lambda制御関数]
    G --> B
```

### 主要な制御ポイント

1. **パラメータ管理（AWS Systems Manager）**
   - メンテナンスモードの有効/無効
   - 許可IPアドレスリスト
   - メンテナンス期間

2. **アクセス制御（ALB）**
   - リスナールールによる振り分け
   - IPベースのルーティング
   - アプリケーション（CS/CL/Tool）ごとの個別制御

3. **メンテナンス画面（Lambda）**
   - 動的なHTML生成
   - メンテナンス情報の表示

## 主要コンポーネント詳細

### 1. SSMパラメータストア

各種設定値を一元管理するためのパラメータとその設定方法：

```yaml
# メンテナンス状態 (String)
Name: /${StackName}/ssm/MaintenanceStatus
Value: "enabled" or "disabled"

# 許可IPアドレス (StringList)
Name: /${StackName}/ssm/MaintenanceAllowedIpRanges
Value: "192.0.2.1/32,192.0.2.2/32"  # カンマ区切りで複数指定

# 対象ドメイン (StringList)
Name: /${StackName}/ssm/MaintenanceTargetDomains
Value: "example.com,service.example.com"  # カンマ区切りで複数指定

# メンテナンススケジュール (String - JSON形式)
Name: /${StackName}/ssm/MaintenanceSchedule
Value: |
  {
    "startTime": "2024年1月1日 00:00",
    "endTime": "2024年1月1日 06:00"
  }
```

### 2. EventBridgeとの連携

メンテナンスモードの自動制御にEventBridgeを使用します：

1. **パラメータ変更の監視**
```yaml
    # EventBridgeルール
    eventRuleMaintenanceStatusChange:
        Type: AWS::Events::Rule
        Properties:
            Description: Monitor maintenance status changes
            EventPattern: !Sub |
                {
                  "source": ["aws.ssm"],
                  "detail-type": ["Parameter Store Change"],
                  "detail": {
                    "name": ["/${AWS::StackName}/ssm/MaintenanceStatus"],
                    "operation": ["Update"],
                    "type": ["String"]
                  }
                }
            State: ENABLED
            Targets:
              - Arn: !GetAtt lambdaMaintenanceControl.Arn
                Id: UpdateMaintenanceMode
              - Arn: !Ref snsTopicMaintenanceAlert
                Id: NotifyMaintenanceModeChange
                
    lambdaPermissionForEventBridge:
        Type: AWS::Lambda::Permission
        Properties:
            Action: "lambda:InvokeFunction"
            FunctionName: !Ref lambdaMaintenanceControl
            Principal: "events.amazonaws.com"
            SourceArn: !GetAtt eventRuleMaintenanceStatusChange.Arn
```


主な自動化シナリオ：

1. **手動トリガー**
   - SSMパラメータの`MaintenanceStatus`を変更
   - EventBridgeがLambdaを呼び出し
   - メンテナンスモードの切り替えを実行

2. **スケジュールトリガー**
   - EventBridgeが定期的にスケジュールを確認
   - 開始/終了時刻に応じて自動的に状態を変更

3. **エラー通知**
   - メンテナンスモードの切り替え失敗時
   - SNSトピック経由で管理者に通知

### 2. Lambda関数

#### メンテナンス制御Lambda（lambdaMaintenanceControl）
- CS, CL, Tool のドメインの判別、設定は適宜変更してください
```python
import boto3
import json
import os

def format_ip_for_condition(ip, is_source_ip=False):
    """
    条件タイプに応じてIPアドレスを適切な形式に変換
    source-ip の場合は CIDR形式に、そうでない場合はプレーンなIP形式に
    """
    if '/' in ip:  # CIDR形式の場合
        return ip if is_source_ip else ip.split('/')[0]
    else:  # プレーンなIP形式の場合
        return f"{ip}/32" if is_source_ip else ip

def lambda_handler(event, context):
    ssm = boto3.client('ssm')
    elbv2 = boto3.client('elbv2')
    stack_name = os.environ['STACK_NAME']

    try:
        # Get environment type from stack name
        environment = stack_name.split('-')[1]  # Assuming format: "estate-{env}"
        is_production = environment == 'prd'

        # Get maintenance status from SSM Parameter Store
        status_param = ssm.get_parameter(Name=f"/{stack_name}/ssm/MaintenanceStatus")
        status = status_param['Parameter']['Value']

        # Get allowed IP ranges
        ip_ranges_param = ssm.get_parameter(Name=f"/{stack_name}/ssm/MaintenanceAllowedIpRanges")
        allowed_ips = ip_ranges_param['Parameter']['Value'].split(',')

        # Get target domains
        domains_param = ssm.get_parameter(Name=f"/{stack_name}/ssm/MaintenanceTargetDomains")
        target_domains = domains_param['Parameter']['Value'].split(',')

        if len(allowed_ips) > 5:
            print(f"Warning: Only first 5 IP ranges will be used due to ALB listener rule limitations. Provided: {len(allowed_ips)}")
            allowed_ips = allowed_ips[:5]

        apps = [
            {
                'name': 'cs',
                'target_group_arn': os.environ['TG_APP_CS_ARN'],
                'domain_pattern': lambda d: not (d.startswith('cl-') or d.startswith('tool-') or
                                            d.startswith('cl.') or d.startswith('tool.')),
                'admin_priority': 15,
                'maintenance_priority': 25
            },
            {
                'name': 'cl',
                'target_group_arn': os.environ['TG_APP_CL_ARN'],
                'domain_pattern': lambda d: d.startswith('cl-') or d.startswith('cl.'),
                'admin_priority': 16,
                'maintenance_priority': 26
            },
            {
                'name': 'tool',
                'target_group_arn': os.environ['TG_APP_TOOL_ARN'],
                'domain_pattern': lambda d: d.startswith('tool-') or d.startswith('tool.'),
                'admin_priority': 17,
                'maintenance_priority': 27
            }
        ]

        # まず既存のメンテナンス関連ルールを削除
        try:
            rules = elbv2.describe_rules(ListenerArn=os.environ['LISTENER_ARN'])['Rules']
            for app in apps:
                for priority in [app['admin_priority'], app['maintenance_priority']]:
                    existing_rule = next((r for r in rules if r['Priority'] == str(priority)), None)
                    if existing_rule:
                        elbv2.delete_rule(RuleArn=existing_rule['RuleArn'])
        except Exception as e:
            print(f"Warning: Error cleaning up maintenance rules: {str(e)}")

        # メンテナンスモードが有効な場合のみ、新しいルールを作成
        if status == 'enabled':
            for app in apps:
                # そのアプリケーションに関連するドメインをフィルタリング
                app_domains = [d for d in target_domains if app['domain_pattern'](d)]

                if not app_domains:
                    continue

                print(f"Creating rules for {app['name']} with domains: {app_domains}")

                # 環境に応じたIP制限の条件を設定
                if is_production:
                    # Production環境: True-Client-IPヘッダーを使用（プレーンなIP形式）
                    formatted_ips = [format_ip_for_condition(ip, False) for ip in allowed_ips]
                    ip_condition = {
                        'Field': 'http-header',
                        'HttpHeaderConfig': {
                            'HttpHeaderName': 'True-Client-IP',
                            'Values': formatted_ips
                        }
                    }
                else:
                    # 非Production環境: source-ipを使用（CIDR形式）
                    formatted_ips = [format_ip_for_condition(ip, True) for ip in allowed_ips]
                    ip_condition = {
                        'Field': 'source-ip',
                        'SourceIpConfig': {
                            'Values': formatted_ips
                        }
                    }

                # 1. 管理者IP向けのルール（通常アプリケーションにアクセス可能）
                admin_rule = {
                    'ListenerArn': os.environ['LISTENER_ARN'],
                    'Priority': app['admin_priority'],
                    'Conditions': [
                        {
                            'Field': 'host-header',
                            'Values': app_domains
                        },
                        ip_condition
                    ],
                    'Actions': [{
                        'Type': 'forward',
                        'TargetGroupArn': app['target_group_arn']
                    }]
                }

                # 2. その他のアクセス（メンテナンス画面に転送）
                maintenance_rule = {
                    'ListenerArn': os.environ['LISTENER_ARN'],
                    'Priority': app['maintenance_priority'],
                    'Conditions': [
                        {
                            'Field': 'host-header',
                            'Values': app_domains
                        }
                    ],
                    'Actions': [{
                        'Type': 'forward',
                        'TargetGroupArn': os.environ['MAINTENANCE_TARGET_GROUP_ARN']
                    }]
                }

                # メンテナンスルールを作成
                elbv2.create_rule(**admin_rule)
                elbv2.create_rule(**maintenance_rule)

        return {
            'statusCode': 200,
            'body': json.dumps({
                'message': f'Successfully updated maintenance mode to {status}',
                'domains': target_domains
            })
        }
    except Exception as e:
        print(f"Error: {str(e)}")
        raise e

```

#### メンテナンス画面Lambda（lambdaMaintenancePage）

```python
import json
import boto3
import logging
from datetime import datetime
from typing import Dict, Optional

logger = logging.getLogger()
logger.setLevel(logging.INFO)

class MaintenancePageGenerator:
    def __init__(self, stack_name: str):
        self.stack_name = stack_name
        self.ssm = boto3.client('ssm')

    def get_maintenance_schedule(self) -> Dict[str, str]:
        """メンテナンススケジュールの取得"""
        try:
            param = self.ssm.get_parameter(
                Name=f'/{self.stack_name}/ssm/MaintenanceSchedule'
            )
            return json.loads(param['Parameter']['Value'])
        except Exception as e:
            logger.error(f"Failed to get maintenance schedule: {str(e)}")
            return {
                'startTime': 'Not specified',
                'endTime': 'Not specified'
            }

    def generate_html(self, schedule: Dict[str, str]) -> str:
        """メンテナンス画面HTMLの生成"""
        current_year = datetime.now().year
        
        return f"""
        <!DOCTYPE html>
        <html lang="ja">
        <head>
            <meta charset="utf-8">
            <meta name="viewport" content="width=device-width, initial-scale=1.0">
            <title>システムメンテナンス中</title>
            <style>
                body {{
                    font-family: -apple-system, BlinkMacSystemFont, 
                               "Segoe UI", Roboto, "Helvetica Neue", 
                               Arial, sans-serif;
                    line-height: 1.6;
                    margin: 0;
                    padding: 20px;
                    background: #f5f5f5;
                }}
                .maintenance-container {{
                    max-width: 600px;
                    margin: 40px auto;
                    background: white;
                    padding: 30px;
                    border-radius: 8px;
                    box-shadow: 0 2px 4px rgba(0,0,0,0.1);
                }}
                .maintenance-title {{
                    font-size: 24px;
                    font-weight: bold;
                    margin-bottom: 20px;
                    color: #333;
                }}
                .maintenance-period {{
                    background: #f8f8f8;
                    padding: 15px;
                    border-radius: 4px;
                    margin: 20px 0;
                }}
                .footer {{
                    margin-top: 30px;
                    text-align: center;
                    font-size: 14px;
                    color: #666;
                }}
            </style>
        </head>
        <body>
            <div class="maintenance-container">
                <h1 class="maintenance-title">
                    システムメンテナンス中
                </h1>
                <p>
                    ご利用のシステムは現在メンテナンス中です。<br>
                    ご不便をおかけし申し訳ございません。
                </p>
                <div class="maintenance-period">
                    <p><strong>メンテナンス期間</strong></p>
                    <p>開始: {schedule['startTime']}<br>
                       終了予定: {schedule['endTime']}</p>
                </div>
                <p>
                    メンテナンス終了まで今しばらくお待ちください。
                </p>
                <div class="footer">
                    &copy; {current_year} System Administrator
                </div>
            </div>
        </body>
        </html>
        """

def lambda_handler(event: Dict, context) -> Dict:
    """Lambda handler"""
    try:
        logger.info(f"Received event: {json.dumps(event)}")
        
        # スタック名の取得
        stack_name = event.get('stack_name', 'default-stack')
        
        # メンテナンスページジェネレーターの初期化
        generator = MaintenancePageGenerator(stack_name)
        
        # スケジュールの取得
        schedule = generator.get_maintenance_schedule()
        
        # HTMLの生成
        html = generator.generate_html(schedule)
        
        # レスポンスの返却
        return {
            'statusCode': 503,
            'headers': {
                'Content-Type': 'text/html; charset=utf-8',
                'Cache-Control': 'no-cache'
            },
            'body': html
        }
        
    except Exception as e:
        logger.error(f"Error in lambda_handler: {str(e)}")
        return {
            'statusCode': 500,
            'headers': {
                'Content-Type': 'text/html; charset=utf-8'
            },
            'body': '<h1>System Error</h1><p>メンテナンス画面の表示に失敗しました。</p>'
        }
```

### 3. ALBリスナールール

本番環境のみ Akamai 利用想定です。

環境ごとの設定の違い：

| 環境 | IP制御方式 | 形式 |
|-----|-----------|------|
| 本番 | True-Client-IPヘッダー | プレーンIP |
| 開発 | source-ip | CIDR形式 |

## 動作フロー

### メンテナンスモード有効化時

```mermaid
sequenceDiagram
    participant Admin as 管理者
    participant SSM as Systems Manager
    participant Lambda as 制御Lambda
    participant ALB as Load Balancer
    
    Admin->>SSM: メンテナンスモード有効化
    SSM->>Lambda: パラメータ変更通知
    Lambda->>ALB: リスナールール作成
    Note over ALB: IP制限ルール適用
```

### アクセス時の判定フロー

```mermaid
sequenceDiagram
    participant User as ユーザー
    participant ALB as Load Balancer
    participant App as アプリケーション
    participant Maintenance as メンテナンス画面
    
    User->>ALB: アクセス
    ALB->>ALB: IP判定
    alt 許可IP
        ALB->>App: 通常アクセス
    else その他IP
        ALB->>Maintenance: メンテナンス画面表示
    end
```

## 実装のポイント

### 1. 優先順位の設計

ALBリスナールールの優先順位が重要です：

```plaintext
優先度の階層:
15-17: 管理者アクセス用ルール（IP制限あり）
25-27: 一般ユーザー用ルール（メンテナンス画面）

CS: 15/25
CL: 16/26
Tool: 17/27
```

### 2. IP制限の実装

環境による違いを考慮した実装：

```python
# 本番環境の場合
if is_production:
    ip_condition = {
        'Field': 'http-header',
        'HttpHeaderConfig': {
            'HttpHeaderName': 'True-Client-IP',
            'Values': formatted_ips
        }
    }
else:
    # 開発環境の場合
    ip_condition = {
        'Field': 'source-ip',
        'SourceIpConfig': {
            'Values': formatted_ips
        }
    }
```

### 3. エラーハンドリング

主要なエラーケースと対応：

1. SSMパラメータ取得エラー
   ```python
   try:
       params = ssm.get_parameters(...)
   except ClientError as e:
       logger.error(f"SSMパラメータ取得エラー: {e}")
       # エラー時のフォールバック処理
   ```

2. ALBルール作成エラー
   ```python
   try:
       elbv2.create_rule(...)
   except Exception as e:
       logger.error(f"ルール作成エラー: {e}")
       # クリーンアップ処理
   ```

## トラブルシューティング

### よくある問題と解決方法

1. メンテナンスモードが有効にならない
   - SSMパラメータの値を確認
   - Lambda関数のログを確認
   - EventBridgeルールの状態確認

2. 特定のIPからアクセスできない
   - IP形式が環境に合っているか確認
   - リスナールールの優先順位確認
   - IPアドレスリストの形式確認

3. メンテナンス画面が表示されない
   - Lambda関数のログ確認
   - ALBターゲットグループの健全性確認
   - HTMLレスポンスの状態確認

### デバッグ方法

1. CloudWatch Logsの確認
   ```bash
   # 必要なロググループ
   /aws/lambda/lambdaMaintenanceControl
   /aws/lambda/lambdaMaintenancePage
   ```

2. ALBルールの確認
   ```bash
   # AWS CLIでのルール一覧取得
   aws elbv2 describe-rules --listener-arn [LISTENER-ARN]
   ```

3. SSMパラメータの確認
   ```bash
   # パラメータ値の確認
   aws ssm get-parameter --name "/[StackName]/ssm/MaintenanceStatus"
   ```


## ベストプラクティス

1. メンテナンス実施前の確認事項
   - 許可IPアドレスの事前確認
   - メンテナンス期間の適切な設定
   - 影響範囲の確認

2. 実装時の注意点
   - 環境別の適切な設定
   - エラーハンドリングの実装
   - ログ出力の充実

3. 運用上の推奨事項
   - 定期的な動作確認
   - 設定値の定期レビュー
   - 手順書の整備と更新


以上、お疲れ様でした！