AWS
Lambda
API Gateway
CloudFront
EC2
Docker
Python
AWS Lambda + API Gateway + CloudFront で作る
社内向けEC2再起動管理ページ
2026-06-02  |  所要時間: 約10〜12時間(2日)

はじめに

本番サーバーが突然応答しなくなったとき、AWSコンソールにアクセスできないメンバーでも安全に再起動できる仕組みが欲しくなる場面があります。かといって、誰でも操作できてしまうのも困ります。

この記事では、Lambda + API Gateway + S3 + CloudFront を組み合わせて、以下の要件を満たす社内運用向けEC2再起動管理ページをゼロから構築する手順を解説します。

  • パスワード認証 + Basic認証の二重保護
  • 複数環境(本番・Staging・開発)を一画面で管理
  • 3種類の再起動タイプを状況に応じて使い分け
  • ヘルスチェック機能で各環境の現在状態を自動判定・推奨操作を表示
  • 完全サーバーレス構成(EC2が停止していても操作可能)

完成後のUI(概要)

ブラウザからアクセスするとBasic認証ダイアログが表示され、ログイン後にリアルタイムのサーバー状態パネルと再起動フォームが表示されます。環境を選択すると推奨する再起動タイプが自動セットされます。

全体アーキテクチャ

ブラウザ
  │
  ▼
[CloudFront]  <── Basic認証(CloudFront Functions)
  │           <── HTTPS強制・カスタムドメイン(ACM証明書)
  ├── 静的ページ配信 ──> [S3]  <-- index.html(管理UI)
  │
  └── /restart (GET/POST) ──> [API Gateway]
                                    │
                                    ▼
                               [Lambda]  <-- Python / パスワード検証
                                    │
                         ┌──────────┼──────────┐
                         ▼          ▼          ▼
                      [EC2]      [EC2]      [EC2]
                      本番       Staging    開発
             (RebootInstances / Stop+Start / SSM SendCommand)
コンポーネント 役割
CloudFront Functions Basic認証(URLを知られても弾く第一関門)
S3 管理UI(index.html)のホスティング
API Gateway REST APIのエンドポイント公開
Lambda(Python) パスワード検証・EC2操作・ヘルスチェック
IAM Role LambdaにEC2/SSM操作権限を付与
SSM Agent EC2インスタンスへのリモートコマンド実行

3種類の再起動タイプ

タイプ 仕組み 復旧目安 使いどき
① 通常再起動 ec2:RebootInstances 5〜10分 まず試すべき基本操作。restart: always でコンテナが自動起動
② 強制復旧 Stop → Start 5〜15分 CPU高負荷・OOM でOSが応答しない場合
③ コンテナ再起動 ssm:SendCommand 2〜5分 EC2は動いているがコンテナだけ落ちた軽度障害

前提条件・必要なAWS権限

作業するIAMユーザーに以下の権限が必要です。

  • IAM(ロール・ポリシー作成)
  • Lambda(関数作成・編集・環境変数設定)
  • API Gateway(REST API作成・デプロイ)
  • S3(バケット作成・オブジェクトアップロード)
  • CloudFront(ディストリビューション作成・Functions作成)
  • EC2(DescribeInstances 閲覧権限)
  • ACM(証明書リクエスト、us-east-1 リージョン)
  • Route53(DNSレコード追加、カスタムドメインを使う場合)
  • Systems Manager(フリートマネージャー確認)

作業前に手元に用意するもの

AWSアカウントID、各EC2インスタンスID、管理ページ用のパスワード(英数字記号混在16文字以上推奨)。インスタンスIDはAWSコンソールの「EC2 → インスタンス一覧」から確認できます。

1
docker-compose.yml の更新

EC2再起動後にコンテナを自動起動させるため、restart: always と、DBの起動完了を待ってからアプリを起動させる healthcheck + depends_on.condition を追加します。

  • restart: always ― EC2起動時・コンテナ異常終了時に自動再起動
  • healthcheck ― DBが本当に準備完了したかを mysqladmin ping で判定
  • depends_on.condition: service_healthy ― DBのhealthcheck通過後にアプリを起動
