背景

Apache のリダイレクトルールを追加するという作業依頼が月に15件ほどあり、効率化のためにSSM Automation で自動化をしました。
作業内容は↓の感じです。
1. サーバーにssh接続する
2. リダイレクト設定ファイルに vi コマンドで設定を追加
3. Apache をリロード
4. リダイレクト元URLに curl を実行し、意図通りのリダイレクトができているかを確認

作業内容としては、簡単ですが、ssh 接続をしたり vi コマンドでの操作があったり、地味なリスクポイントが多数あります。
そのため、ミスが起こらないように厳格な手順書を毎回作成していました。
それが月に15件もあるとかなり時間を取られます。
手順書もどれがどれだかわからなくなりそうで、かなり不安な状態で作業をしていたので、今回自動化をしてみました。

どうやってやったか

以下の図の通りで、SSM RunCommand や Lambda、Bedrock を使った処理を SSM Automation で自動化しています。

  • SSM Automation
    • ワークフロー全体の設計図
    • yaml で定義
    • どの順番で Lambda や RunCommand を実行するかを定義
  • SSM Run Command
    • EC2内の設定ファイルの書き換えを実行
    • 実行するシェルを Automation の yaml 内で定義
  • Lambda ①
    • リダイレクト確認時のリダイレクト元となるURLを生成する
    • 複雑な正規表現で指定されたリダイレクトルールに対応するために、Bedrock を使用してリダイレクト元URLを生成する
  • Lambda ②
    • リダイレクト元URLにアクセスし、正常にリダイレクトが実行されることを確認する

前提として、操作対象のEC2にSSMエージェントがインストールされている必要があります。
また、使用するAI のモデルを対象リージョンのBedrock で使用できるようにリクエストをしておく必要があります。

実際のコード

実際のコードは以下の通りに作成しました。

SSM Automation
schemaVersion: '0.3'
# このAutomationが、使用するIAMロールを指定
assumeRole: arn:aws:iam::xxxxxxxxx:role/y-ishikawa-ssm-automation-role

# --- Automation実行時に受け取るパラメータ ---
# このAutomationを実行する際に、ユーザーが指定する必要がある情報を定義
parameters:
  InstanceId:
    type: String
    description: '(必須) 設定変更対象のEC2インスタンスID (例: i-0123456789abcdef0)'
  AddRules:
    type: String
    description: (必須) 設定ファイルの先頭に追加する書き換えルール。複数行の場合は改行で区切ります。
  TaskId:
    type: String
    description: (必須) backlogの課題キー。バックアップファイル名に使用します。
  BaseDomain:
    type: String
    description: '(必須) リダイレクトURLを組み立てるためのベースドメイン (例: https://www.example.com)'
  ConfigFile:
    type: String
    description: (必須) 変更対象の設定ファイルのフルパス。

