[Amazon EC2] LINE で「まだ使ってる?」と聞いてくる EC2 停止忘れ防止の仕組みを作ってみました

[Amazon EC2] LINE で「まだ使ってる?」と聞いてくる EC2 停止忘れ防止の仕組みを作ってみました

GPU インスタンスの止め忘れによる高額課金を防ぐため、毎時 LINE で「継続しますか?停止しますか?」と能動的に問いかけ、無応答なら自動停止する仕組みを作ってみました。タグを付けるだけで対象でき、殆ど固定費ゼロでサーバレス構成により運用できます。
2026.05.23

1 はじめに

製造ビジネステクノロジー部の平内(SIN)です。

最近、検証にGPUインスタンスを使う機会が増え、止め忘れによる高額な課金をひたすら恐れています。

EC2 の止め忘れ対策には、CloudWatch で使っていないインスタンスを自動シャットダウンする、EventBridge で定期停止する、Slack やメールで通知する、といったさまざまな方法があります。DevelopersIO でも数多く紹介されています。

どれも有効な方法ですが、本当に個人的な下記の理由から、今回の仕組みを作ってみました。

  • メールは、頻繁にチェックしていない
  • Slackは、仕事中以外、リアルタイムにチェックしていない
  • 友達が少ないので、LINEの通知は、一番見逃しにくい
  • 最近の検証作業では、放置してもCPUの使用率がある程度高い

起動中の EC2 に対して 毎時 0 分(1 時間ごと)に LINE で「継続しますか?停止しますか?」と能動的に問いかけ、無応答が続いたら自動的に停止します。「使っていなさそうなら止める」のではなく、「まだ使ってる?」と都度確認してくる点が特徴です。

004

今回こだわったのは次の 3 点です。

  • タグを付けるだけで対象にできる: AutoStopNotify=true を付けるだけで、どのインスタンスでもいつでも対象にできる(特定のインスタンスに作り込まない)
  • AWS利用費を最小限に抑える: 仕組み自体のコストを可能な限り小さくする
  • 継続/停止を選べる: 催促に対してレスポンスを選択できる
  • 最悪でも止まる: 応答がない場合に自動停止することで、完全に忘れていても 1 時間ほど(次の確認+無応答判定)で強制的に停止する
  • LINE無料枠で運用できる頻度にする: LINE のコミュニケーションプラン(月 200 通まで無料)の範囲で運用できる頻度を狙う(通知に現在の利用数を併記して確認できるようにする)

サーバレス構成(Lambda / Step Functions / EventBridge Scheduler / API Gateway)と、オンデマンドの DynamoDB、そして LINE のトークン保管に無料の SSM Parameter Store SecureString を使うことで、待機しているだけで発生する固定費を避けています。

2 全体構成

全体の構成は次のとおりです。

  • ① cronで指定したタイミングでStep Functions が起動されます
  • ② Step Functions が check_running Lambda を呼び、タグが設定された起動中のインスタンスを列挙します
  • ③ その後、notifier Lambda を待機モード(タスクトークン)で起動します
  • ④ notifier Lambda が LINE 通知を送信します
  • ⑤「継続」若しくは「停止」レスポンスをResponder Lambdaが処理します
  • ⑥ レスポンスを受け 待機しているStep Functions に制御を戻します
  • ⑦ Step Functionsの待機がタイム・アウトした場合は、インスタンスを停止します
  • ⑧ 「停止」のレスポンスがあった場合は、直ちにインスタンスを停止します

001

監視対象は、 AutoStopNotify=true というタグが付いた EC2 インスタンスです。

3 LINE 側の準備

(1) LINE 公式アカウント / Messaging API チャネル作成

現在は LINE Developers コンソールから Messaging API チャネルを直接作成できなくなっており、先に LINE 公式アカウントを作成する流れになっています(Messaging API を始めよう)。手順は次のとおりです。

  1. LINE Developers コンソール でプロバイダーを作成する
  2. プロバイダー内の「LINE公式アカウントを作成する」から、LINE Official Account Manager でLINE 公式アカウントを作成する
  3. LINE Official Account Manager の「設定 → Messaging API」で「Messaging API を利用する」を有効化し、手順 1 のプロバイダーを選択する
  4. 有効化すると、LINE Developers コンソールの当該プロバイダー配下に Messaging API チャネルが現れる