version: '3.9'

services:
  app:
    build:
      context: .
    restart: always                    # EC2起動時・異常終了時に自動再起動
    ports:
      - '3000:3000'
    depends_on:
      db:
        condition: service_healthy     # DB healthcheck通過後に起動
    environment:
      AWS_REGION: ap-northeast-1

  db:
    image: mysql:8.0
    restart: always
    healthcheck:
      test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-uroot", "-proot"]
      interval: 10s
      timeout: 5s
      retries: 5
      start_period: 30s
    volumes:
      - db_data:/var/lib/mysql
    environment:
      MYSQL_ROOT_PASSWORD: root
      TZ: 'Asia/Tokyo'

volumes:
  db_data:

動作確認

docker compose down
docker compose up -d

# DBが healthy になるまで待つ
docker compose ps
# STATUS が "Up X seconds (healthy)" になればOK

すべての環境(本番・Staging・開発)で同様の更新が必要です。

ローカルで変更して git push し、各EC2で git pull して適用します。

2
SSM Agent の有効化確認

タイプ③(コンテナ再起動)で ssm:SendCommand を使うため、各EC2インスタンスでSSM Agentが動いている必要があります。

  1. Systems Managerフリートマネージャー を開く
  2. 初回は「デフォルトのホスト管理を設定する」をクリック → 推奨ロールで有効化
  3. 各EC2インスタンスが「オンライン」と表示されることを確認

オンラインにならない場合(SSH接続して対応)

# SSM Agentのステータス確認
sudo systemctl status amazon-ssm-agent

# 起動していなければ有効化・起動
sudo systemctl enable amazon-ssm-agent
sudo systemctl start amazon-ssm-agent

IAMインスタンスプロファイルの確認

EC2 → 対象インスタンス → 「セキュリティ」タブ → IAMロールに AmazonSSMManagedInstanceCore が含まれているか確認。含まれていなければ該当IAMロールにポリシーを追加してください。

3
IAM Role の作成

LambdaがEC2操作・SSMコマンド送信を行うための専用ロールを作成します。IAMロール → 「ロールを作成」から進めます。

  • エンティティタイプ: 「AWSのサービス」
  • ユースケース: 「Lambda」
  • マネージドポリシー: AWSLambdaBasicExecutionRole(CloudWatch Logsへの書き込み)
  • ロール名例: YourProjectEC2RestartLambdaRole

インラインポリシーを追加

ロール作成後、「ポリシーを追加」→「インラインポリシーを作成」→ JSONタブで以下を貼り付けます。<AWSアカウントID> と各 <インスタンスID> は実際の値に置き換えてください。

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "EC2Restart",
      "Effect": "Allow",
      "Action": [
        "ec2:RebootInstances",
        "ec2:StopInstances",
        "ec2:StartInstances"
      ],
      "Resource": [
        "arn:aws:ec2:ap-northeast-1:<AWSアカウントID>:instance/<本番インスタンスID>",
        "arn:aws:ec2:ap-northeast-1:<AWSアカウントID>:instance/<StagingインスタンスID>",
        "arn:aws:ec2:ap-northeast-1:<AWSアカウントID>:instance/<開発インスタンスID>"
      ]
    },
    {
      "Sid": "EC2Describe",
      "Effect": "Allow",
      "Action": ["ec2:DescribeInstances"],
      "Resource": "*"
    },
    {
      "Sid": "SSMCommand",
      "Effect": "Allow",
      "Action": ["ssm:SendCommand"],
      "Resource": [
        "arn:aws:ec2:ap-northeast-1:<AWSアカウントID>:instance/<本番インスタンスID>",
        "arn:aws:ec2:ap-northeast-1:<AWSアカウントID>:instance/<StagingインスタンスID>",
        "arn:aws:ec2:ap-northeast-1:<AWSアカウントID>:instance/<開発インスタンスID>",
        "arn:aws:ssm:ap-northeast-1::document/AWS-RunShellScript"
      ]
    },
    {
      "Sid": "SSMResult",
      "Effect": "Allow",
      "Action": ["ssm:GetCommandInvocation"],
      "Resource": "*"
    }
  ]
}