# --- 自動化のメイン処理ステップ ---
mainSteps:
  # === ステップ1: 設定ファイルのバックアップ、ルールの追加、構文チェック ===
  - name: AddConfigAndTest
    action: aws:runCommand # EC2インスタンス上でコマンドを実行するアクション
    nextStep: GenerateUrl  # このステップが成功したら、次に 'GenerateUrl' ステップに進む
    isEnd: false           # このステップは最終ステップではない
    inputs:
      DocumentName: AWS-RunShellScript # AWSが提供する標準のシェルスクリプト実行ドキュメントを使用
      InstanceIds:
        - '{{ InstanceId }}' # パラメータで受け取ったインスタンスIDを対象とする
      Parameters:
        commands:
          - |
            #!/bin/bash
            # set -e: コマンドがエラーで終了したらスクリプトを即座に終了する
            # set -u: 未定義の変数を使おうとしたらエラーにする
            # set -x: 実行するコマンドをログに出力する
            # set -o pipefail: パイプの途中でコマンドが失敗した場合もエラーにする
            set -euxo pipefail

            # --- パラメータを変数に格納 ---
            TASK_ID="{{ TaskId }}"
            CONFIG_FILE="{{ ConfigFile }}"
            DATE_STR=$(date +%Y%m%d)
            BACKUP_FILE="${CONFIG_FILE}.${DATE_STR}-${TASK_ID}"

            # --- 1. ファイルのバックアップ作成 ---
            echo "--- 1. ファイルのバックアップ作成 ---"
            # -v: 処理内容を表示, -p: パーミッションなどを維持
            cp -ipv "$CONFIG_FILE" "$BACKUP_FILE"

            # --- 2. 設定の追加 ---
            # 直接 sed で書き換えるのではなく、一時ファイルを利用することで、より安全にファイル内容を更新する
            echo "--- 2. 設定の追加 ---"
            # ヒアドキュメントを使い、複数行のルールを一時ファイルに書き出す
            cat << EOF > /tmp/new_rules_{{automation:EXECUTION_ID}}.txt
            {{ AddRules }}
            EOF
            # 新しいルールと既存の設定ファイルを結合して、新しい設定ファイルを作成
            cat /tmp/new_rules_{{automation:EXECUTION_ID}}.txt "$CONFIG_FILE" > /tmp/config_new_{{automation:EXECUTION_ID}}.txt
            # 作成した新しい設定ファイルで、既存のファイルを上書きする
            mv -v /tmp/config_new_{{automation:EXECUTION_ID}}.txt "$CONFIG_FILE"
            # 一時ファイルを削除
            rm -f /tmp/new_rules_{{automation:EXECUTION_ID}}.txt

            # --- 3. 変更内容の確認 ---
            echo "--- 3. 変更内容の確認 ---"
            # バックアップと現在のファイルの差分を表示する。
            # "|| true" は、差分があった場合にdiffコマンドがエラーコードを返しても、スクリプトを続行させるためのおまじない。
            diff -u "$BACKUP_FILE" "$CONFIG_FILE" || true

            # --- 4. 設定ファイルの構文チェック ---
            echo "--- 4. 設定ファイルの構文チェック ---"
            # Apacheの設定ファイルに文法ミスがないかチェックする。
            # このコマンドが失敗した場合(exit codeが0以外)、'set -e' によりスクリプト全体が失敗し、Automationが停止する。
            if ! service httpd configtest; then
              echo "Apache configtestに失敗しました。処理を中断します。"
              exit 1
            fi
            echo "Apache configtestに成功しました。"

  # === ステップ2: Bedrock(AI)を使ってリダイレクト元URLを生成 ===
  - name: GenerateUrl
    action: aws:invokeLambdaFunction # Lambda関数を呼び出すアクション
    nextStep: ApplyConfig           # 成功したら 'ApplyConfig' ステップに進む
    isEnd: false
    inputs:
    # 呼び出すLambda関数のARN
      FunctionName: arn:aws:lambda:ap-northeast-1:xxxxxxxxx:function:y-ishikawa-url-generator-lambda 
      InputPayload: # Lambda関数に渡すデータ (eventオブジェクトになる)
        AddRules: '{{ AddRules }}'     # 追加するリダイレクト ルール
        BaseDomain: '{{ BaseDomain }}' # URLのベースとなるドメイン
    outputs: # Lambdaからの返り値を取り出して、後のステップで使えるようにする
      - Name: GeneratedUrls # 'GeneratedUrls' という名前で保存
        Selector: "$.urls"  # Lambdaの返り値(JSON)の 'urls' というキーの値を取得 ($.はルートを示すJSONPath)
        Type: StringList    # 取得する値の型は文字列のリスト

  # === ステップ3: Apacheに設定を適用(リロード) ===
  - name: ApplyConfig
    action: aws:runCommand
    nextStep: VerifyRedirects # 成功したら 'VerifyRedirects' ステップに進む
    isEnd: false
    inputs:
      DocumentName: AWS-RunShellScript
      InstanceIds:
        - '{{ InstanceId }}'
      Parameters:
        commands:
          - |
            #!/bin/bash
            set -euxo pipefail
            echo "--- 5. 設定の適用 ---"
            # Apacheのサービスを停止せずに設定を再読み込みさせる
            systemctl reload httpd

            # リロード後、httpdサービスが正常に動作しているかを確認する
            if ! systemctl is-active --quiet httpd; then
                echo "httpdサービスがアクティブではありません。リロードに失敗した可能性があります。"
                systemctl status httpd # 詳細なステータスを出力
                exit 1 # エラーで終了し、Automationを停止
            fi
            echo "httpdは正常にリロードされ、アクティブです。"
            systemctl status httpd

  # === ステップ4: Lambdaを使ってリダイレクトを実際に検証 ===
  - name: VerifyRedirects
    action: aws:invokeLambdaFunction
    nextStep: FinishSuccessfully # 成功したら 'FinishSuccessfully' ステップに進む
    isEnd: false
    inputs:
      FunctionName: arn:aws:lambda:ap-northeast-1:xxxxxxxx:function:y-ishikawa-redirect-verifier-lambda
      InputPayload:
        # 前の 'GenerateUrl' ステップで生成し、保存しておいたURLリストをLambdaに渡す
        urls: '{{ GenerateUrl.GeneratedUrls }}'

  # === ステップ5: 正常終了 ===
  - name: FinishSuccessfully
    action: aws:executeScript # 簡単なスクリプトを実行するアクション
    isEnd: true # これが最終ステップ
    inputs:
      Runtime: python3.11
      Handler: handler
      Script: |
        def handler(events, context):
          print("✅ オートメーションは正常に完了しました。")
          return {"status": "SUCCESS", "message": "Automation completed successfully."}
