0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

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

Posted at

目次

概要

メンテナンスモードとは?

メンテナンスモードは、システムのメンテナンス中に一般ユーザーのアクセスを制限し、特定の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サイトと出店者向け管理画面
  • 教育サービスと講師用管理システム
  • 予約サービスと加盟店舗向けシステム
    など

基本的な仕組みの説明

全体アーキテクチャ

主要な制御ポイント

  1. パラメータ管理(AWS Systems Manager)

    • メンテナンスモードの有効/無効
    • 許可IPアドレスリスト
    • メンテナンス期間
  2. アクセス制御(ALB)

    • リスナールールによる振り分け
    • IPベースのルーティング
    • アプリケーション(CS/CL/Tool)ごとの個別制御
  3. メンテナンス画面(Lambda)

    • 動的なHTML生成
    • メンテナンス情報の表示

主要コンポーネント詳細

1. SSMパラメータストア

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

# メンテナンス状態 (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. パラメータ変更の監視
    # 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 のドメインの判別、設定は適宜変更してください
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)

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形式

動作フロー

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

アクセス時の判定フロー

実装のポイント

1. 優先順位の設計

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

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

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

2. IP制限の実装

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

# 本番環境の場合
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パラメータ取得エラー

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

    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の確認

    # 必要なロググループ
    /aws/lambda/lambdaMaintenanceControl
    /aws/lambda/lambdaMaintenancePage
    
  2. ALBルールの確認

    # AWS CLIでのルール一覧取得
    aws elbv2 describe-rules --listener-arn [LISTENER-ARN]
    
  3. SSMパラメータの確認

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

ベストプラクティス

  1. メンテナンス実施前の確認事項

    • 許可IPアドレスの事前確認
    • メンテナンス期間の適切な設定
    • 影響範囲の確認
  2. 実装時の注意点

    • 環境別の適切な設定
    • エラーハンドリングの実装
    • ログ出力の充実
  3. 運用上の推奨事項

    • 定期的な動作確認
    • 設定値の定期レビュー
    • 手順書の整備と更新

以上、お疲れ様でした!

0
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?