最小権限の原則

Resourceには "*" ではなく操作対象インスタンスのARNのみを指定しています。これにより、意図しないインスタンスへの操作を防ぎます。

4
Lambda 関数の作成

Lambda → 「関数の作成」→「一から作成」で進めます。

項目
関数名 your-project-ec2-restart(任意)
ランタイム Python 3.12(安定版)
アーキテクチャ x86_64
実行ロール 「カスタム実行ロールを使用する」→ STEP 3 で作成したロール

タイムアウトは5分に変更する

「設定」タブ → 「一般設定」→「編集」→ タイムアウトを 5分0秒 に変更します。タイプ②(強制復旧)はStop→Startの内部ポーリングで最大120秒かかるため、デフォルトの30秒では不足します。

環境変数の設定

キー 値の説明
RESTART_PASSWORD 再起動操作用パスワード(英数字記号混在16文字以上推奨)
INSTANCE_ID_PROD 本番EC2インスタンスID
INSTANCE_ID_STAGING StagingのEC2インスタンスID
INSTANCE_ID_DEV 開発環境のEC2インスタンスID
PROJECT_PATH EC2上のプロジェクトパス(例: /home/ec2-user/your-project
ALLOWED_ORIGIN *(CloudFront URL確定後に更新)
SERVICE_URL_PROD 本番サービスのURL(ヘルスチェック用)
SERVICE_URL_STAGING StagingサービスのURL(ヘルスチェック用)
SERVICE_URL_DEV 開発サービスのURL(ヘルスチェック用)

Lambdaコード(ヘルスチェック対応版)

「コード」タブ → lambda_function.py を全選択して削除し、以下を貼り付けて「Deploy」をクリックします。

import json
import os
import time
import logging
import urllib.request
import boto3

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

ec2 = boto3.client("ec2", region_name="ap-northeast-1")
ssm = boto3.client("ssm", region_name="ap-northeast-1")

PASSWORD     = os.environ["RESTART_PASSWORD"]
PROJECT_PATH = os.environ["PROJECT_PATH"]
INSTANCE_IDS = {
    "prod":    os.environ["INSTANCE_ID_PROD"],
    "staging": os.environ["INSTANCE_ID_STAGING"],
    "dev":     os.environ["INSTANCE_ID_DEV"],
}
SERVICE_URLS = {
    "prod":    os.environ.get("SERVICE_URL_PROD", ""),
    "staging": os.environ.get("SERVICE_URL_STAGING", ""),
    "dev":     os.environ.get("SERVICE_URL_DEV", ""),
}
ALLOWED_ORIGIN = os.environ.get("ALLOWED_ORIGIN", "*")

CORS_HEADERS = {
    "Access-Control-Allow-Origin":  ALLOWED_ORIGIN,
    "Access-Control-Allow-Headers": "Content-Type",
    "Access-Control-Allow-Methods": "POST,OPTIONS,GET",
}

ENV_LABELS = {"prod": "本番環境", "staging": "Staging環境", "dev": "開発環境"}

def respond(status, body):
    return {
        "statusCode": status,
        "headers": {**CORS_HEADERS, "Content-Type": "application/json"},
        "body": json.dumps(body, ensure_ascii=False),
    }

def check_ec2_state(instance_id):
    try:
        resp = ec2.describe_instances(InstanceIds=[instance_id])
        return resp["Reservations"][0]["Instances"][0]["State"]["Name"]
    except Exception:
        return "unknown"

def check_service(url):
    if not url:
        return "unknown"
    try:
        with urllib.request.urlopen(
            urllib.request.Request(url, method="GET"), timeout=5
        ) as res:
            return "ok" if res.status == 200 else "error"
    except Exception:
        return "unreachable"

def get_recommendation(ec2_state, service_status):
    if ec2_state == "stopped":
        return {"status": "critical", "icon": "🔴", "message": "EC2停止中",
                "recommended_type": "type_2_force", "recommended_label": "② 強制復旧を推奨"}
    if ec2_state == "rebooting":
        return {"status": "rebooting", "icon": "🔄", "message": "EC2再起動中(復旧まで待機)",
                "recommended_type": None, "recommended_label": "操作不要(復旧中)"}
    if ec2_state == "running" and service_status == "ok":
        return {"status": "healthy", "icon": "🟢", "message": "正常稼働中",
                "recommended_type": None, "recommended_label": "操作不要"}
    if ec2_state == "running" and service_status in ("unreachable", "error"):
        return {"status": "warning", "icon": "🟡", "message": "EC2起動中・サービス応答なし",
                "recommended_type": "type_1_reboot", "recommended_label": "① 通常再起動を推奨"}
    return {"status": "unknown", "icon": "⚪", "message": "状態確認中",
            "recommended_type": "type_1_reboot", "recommended_label": "① 通常再起動を推奨"}

def force_restart(instance_id):
    ec2.stop_instances(InstanceIds=[instance_id], Force=True)
    for _ in range(24):
        time.sleep(5)
        state = ec2.describe_instances(InstanceIds=[instance_id])[
            "Reservations"][0]["Instances"][0]["State"]["Name"]
        if state == "stopped":
            break
    ec2.start_instances(InstanceIds=[instance_id])

def lambda_handler(event, context):
    method = event.get("httpMethod", "")

    if method == "OPTIONS":
        return respond(200, {})

    if method == "GET":
        result = {}
        for env, instance_id in INSTANCE_IDS.items():
            ec2_state      = check_ec2_state(instance_id)
            service_status = check_service(SERVICE_URLS[env])
            result[env]    = {"label": ENV_LABELS[env], "ec2_state": ec2_state,
                              "service_status": service_status,
                              **get_recommendation(ec2_state, service_status)}
        return respond(200, result)

    try:
        body = json.loads(event.get("body") or "{}")
    except json.JSONDecodeError:
        return respond(400, {"error": "リクエスト形式が正しくありません"})

    password     = body.get("password", "")
    env          = body.get("env", "")
    restart_type = body.get("restart_type", "")

    if password != PASSWORD:
        logger.warning(json.dumps({"action": "restart_rejected", "env": env,
                                   "reason": "invalid_password"}, ensure_ascii=False))
        return respond(401, {"error": "パスワードが正しくありません"})

    if env not in INSTANCE_IDS:
        return respond(400, {"error": f"環境が不正です: {env}"})

    instance_id = INSTANCE_IDS[env]
    env_label   = ENV_LABELS[env]

    logger.info(json.dumps({"action": "restart_accepted", "env": env,
                            "restart_type": restart_type}, ensure_ascii=False))

    if restart_type == "type_1_reboot":
        ec2.reboot_instances(InstanceIds=[instance_id])
        return respond(200, {"message": f"{env_label} のEC2を再起動しました。約5〜10分で復旧します。"})

    if restart_type == "type_2_force":
        force_restart(instance_id)
        return respond(200, {"message": f"{env_label} のEC2を強制停止→起動しました。約5〜15分で復旧します。"})

    if restart_type == "container":
        ssm.send_command(
            InstanceIds=[instance_id],
            DocumentName="AWS-RunShellScript",
            Parameters={
                "commands": [
                    "sudo systemctl is-active --quiet docker || sudo systemctl start docker",
                    "sleep 3",
                    f"cd {PROJECT_PATH} && docker compose up -d",
                ],
                "executionTimeout": ["600"],
            },
        )
        return respond(200, {"message": f"{env_label} のコンテナを再起動しました。約2〜5分で復旧します。"})

    return respond(400, {"error": f"再起動タイプが不正です: {restart_type}"})

GETリクエストはパスワード不要

ヘルスチェック(GET)はCloudFront FunctionsのBasic認証で保護されているため、Lambdaレベルでのパスワード検証は不要です。POSTリクエスト(再起動実行)のみパスワードを検証します。

5
API Gateway の作成

API Gateway → 「APIを作成」→ REST API(プライベートではない)→「構築」で進めます。

  1. 「リソースを作成」→ リソース名 restart(パスは /restart と自動設定される)
  2. 「CORS を有効にする」にチェックONOPTIONS メソッドが自動作成される
  3. /restart リソースを選択 → 「メソッドを作成」でGET・POSTをそれぞれ追加(Lambdaプロキシ統合: ON)
  4. 「APIをデプロイ」→ ステージ名: prod → 「デプロイ」

発行されたURLを控えます(次のSTEPで使用):

https://<API-ID>.execute-api.ap-northeast-1.amazonaws.com/prod/restart

curl で動作確認

curl -X POST \
  -H "Content-Type: application/json" \
  -d '{"env":"staging","restart_type":"container","password":"設定したパスワード"}' \
  https://<API-ID>.execute-api.ap-northeast-1.amazonaws.com/prod/restart

6
管理UI(index.html)の作成・S3配置

管理UIはバニラHTML+CSS+JavaScriptで実装します。フレームワーク不要で、S3に1ファイル置くだけです。

  • ページ読み込み時に自動でヘルスチェックAPIを呼び出し各環境の状態を表示
  • 状態カードをクリックすると対象環境が選択される
  • 環境選択時に推奨する再起動タイプを自動セット
  • 実行前の確認モーダル・処理中・成功・エラーの状態表示
項目
バケット名 your-project-restart-page(グローバルで一意の名前)
リージョン ap-northeast-1
パブリックアクセス すべてブロック(デフォルトのまま)

.gitignore への追加を忘れずに

管理UIの index.html にはAPI GatewayのエンドポイントURLが含まれます。Gitリポジトリにコミットしないよう .gitignore に追加してください。

7
CloudFront ディストリビューション作成 + Basic認証

S3バケットを直接公開するのではなく、CloudFrontを経由することでHTTPS強制・Basic認証・カスタムドメイン対応を一括で実現します。

項目
S3へのアクセス OAC(推奨)を有効にする
ビューワープロトコルポリシー Redirect HTTP to HTTPS
キャッシュポリシー CachingDisabled(HTMLを常に最新化)
WAF 「セキュリティ保護を有効にしない」(CloudFront Functionsで代替)
デフォルトルートオブジェクト index.html必須。忘れるとAccessDenied

ハマりポイント: デフォルトルートオブジェクトを忘れずに

ディストリビューション作成後、「一般」→「設定」→「編集」→「Default root object」に index.html を入力して保存します。これを忘れるとルートURLで AccessDenied エラーになります。

CloudFront Functions でBasic認証を追加

CloudFront → 「関数」→「関数を作成」で以下のJavaScriptをデプロイします。

function handler(event) {
    var request = event.request;
    var headers = request.headers;

    // "admin:パスワード" をBase64エンコードした値に変更する
    // ブラウザのコンソールで btoa("admin:YourPassword") を実行して取得
    var EXPECTED = "Basic <base64エンコードした 'admin:パスワード'>";

    var authHeader = headers["authorization"];
    if (!authHeader || authHeader.value !== EXPECTED) {
        return {
            statusCode: 401,
            statusDescription: "Unauthorized",
            headers: {
                "www-authenticate": { value: 'Basic realm="Server Restart Page"' }
            }
        };
    }
    return request;
}

「保存」→「発行」後、ディストリビューションの「ビヘイビア」→「ビューワーリクエスト」に関連付けます。CloudFrontのURLが確定したらLambdaの環境変数 ALLOWED_ORIGIN を更新します。

8
カスタムドメイン設定(オプション)

費用は完全無料(ACM証明書の発行・更新も無料)、所要時間は約30分です。

ACM証明書は必ず us-east-1 で発行する

CloudFrontで使用するACM証明書は、バージニア北部(us-east-1)リージョンで発行しなければなりません。他のリージョンで発行した証明書はCloudFrontに適用できません。

  1. リージョンを us-east-1(バージニア北部) に切り替える
  2. ACM → 「証明書をリクエスト」→ ドメイン名を入力 → DNS検証を選択
  3. 「Route53でレコードを作成」ボタンをクリック(ワンクリックでCNAMEが自動追加される)
  4. 証明書のステータスが「発行済み」になるまで数分〜10分待つ
  5. CloudFront → 「代替ドメイン名(CNAME)」にカスタムドメインを追加・証明書を選択
  6. Route53 → Aレコード(エイリアス)をCloudFrontに向けて作成
  7. Lambda の ALLOWED_ORIGIN をカスタムドメインのURLに更新

9
動作確認

最初は必ずStaging環境から確認してください。

  1. CloudFrontのURLにアクセスするとBasic認証ダイアログが表示される
  2. ログイン後にヘルスチェックパネルで各環境の状態が表示される
  3. 環境を選択すると推奨する再起動タイプが自動セットされる
  4. 「再起動する」→ 確認モーダル →「実行する」で「再起動を受け付けました」が表示される
  5. AWSコンソールでEC2の状態遷移を確認する
  6. 復旧後に「更新」ボタンで🟢正常稼働中になることを確認する
タイプ EC2コンソールで確認する状態遷移
① 通常再起動 running → rebooting → running
② 強制復旧 running → stopping → stopped → running
③ コンテナ再起動 Systems Manager → Run Command → コマンド履歴に実行ログがあること

運用メモ

確認項目 確認先 頻度
Lambda実行エラー CloudWatch Logs 障害発生時
誰がいつ実行したか CloudWatch Logs(restart_accepted のログ) 必要時
SSM Agentの稼働状態 Systems Manager → フリートマネージャー 月1回
パスワード変更 Lambda環境変数 + CloudFront Functions の EXPECTED を同時更新 定期的に

パスワードを変更するときは2か所同時に更新が必要です

Lambda環境変数の RESTART_PASSWORD(POSTリクエストの認証用)と、CloudFront Functionsの EXPECTED(Basic認証のBase64文字列)は独立しているため、両方を同時に更新します。

まとめ

Lambda + API Gateway + CloudFront の組み合わせにより、EC2が完全に停止していても操作可能なサーバーレス構成の再起動管理ページを構築できました。

  • EC2に依存しない管理基盤 ― S3+CloudFrontで動いているため、EC2が落ちていても操作できる
  • 二重認証 ― CloudFront FunctionsのBasic認証 + Lambda内のパスワード検証
  • 最小権限 ― IAMポリシーで操作対象インスタンスを明示的に制限
  • 監査ログ ― CloudWatch Logsに誰がいつどの環境を再起動したかを自動記録
  • ランニングコストほぼゼロ ― 滅多に使わないLambda+API Gatewayは従量課金のため実質無料

docker-compose の restart: alwayshealthcheck を組み合わせることで、EC2再起動後のコンテナ自動起動も担保されます。サービス規模や要件に応じてタイプや環境数を増減させて活用してください。

次にやりたいこと

Slackからの操作(Slash Command)対応や、再起動後の自動ヘルスチェック通知なども面白そうです。あとLambdaのWebhook URLを固定化するためにカスタムドメインをAPI Gatewayにも当てると、よりすっきりした構成になるはずです。

ここまで読んでいただき、ありがとうございます。もしこの記事の技術や考え方に少しでも興味を持っていただけたら、ネクストのエンジニアと気軽に話してみませんか。

  • 選考ではありません
  • 履歴書不要
  • 技術の話が中心
  • 所要時間30分程度
  • オンラインOK

エンジニアと話してみる

関連リンク

AI・クラウド・データ分析のご相談はネクスト株式会社までお問い合わせください。