Lambda ①
import boto3
import json
import os

# --- Bedrockクライアントの初期設定 ---
# 環境変数からBedrockのリージョンと使用するモデルIDを取得。なければデフォルト値を使用。
BEDROCK_REGION = os.environ.get("BEDROCK_REGION", "ap-northeast-1")
# Claude 3.5 Sonnet モデルを使用
BEDROCK_MODEL_ID = os.environ.get("BEDROCK_MODEL_ID", "anthropic.claude-3-5-sonnet-20240620-v1:0")
# Bedrockを操作するためのBoto3クライアントを作成
bedrock_runtime = boto3.client(service_name='bedrock-runtime', region_name=BEDROCK_REGION)

def lambda_handler(event, context):
    """
    SSM Automationから呼び出され、Amazon Bedrockを使って
    ApacheのRewriteRuleからテスト用の具体的なURLを生成するLambda関数。
    """
    # どんなデータを受け取ったかログに出力する(デバッグに役立つ)
    print(f"Received event: {json.dumps(event)}")

    # eventオブジェクトから 'AddRules' と 'BaseDomain' を取り出す
    add_rules = event.get('AddRules', '')
    base_domain = event.get('BaseDomain', '')

    # 必要なパラメータがなければエラーを発生させて処理を中断する
    if not add_rules or not base_domain:
        raise ValueError("Parameters 'AddRules' and 'BaseDomain' are required.")

    # 生成したURLを格納するためのリスト
    generated_urls = []

    # 複数行で渡されたルールを1行ずつ処理する
    for rule in add_rules.splitlines():
        rule = rule.strip() # 前後の空白を削除
        # 空行や、'RewriteRule'で始まらない行は無視する
        if not rule or not rule.startswith('RewriteRule'):
            continue

        # Bedrockに渡すプロンプト
        user_prompt = f"""You are an expert in Apache mod_rewrite regular expressions. Your task is to generate one concrete example URL path that would be matched by the following RewriteRule.

Respond ONLY with a JSON object in the format {{"path": "/your/generated/path.html"}}. Do not include any other text, explanations, or markdown.

Rule:
`{rule}`"""

        # BedrockのAPI (Messages API) に合わせた形式でメッセージを作成
        messages = [
            {
                "role": "user",
                "content": [{"type": "text", "text": user_prompt}]
            }
        ]

        # Bedrockモデルに渡すリクエストボディを作成
        body = json.dumps({
            "anthropic_version": "bedrock-2023-05-31", # 使用するAPIのバージョン
            "max_tokens": 512, # AIが生成する文章の最大長
            "messages": messages, # プロンプト
            "temperature": 0.1, # 値が低いほどAIの応答が安定し、毎回同じような結果になりやすい(0.0~1.0)
        })

        try:
            # Bedrockのモデルを呼び出して、AIに応答を生成させる
            response = bedrock_runtime.invoke_model(
                body=body, 
                modelId=BEDROCK_MODEL_ID
            )

            # AIからの応答(ストリーム形式)を読み込んでJSONとしてパース
            response_body = json.loads(response.get('body').read())

            # 応答の中から、AIが生成したテキスト部分を取り出す
            completion = response_body['content'][0]['text']

            # AIが生成したテキスト(JSON形式のはず)をさらにパースする
            path_json = json.loads(completion)
            generated_path = path_json.get('path') # 'path'キーの値を取得

            if generated_path:
                # ベースドメインと結合して、完全なURLを作成
                full_url = f"{base_domain}{generated_path}"
                print(f"Rule: '{rule}' -> Generated URL: '{full_url}'")
                # 生成したURLをリストに追加
                generated_urls.append(full_url)
            else:
                # AIが期待通りのJSONを返さなかった場合
                print(f"Warning: AI did not return a valid path for rule: {rule}")

        except Exception as e:
            # Bedrockの呼び出しやJSONのパースでエラーが発生した場合
            ai_response_on_error = "N/A"
            if 'response_body' in locals() and response_body:
                ai_response_on_error = json.dumps(response_body)

            print(f"Error processing rule '{rule}': {e}. AI response was: {ai_response_on_error}")
            continue # エラーが起きても次のルールの処理へ進む

    # 1つもURLが生成できなかった場合は、Automationを失敗させるために例外を投げる
    if not generated_urls:
        raise Exception("Failed to generate any URLs from the provided rules.")

    # 成功した場合、生成したURLのリストをJSON形式で返す。これがAutomationの次のステップに渡される。
    return {"urls": generated_urls}


