Lambda
API Gateway
CloudFront
EC2
Docker
Python
社内向けEC2再起動管理ページ
はじめに
本番サーバーが突然応答しなくなったとき、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 → インスタンス一覧」から確認できます。
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 して適用します。
SSM Agent の有効化確認
タイプ③(コンテナ再起動)で ssm:SendCommand を使うため、各EC2インスタンスでSSM Agentが動いている必要があります。
Systems Manager→フリートマネージャーを開く- 初回は「デフォルトのホスト管理を設定する」をクリック → 推奨ロールで有効化
- 各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ロールにポリシーを追加してください。
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のみを指定しています。これにより、意図しないインスタンスへの操作を防ぎます。
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リクエスト(再起動実行)のみパスワードを検証します。
API Gateway の作成
API Gateway → 「APIを作成」→ REST API(プライベートではない)→「構築」で進めます。
- 「リソースを作成」→ リソース名
restart(パスは/restartと自動設定される) - 「CORS を有効にする」にチェックON →
OPTIONSメソッドが自動作成される /restartリソースを選択 → 「メソッドを作成」でGET・POSTをそれぞれ追加(Lambdaプロキシ統合: ON)- 「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
管理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 に追加してください。
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 を更新します。
カスタムドメイン設定(オプション)
費用は完全無料(ACM証明書の発行・更新も無料)、所要時間は約30分です。
ACM証明書は必ず us-east-1 で発行する
CloudFrontで使用するACM証明書は、バージニア北部(us-east-1)リージョンで発行しなければなりません。他のリージョンで発行した証明書はCloudFrontに適用できません。
- リージョンを us-east-1(バージニア北部) に切り替える
- ACM → 「証明書をリクエスト」→ ドメイン名を入力 → DNS検証を選択
- 「Route53でレコードを作成」ボタンをクリック(ワンクリックでCNAMEが自動追加される)
- 証明書のステータスが「発行済み」になるまで数分〜10分待つ
- CloudFront → 「代替ドメイン名(CNAME)」にカスタムドメインを追加・証明書を選択
- Route53 → Aレコード(エイリアス)をCloudFrontに向けて作成
- Lambda の
ALLOWED_ORIGINをカスタムドメインのURLに更新
動作確認
最初は必ずStaging環境から確認してください。
- CloudFrontのURLにアクセスするとBasic認証ダイアログが表示される
- ログイン後にヘルスチェックパネルで各環境の状態が表示される
- 環境を選択すると推奨する再起動タイプが自動セットされる
- 「再起動する」→ 確認モーダル →「実行する」で「再起動を受け付けました」が表示される
- AWSコンソールでEC2の状態遷移を確認する
- 復旧後に「更新」ボタンで🟢正常稼働中になることを確認する
| タイプ | 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: always と healthcheck を組み合わせることで、EC2再起動後のコンテナ自動起動も担保されます。サービス規模や要件に応じてタイプや環境数を増減させて活用してください。
次にやりたいこと
Slackからの操作(Slash Command)対応や、再起動後の自動ヘルスチェック通知なども面白そうです。あとLambdaのWebhook URLを固定化するためにカスタムドメインをAPI Gatewayにも当てると、よりすっきりした構成になるはずです。
ここまで読んでいただき、ありがとうございます。もしこの記事の技術や考え方に少しでも興味を持っていただけたら、ネクストのエンジニアと気軽に話してみませんか。
- 選考ではありません
- 履歴書不要
- 技術の話が中心
- 所要時間30分程度
- オンラインOK