(2) Channel Access Token / Channel Secret 取得

チャネルの設定画面から、以下の 2 つを取得して控えます。

  • Channel access token(long-lived): Push / Reply の送信に使用
  • Channel secret: Webhook の署名検証に使用

(3) 自分の userId 取得

Push の宛先には自分の userId が必要です。userId はコンソールから直接は確認しづらいため、本記事では「いったん Webhook を疎通させ、届いたイベントの source.userId をログから拾う」方法を採りました。

4 CDK でインフラ構築

コードは GitHub で公開しています。

(1) リポジトリ取得 + デプロイ

git clone https://github.com/furuya02/aws-ec2-line-stop-reminder.git
cd aws-ec2-line-stop-reminder/cdk
pnpm install

# 初回のみ
pnpm exec cdk bootstrap

pnpm exec cdk deploy

デプロイで作成される主なリソースは、DynamoDB / Lambda × 4(check_running / notifier / stopper / responder)/ Step Functions / EventBridge Scheduler / API Gateway / IAM です。デプロイ後、Outputs に表示される WebhookUrl を控えておきます。

Outputs:
LineStopReminderStack.ParameterPrefix = /ec2-line-stop-reminder
LineStopReminderStack.StateMachineArn = arn:aws:states:ap-northeast-1:xxxxxxxxxxxx:stateMachine:aws-ec2-line-stop-reminder-xxxxxxxxxxxx
LineStopReminderStack.StateTableName = aws-ec2-line-stop-reminder-xxxxxxxxxxxx
LineStopReminderStack.WebhookApiEndpointXXXXXX = https://xxxxxxxx.execute-api.ap-northeast-1.amazonaws.com/prod/
LineStopReminderStack.WebhookUrl = https://xxxxxxxx.execute-api.ap-northeast-1.amazonaws.com/prod/webhook

起動スケジュールそのものを変えたい場合は -c schedule に cron / rate 式を渡します(例: -c schedule="cron(0 */2 * * ? *)" で 2 時間ごとの 0 分、-c schedule="rate(30 minutes)" で 30 分ごと)。ただし間隔は 1 回の通知セッション(wait_minutes ×(max_retry+1)、既定で約 10 分)より長くしてください(短いと同じインスタンスで実行が重複します)。

(2) SSM Parameter に LINE トークン投入(SecureString)

取得した Channel access token と Channel secret を、SecureString として登録します。

cd ../scripts
./put-line-params.sh "<CHANNEL_ACCESS_TOKEN>" "<CHANNEL_SECRET>"

006

(3) Webhook URL を LINE コンソールに登録

Outputs の WebhookUrl を、LINE Developers コンソールの「Messaging API 設定 → Webhook URL」に登録し、「Webhook の利用」を ON にします。「検証(Verify)」で 200 が返ることを確認します。

その後、作成した公式アカウントを友だち追加し、トークに何かメッセージを送ります。responder Lambda の CloudWatch Logs に userId=U.... が出力されるので、その値を登録します。

aws ssm put-parameter --region ap-northeast-1 --type SecureString --overwrite \
  --name /ec2-line-stop-reminder/user-id --value "<YOUR_USER_ID>"

007

5 動作内容

(1) Step Functions

003

通知から判定までの流れを Step Functions のステートマシンで表現しています。CheckRunning でタグ付きの running インスタンスを列挙し、該当インスタンスがない場合は、即終了、存在する場合は、Map でインスタンスごとに「通知して応答を待機 → 応答で即終了 / 無応答ならタイムアウトして再送」を実行します。応答待ちには Step Functions の コールバック(タスクトークン) を使い、ユーザーが応答した時点で再開となります。

再送回数のインクリメントには、Step Functions の組み込み関数 States.MathAdd を使っています。

const incrementRetry = new sfn.Pass(this, 'IncrementRetry', {
  parameters: {
    instanceId: sfn.JsonPath.stringAt('$.instanceId'),
    sessionId: sfn.JsonPath.stringAt('$.sessionId'),
    retryCount: sfn.JsonPath.numberAt('States.MathAdd($.retryCount, 1)'),
  },
});