Lambda ②

import urllib3
import json

# SSL証明書の警告を非表示にする
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
# HTTPリクエストを送信するためのクライアントを作成
http = urllib3.PoolManager()

def lambda_handler(event, context):
    """
    URLリストを受け取り、各URLが「ちょうど1回のリダイレクトを経てステータス200になる」ことを確認する。
    検証結果がNGでも、Automation全体は止めずに処理を完了させ、結果をログに残す。
    """
    print(f"Received event: {json.dumps(event)}")
    # Automationから渡されたURLのリストを取得
    urls_to_check = event.get('urls', [])

    # チェック対象のURLがなければ、何もせず正常終了
    if not urls_to_check:
        return {"status": "SUCCESS", "results": "No URLs provided."}

    results_log = [] # 検証結果を記録するリスト
    all_ok = True    # 全てのURLが検証をパスしたかどうかのフラグ
    MAX_REDIRECTS = 5 # 無限リダイレクトを防ぐための最大追跡回数

    for url in urls_to_check:
        redirect_count = 0 # リダイレクト回数を初期化
        current_url = url
        final_status = 0   # 最終的なHTTPステータスコードを初期化

        try:
            # 最大5回までリダイレクトを追跡する
            for i in range(MAX_REDIRECTS):
                # HTTP GETリクエストを送信
                resp = http.request(
                    'GET',
                    current_url,
                    retries=False,    # 自動リトライはしない
                    redirect=False,   #  urllib3に自動でリダイレクトさせない。自分でリダイレクトを制御するため。
                    headers={'User-Agent': 'AWS-Lambda-Redirect-Verifier/2.0'} # User-Agentを偽装
                )

                # ステータスコードが3xx (リダイレクト) の場合
                if 300 <= resp.status < 400:
                    redirect_count += 1
                    # レスポンスヘッダーからリダイレクト先のURL ('Location') を取得
                    location = resp.headers.get('Location')
                    if not location:
                        # Locationヘッダーがないリダイレクトは異常とみなし、ループを抜ける
                        final_status = resp.status
                        break
                    # 次のリクエストのためにURLを更新
                    current_url = location
                else:
                    # 3xx以外のステータスコードなら、それが最終結果なのでループを抜ける
                    final_status = resp.status
                    break
            else:
                # forループがbreakされずに終了した場合(=リダイレクトが上限に達した場合)
                final_status = -1 # 無限リダイレクトの可能性を示すために独自のエラーコードを設定

            # --- 検証ロジック ---
            # 「リダイレクトが1回」かつ「最終ステータスが200」なら成功
            if redirect_count == 1 and final_status == 200:
                results_log.append(f"✅ OK: {url} (Redirects: {redirect_count}, Final Status: {final_status})")
            else:
                # それ以外は失敗
                all_ok = False
                results_log.append(f"❌ NG: {url} (Redirects: {redirect_count}, Final Status: {final_status})")

        except Exception as e:
            # リクエスト中にネットワークエラーなどが発生した場合
            all_ok = False
            results_log.append(f"❌ ERROR: {url} - Exception occurred: {e}")

    # 検証結果をCloudWatch Logsにまとめて出力
    print("\n--- Verification Results ---\n" + "\n".join(results_log))

    # 1つでもNGがあった場合、その旨をログに出力
    if not all_ok:
        print("Verification failed for one or more URLs, but returning SUCCESS to allow automation to complete.")

    # 検証結果がNGでも、Lambda関数自体は「成功」として終了する。
    # これにより、Automation全体を停止させることなく、担当者がログで結果を確認して次のアクションを判断できるようにしている。
    return {"status": "VERIFICATION_COMPLETE", "results": results_log}