mapの中の、終端ステート(Pass)は、特に処理は無く、単純にどういう動作をしたかが視覚的に分かるようにと設置されているだけです。

002

終端ステート 到達する経路 インスタンス停止処理
Continued EvaluateResponse で「継続」 なし
StoppedByUser EvaluateResponse で「停止」 responder が postback 受信時に停止
AutoStopped 無応答 → CheckRetry が上限到達 → AutoStop(stopper Lambda)の後 stopper Lambda が停止

(2) CheckRunning

Github cdk/lambda/check_running/index.py

StepFunctionsから呼び出され、タグ AutoStopNotify=true が設定されていて、状態が runningの EC2 を列挙して返します(Name タグも併せて返しています)

lambda/check_running/index.py(抜粋)

def handler(event: dict[str, Any], context: Any) -> dict[str, Any]:
    response = ec2_client.describe_instances(
        Filters=[
            {"Name": "tag:AutoStopNotify", "Values": ["true"]},
            {"Name": "instance-state-name", "Values": ["running"]},
        ]
    )
    running_instances: list[dict[str, str]] = []
    for reservation in response["Reservations"]:
        for instance in reservation["Instances"]:
            running_instances.append(
                {"instanceId": instance["InstanceId"], "name": get_name_tag(instance)}
            )
    return {"instances": running_instances, "count": len(running_instances)}

(3) Notifier

Github cdk/lambda/notifier/index.py

StepFunctionsから呼び出され、LINE へ「継続 / 停止」の Quick Reply 付き Push メッセージを送ります。

Step Functions の waitForTaskToken から受け取ったタスクトークンは、DynamoDB に保存しておき、ユーザーの応答を responder が処理するとき、このトークンでSendTaskSuccess を呼ぶことで、ステートマシン再開時に、どのインスタンスの応答なのかがひも付きます。

なお、Notifierは、GET /v2/bot/message/quota/consumption で今月のLINEの送信済み通数をメッセージの末尾に付けています。
LINE Messaging API のコミュニケーションプランは 月 200 通までとなっており(Messaging API の料金)長時間起動しっぱなしで通知や再送が積み重なると、無料枠を超える可能性があります。残量を把握しやすいよう、通知には「今月の無料枠 残り 約 N 通」を併記しました。

lambda/notifier/index.py(抜粋)

FREE_QUOTA: int = int(os.environ.get("FREE_QUOTA", "200"))  # フリープラン無料枠(日本=月200通)

def handler(event: dict[str, Any], context: Any) -> dict[str, Any]:

    # 応答受信時に responder が使うトークンを保存(セッションごとに上書き)
    state_table = dynamodb_resource.Table(TABLE_NAME)
    state_table.put_item(
        Item={
            "instanceId": instance_id,
            "sessionId": session_id,
            "taskToken": task_token,
            "ttl": int(time.time()) + 3600,
        }
    )

    # ID・トークンの取得
    access_token = get_secure_parameter(TOKEN_PARAM)
    user_id = get_secure_parameter(USER_PARAM)
    # 今月の送信済み通数から無料枠の残数を概算で返す
    remaining = get_remaining_free_quota(access_token)
    quota_line = f"\n(今月の無料枠 残り 約 {remaining} 通)" if remaining is not None else ""
    message_text = (
        f"EC2 インスタンス {instance_label} が起動中です。\n"
        f"継続しますか?停止しますか?\n"
        f"(5 分以内に応答がない場合は再確認します。無応答が続くと自動停止します)"
        f"{quota_line}"
    )
    # メッセージ送信
    push_quick_reply(access_token, user_id, message_text, instance_id, session_id)
    return {"instanceId": instance_id}

(4) Stopper

Github cdk/lambda/stopper/index.py

StepFunctionsから呼び出され、インスタンスを停止します。

lambda/stopper/index.py(抜粋)


ec2_client = boto3.client("ec2")

def handler(event: dict[str, Any], context: Any) -> dict[str, Any]:
    instance_id: str = event["instanceId"]
    ec2_client.stop_instances(InstanceIds=[instance_id])
    return {"instanceId": instance_id, "stopped": True}

(5) Responder

Github cdk/lambda/responder/index.py

LINEのレスポンスを受け取るための Webhook です。

005

受信した生のリクエストボディを Channel Secret で HMAC-SHA256 計算し、Base64 にエンコードした値を x-line-signature ヘッダと比較します(Webhook の署名検証)。

受信した、postback(継続/停止)は、DynamoDB のタスクトークンで SendTaskSuccess してステートマシンを再開させます。

なお、「停止」を受け取った場合は、直ちにインスタンスを停止しています。(理由については後述)

lambda/responder/index.py(抜粋)

def handler(event: dict[str, Any], context: Any) -> dict[str, Any]:

    # body取得

    # x-line-signature を Channel Secret で HMAC-SHA256 検証
    signature = request_headers.get("x-line-signature", "")
    channel_secret = get_secure_parameter(SECRET_PARAM)
    if not verify_signature(channel_secret, raw_body, signature):
        return {"statusCode": 403, "body": "invalid signature"}

    payload = json.loads(raw_body) if raw_body else {}
    access_token = get_secure_parameter(TOKEN_PARAM)

    for line_event in payload.get("events", []):
        user_id = line_event.get("source", {}).get("userId", "")
        # userId 取得用のログ(初回の userId 登録に利用)
        print(f"LINE event type={line_event.get('type')} userId={user_id}")

        if line_event.get("type") != "postback":
            continue

        postback_data = parse_postback_data(line_event["postback"]["data"])
        action = postback_data.get("action")
        instance_id = postback_data.get("instanceId", "")
        session_id = postback_data.get("session", "")
        reply_token = line_event.get("replyToken", "")

        # 現在セッションのタスクトークンで SendTaskSuccess し、待機中のステートマシンを再開する
        if action == "continue":
            resume_state_machine(instance_id, session_id, "continue")
            reply_message(access_token, reply_token, f"{instance_id} を継続します。")
        elif action == "stop":
            ec2_client.stop_instances(InstanceIds=[instance_id])
            resume_state_machine(instance_id, session_id, "stop")
            reply_message(access_token, reply_token, f"{instance_id} を停止しました。")

    return {"statusCode": 200, "body": "OK"}

(6) EventBridge

起動スケジュールは、デフォルトで毎時 0 分(cron(0 * * * ? *))となっていますが、cdk deploy -c schedule で変更可能です。

ただし、短くしすぎると、前回の通知セッション(応答待ち最大 wait_minutes ×(max_retry+1) ≒ 既定 10 分)が終わる前に次の実行が始まり、同じインスタンスのタスクトークンを上書きして誤動作しますので、間隔はセッション長より長くすることが必須です。

(7) 削除

この仕組みを完全に削除する場合は、teardown.shを使用してください。

cd scripts
./teardown.sh

このスクリプトは cdk destroy に加えて、SSM Parameter(SecureString)の削除と、残留リソースの確認まで行います。

6 いつでも停止

「停止」を受け取った場合は、直ちにインスタンスを停止しています。(理由については後述)

このボットの「停止」ボタンは、Step Functions が応答を待っている間かどうかに関係なく、いつ押しても有効です。たとえば、「継続」と押した直後でも、「停止」を押せば直ちにインスタンスは止まります。

この仕組みを実現するために、responder Lambda が「停止」を受け取った時点で、Step Functions を介さずに直接 ec2:StopInstances を実行しています。停止忘れを防ぐという目的上、「止めたい」という明確な意思表示は、仕組みの内部状態にかかわらず必ず効くべきだと考え、この構成にしています。

7 最後に

今回は、固定費ゼロを狙いつつ、LINE で能動的に「まだ使ってる?」と確認し、無応答なら自動停止する停止忘れ防止の仕組みを作ってみました。

確認は毎時 0 分に入るため、放置していても長くても 1 時間強(次の確認+無応答での停止まで約 10 分)で停止します。

ただし、本仕組みが減らせるのはあくまで「停止忘れ」であり、監視対象の EC2 自体の課金は別物です。本仕組みは停止忘れを減らすだけで、課金を止める最終的な責任は利用者にあります。特に GPU インスタンスは単価が高いため、本仕組みに頼り切らず、最終的にはご自身でも確認することをおすすめします。

これで以前より少し安心して、GPU インスタンスを起動できそうです。

8 参考リンク


この記事をシェアする

関連記事