動かしてみる

この作業では、リダイレクトは、一回のみ発生して、最終的なステータスコードが200で終了することが成功条件です。
リダイレクトが複数回発生する場合や、リダイレクト先のページが見れない場合は失敗として報告する必要がありました。

以下の4パターンでテストをしてみます。

  1. 通常のリダイレクト設定(正常系)
    RewriteRule ^/test1/index.html$ http://y-ishikawa.example.com/A/index.html [R=301,L]
    
    1. 正規表現のリダイレクト設定(正常系)
RewriteRule ^/test2/([a-zA-Z0-9-]+)/index.html$ http://y-ishikawa.example.com/A/index.html [R=301,L]
  1. 存在しないファイルへリダイレクト(異常系)
RewriteRule ^/test3/index.html$ http://y-ishikawa.example.com/A/not-found.html [R=301,L]
  1. 1.のリダイレクトルールにリダイレクトし、リダイレクトが2回発生する設定(異常系)
RewriteRule ^/test4/index.html$ http://y-ishikawa.example.com/test1/index.html [R=301,L]

1. 通常のリダイレクト設定(正常系)

パラメータを指定し、Automation を実行します。

ちゃんと成功しました。

curl コマンドで確認してみると、指定したリダイレクト先にリダイレクトされています。

RunCommand の実行結果も表示されています。ここの表示内容はもっとちゃんと設定した方が良さそうです。

リダイレクトURLの生成、リダイレクトの確認のLambdaも正常に完了してくれています。

2. 正規表現のリダイレクト設定(正常系)

正規表現のルールを追加してみます。

Bedrock が正規表現のルールに当てはまるURLを生成してくれています。

リダイレクトの確認も成功しました。

curl コマンドからも期待通りリダイレクトされることが確認できます。

3. 存在しないファイルへリダイレクト(異常系)

存在しないファイルへのリダイレクトルールを追加します。

リダイレクト確認LambdaでNGをお知らせしてくれています。
最終的なステータスコードが404であることも確認できます。

curl コマンドからもリダイレクトののち、404で終了することが確認できます。

4. 1.のリダイレクトルールにリダイレクトし、リダイレクトが2回発生する設定(異常系)

1.で設定したリダイレクトルールのリダイレクト元URLへリダイレクトするルールを追加します。

最終的なステータスコードは200ですがリダイレクトが2回発生するため、LambdaはNGをお知らせしてくれています。

curl コマンドからも確認できます。

まとめ

この自動化によって、手順が ↓の感じで良くなり、かなり楽になりました。

1. AWSアカウントにログインし、Cloud Shellを起動
2. 以下コマンドでSSM Automation を実行
   aws ssm start-automation-execution \
       --document-name <作成したドキュメント名> \
       --parameters <パラメータ>
3. 結果を確認
  - 〇〇であることを確認

SSM Automation、Bedrock でかなりの作業を自動化できると思いました。
コードもほとんどAIで作成できたので、定型作業が多い場合はぜひ検討してみてください。