Step Functionsを使わずLambda Durable FunctionsでSlackの承認ボタン待ちを実装してみた

Step Functionsを使わずLambda Durable FunctionsでSlackの承認ボタン待ちを実装してみた

Lambda durable functionsの機能waitForCallbackを使って、Slackの承認ボタン押下まで処理をサスペンドさせる実装に挑戦しました。Step Functionsなしで、Lambda 2つとFunction URLだけでこれが実現できるか試してみます。
2026.07.30

どうも!オペ部の西村祐二です!

「Slackの承認ボタンが押されるまでワークフローを止めておく」という要件は、これまで Step Functions の waitForTaskToken を使うのが定番でした。re:Invent 2025 で発表された Lambda durable functions には外部コールバックを待てる waitForCallback があるので、Step Functions を使わず Lambda だけで同じことができるのか試してみました。

結果として、Lambda 2つと Lambda Function URL だけの構成で「Slack に承認ボタンを投稿 → 押されるまでサスペンド → 押されたら再開」が動きました。承認・却下に加えて、誰も押さなかった場合のタイムアウト処理までコードで書けます。ハマったポイントも含めてまとめます。

Lambda Durable Functions の waitForCallback でできること

Lambda durable functions は、チェックポイントとリプレイの仕組み(処理の進捗を保存しておき、再開時はコードを先頭から再実行しつつ保存済みの結果を読み戻して続きから進める)で、「複数回の呼び出しにまたがる長時間ワークフロー」を1つの Lambda 関数のコードとして書ける機能です。関数作成時に durable execution を有効化し、ハンドラを Durable Execution SDK の withDurableExecution でラップして使います。

外部イベント待ちに使うのが waitForCallback です。呼び出すと callbackId が発行され、外部システムが SendDurableExecutionCallbackSuccess API を呼ぶまで実行がサスペンドされます。サスペンド中はコンピュート課金が発生せず(オンデマンド関数の場合)、最大1年(ExecutionTimeout の上限 31,622,400 秒)まで待てます。

Step Functions で同じことをやる場合の構成要素と対応させると、次のようになります。

Step Functions(waitForTaskToken) Lambda durable functions
ステートマシン + ASL 定義 Lambda 関数のコード
TaskToken callbackId
SendTaskSuccess / SendTaskFailure SendDurableExecutionCallbackSuccess / Failure
タイムアウトは TimeoutSeconds / HeartbeatSeconds で設定 waitForCallbacktimeout オプション

今回の構成

構成要素は次の4つだけです。ステートマシンも API Gateway もありません。

  • 承認フロー本体(durable function): Slack へボタン付きメッセージを投稿し、waitForCallback で押下を待つ
  • コールバック受信 Lambda + Function URL: Slack のボタン押下(interactivity)を受けて SendDurableExecutionCallbackSuccess を呼ぶ
  • Slack app: Interactivity の Request URL に Function URL を設定
  • Secrets Manager: Slack Bot Token の保管

処理の流れは以下です。

  1. 承認フロー本体を非同期呼び出しで起動
  2. waitForCallback の submitter(callbackId を受け取って外部への通知を行う関数)内で、callbackId をボタンの value に埋めた Block Kit メッセージを Slack に投稿
  3. 関数はサスペンド
  4. ボタン押下 → Slack が Function URL に interaction payload を POST
  5. コールバック受信 Lambda が value から callbackId を取り出して SendDurableExecutionCallbackSuccess を呼ぶ
  6. 承認フロー本体がリプレイで再開し、承認/却下の結果に応じた後続処理を実行

試してみる

環境

  • aws-cdk-lib 2.262.1
  • @aws/durable-execution-sdk-js 2.2.0
  • @aws-sdk/client-lambda 3.1097.0
  • Node.js 22 / TypeScript 5.9
  • リージョン: ap-northeast-1(東京リージョンは 2025-12-18 に対応済み)

1. CDK で durable function を定義する

aws-cdk-lib v2.232.0 から Function の L2 コンストラクトが durableConfig にネイティブ対応しています。executionTimeout(必須)と retentionPeriod(完了後の実行履歴保持期間、デフォルト14日)を指定するだけです。

lib/stack.ts
import * as cdk from "aws-cdk-lib";
import * as lambda from "aws-cdk-lib/aws-lambda";
import * as nodejs from "aws-cdk-lib/aws-lambda-nodejs";
import * as iam from "aws-cdk-lib/aws-iam";
import * as secretsmanager from "aws-cdk-lib/aws-secretsmanager";
import { Construct } from "constructs";
import * as path from "path";

const BOT_TOKEN_SECRET_NAME = "dev/blog-verify/slack-bot-token";

export class DurableSlackApprovalStack extends cdk.Stack {
  constructor(scope: Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props);

    const botTokenSecret = secretsmanager.Secret.fromSecretNameV2(
      this,
      "BotTokenSecret",
      BOT_TOKEN_SECRET_NAME,
    );

    // 承認フロー本体(durable function)
    const approvalFlowFn = new nodejs.NodejsFunction(this, "ApprovalFlowFn", {
      entry: path.join(__dirname, "../lambda/approval-flow.ts"),
      runtime: lambda.Runtime.NODEJS_22_X,
      timeout: cdk.Duration.seconds(60),
      durableConfig: {
        executionTimeout: cdk.Duration.hours(1),
        retentionPeriod: cdk.Duration.days(7),
      },
      environment: {
        BOT_TOKEN_SECRET: BOT_TOKEN_SECRET_NAME,
      },
    });
    botTokenSecret.grantRead(approvalFlowFn);

    // Slackのボタン押下を受けてコールバックを完了させるLambda
    const slackCallbackFn = new nodejs.NodejsFunction(this, "SlackCallbackFn", {
      entry: path.join(__dirname, "../lambda/slack-callback.ts"),
      runtime: lambda.Runtime.NODEJS_22_X,
      timeout: cdk.Duration.seconds(10),
    });
    // durable execution のARNは「関数ARN:バージョン/durable-execution/...」形式のため
    // Resource には :* までを含めたパターンが必要
    slackCallbackFn.addToRolePolicy(
      new iam.PolicyStatement({
        actions: [
          "lambda:SendDurableExecutionCallbackSuccess",
          "lambda:SendDurableExecutionCallbackFailure",
        ],
        resources: [`${approvalFlowFn.functionArn}:*`],
      }),
    );

    const fnUrl = slackCallbackFn.addFunctionUrl({
      authType: lambda.FunctionUrlAuthType.NONE,
    });

    new cdk.CfnOutput(this, "CallbackUrl", { value: fnUrl.url });
    new cdk.CfnOutput(this, "ApprovalFlowFunctionName", {
      value: approvalFlowFn.functionName,
    });
  }
}

ポイントは2つあります。

  • durableConfig を指定すると、CDK が自動生成する実行ロールに通常の AWSLambdaBasicExecutionRole の代わりに AWSLambdaBasicDurableExecutionRolePolicy が付与されます(チェックポイント保存用の権限が含まれます)。synth したテンプレートで確認できました
  • コールバックを完了させる側の IAM は、Resource を必ず 関数ARN:* のようにバージョン部分のワイルドカードまで含めて指定します。durable execution の ARN は 関数ARN:バージョン/durable-execution/実行名/ID という形式なので、関数 ARN をそのまま書くとマッチせず AccessDenied になります(公式ドキュメントにも明記があります)

2. 承認フロー本体を実装する

ハンドラを withDurableExecution でラップし、ハンドラの第2引数に渡ってくる DurableContext から waitForCallback を呼びます。waitForCallback の第2引数である submitter 関数に callbackId が渡ってくるので、その中で Slack への投稿を行います。

lambda/approval-flow.ts
import {
  withDurableExecution,
  DurableContext,
  CallbackTimeoutError,
} from "@aws/durable-execution-sdk-js";
import {
  SecretsManagerClient,
  GetSecretValueCommand,
} from "@aws-sdk/client-secrets-manager";

const secretsManager = new SecretsManagerClient({});

interface ApprovalEvent {
  channel: string;
  summary: string;
  timeoutMinutes?: number;
}

interface Decision {
  approved: boolean;
  user: string;
}

// トークンはSecrets Managerから都度取得し、チェックポイントには永続化しない
async function getBotToken(): Promise<string> {
  const res = await secretsManager.send(
    new GetSecretValueCommand({ SecretId: process.env.BOT_TOKEN_SECRET! }),
  );
  return res.SecretString!;
}

async function postSlackMessage(body: unknown): Promise<void> {
  const token = await getBotToken();
  const res = await fetch("https://slack.com/api/chat.postMessage", {
    method: "POST",
    headers: {
      "Content-Type": "application/json; charset=utf-8",
      Authorization: `Bearer ${token}`,
    },
    body: JSON.stringify(body),
  });
  const data = (await res.json()) as { ok: boolean; error?: string };
  if (!data.ok) {
    throw new Error(`Slack API error: ${data.error}`);
  }
}

export const handler = withDurableExecution(
  async (event: ApprovalEvent, context: DurableContext) => {
    let decision: Decision;
    try {
      // Slackに承認ボタン付きメッセージを投稿し、ボタン押下まで待機する
      const result = await context.waitForCallback<string>(
        "wait-for-slack-approval",
        async (callbackId) => {
          await postSlackMessage({
            channel: event.channel,
            text: `承認リクエスト: ${event.summary}`,
            blocks: [
              {
                type: "section",
                text: {
                  type: "mrkdwn",
                  text: `*承認リクエスト*\n${event.summary}`,
                },
              },
              {
                type: "actions",
                elements: [
                  {
                    type: "button",
                    text: { type: "plain_text", text: "承認" },
                    style: "primary",
                    action_id: "approve",
                    value: callbackId,
                  },
                  {
                    type: "button",
                    text: { type: "plain_text", text: "却下" },
                    style: "danger",
                    action_id: "reject",
                    value: callbackId,
                  },
                ],
              },
            ],
          });
        },
        { timeout: { minutes: event.timeoutMinutes ?? 5 } },
      );
      decision = JSON.parse(result) as Decision;
    } catch (err) {
      if (err instanceof CallbackTimeoutError) {
        // 期限までに誰も押さなかった場合は自動却下として扱う
        await context.step("notify-timeout", async () => {
          await postSlackMessage({
            channel: event.channel,
            text: `承認リクエストは期限切れになりました(自動却下): ${event.summary}`,
          });
        });
        return { status: "timeout" };
      }
      throw err;
    }

    await context.step("notify-result", async () => {
      await postSlackMessage({
        channel: event.channel,
        text: decision.approved
          ? `${decision.user} さんが承認しました。後続処理を実行します: ${event.summary}`
          : `${decision.user} さんが却下しました: ${event.summary}`,
      });
    });

    return {
      status: decision.approved ? "approved" : "rejected",
      by: decision.user,
    };
  },
);

callbackId はボタンの value にそのまま埋めています。callbackId は最大1024文字の base64 風文字列(今回の実測では347文字)なので、Slack ボタンの value 上限(2000文字)に収まります。

タイムアウト処理は CallbackTimeoutError を catch して書きます。timeout を指定しない場合は ExecutionTimeout まで待ち続けるため、公式のベストプラクティスでも必ず設定することが推奨されています。

3. Slack ボタンの受け口を実装する

Function URL で Slack の interaction payload を受け、SendDurableExecutionCallbackSuccess を呼びます。

lambda/slack-callback.ts
import {
  LambdaClient,
  SendDurableExecutionCallbackSuccessCommand,
} from "@aws-sdk/client-lambda";
import type {
  LambdaFunctionURLEvent,
  LambdaFunctionURLResult,
} from "aws-lambda";

const lambda = new LambdaClient({});

interface SlackInteractionPayload {
  user: { id: string; username?: string };
  actions: { action_id: string; value: string }[];
  response_url: string;
}

export const handler = async (
  event: LambdaFunctionURLEvent,
): Promise<LambdaFunctionURLResult> => {
  const rawBody = event.isBase64Encoded
    ? Buffer.from(event.body ?? "", "base64").toString()
    : (event.body ?? "");
  const params = new URLSearchParams(rawBody);

  // Request URL保存時にSlackが送るssl_check検証POSTには200を返す
  // (payloadフィールドを持たないPOSTに200を返さないとURLの保存に失敗する)
  const payloadStr = params.get("payload");
  if (!payloadStr) {
    return { statusCode: 200, body: "" };
  }
  const payload = JSON.parse(payloadStr) as SlackInteractionPayload;

  const action = payload.actions[0];
  const decision = {
    approved: action.action_id === "approve",
    user: payload.user.username ?? payload.user.id,
  };

  try {
    await lambda.send(
      new SendDurableExecutionCallbackSuccessCommand({
        CallbackId: action.value,
        // ResultはUint8Array型のためBufferで渡す
        Result: Buffer.from(JSON.stringify(decision)),
      }),
    );
  } catch (err) {
    // 期限切れ・処理済みのコールバックに対する押下はSlack上で通知する
    await fetch(payload.response_url, {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({
        replace_original: true,
        text: `この承認リクエストは既に処理済みか、期限切れです(${(err as Error).name})`,
      }),
    });
    return { statusCode: 200, body: "" };
  }

  // 元のメッセージを押下結果に差し替える(ボタンの二度押し防止)
  await fetch(payload.response_url, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({
      replace_original: true,
      text: decision.approved
        ? `${decision.user} さんが承認しました`
        : `${decision.user} さんが却下しました`,
    }),
  });

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

実装上の注意点が3つあります。

  • payload を持たない POST に 200 を返す分岐が必須です。Slack は Request URL の保存時に ssl_check=1&token=... という検証 POST を送ってきます。最初はこの分岐がなく、ハンドラがクラッシュして 502 を返していました。その結果、Slack 側で Request URL の保存が失敗し続けるという現象にハマりました
  • Result の型は Uint8Array です。boto3 と違い文字列をそのまま渡せないので、Buffer.from(JSON.stringify(...)) とします
  • 期限切れ後や二度押しで SendDurableExecutionCallbackSuccess を呼ぶと例外(クローズ済みコールバックへの送信)になるため、catch して response_url 経由でユーザーに通知します

4. Slack app とトークンを準備する

Slack app 側の準備は次の2つです。Interactivity の Request URL 設定は、デプロイして Function URL が確定した後に行います(手順7)。

  1. Bot Token Scopes に chat:write を付与し、対象チャンネルに Bot を招待する(後述の curl での単体テストも行う場合は channels:history も付与する)
  2. Bot User OAuth Token(xoxb- から始まる文字列)を Secrets Manager に登録する

トークンはコード側で SecretString を生の文字列として読むため、JSON 形式ではなくそのまま保存します。

aws secretsmanager create-secret \
  --name dev/blog-verify/slack-bot-token \
  --secret-string "xoxb-(Bot User OAuth Token)"

5. デプロイして承認フローを起動する

依存パッケージを入れて cdk deploy します。NodejsFunction のバンドルに esbuild を使うため、devDependencies に含めておきます(cdk bootstrap は済んでいる前提です)。

pnpm add aws-cdk-lib constructs @aws/durable-execution-sdk-js \
  @aws-sdk/client-lambda @aws-sdk/client-secrets-manager
pnpm add -D aws-cdk tsx typescript esbuild @types/node @types/aws-lambda
pnpm exec cdk deploy

デプロイ出力の CallbackUrl(Function URL)と ApprovalFlowFunctionName を控えて、以降のコマンドでは環境変数 FUNCTION_URL / FUNCTION_NAME にセットして使います。

承認フローの呼び出しは非同期(--invocation-type Event)で、関数名にバージョンやエイリアスを付けて行います。durable function はバージョン・エイリアス・$LATEST のいずれかを付けた ARN(qualified ARN)でしか呼び出せません。また、同期呼び出しでは実行全体が15分の制限を受けるため、承認待ちのような長時間ワークフローは非同期呼び出しが前提です。

aws lambda invoke \
  --function-name "$FUNCTION_NAME:\$LATEST" \
  --invocation-type Event \
  --cli-binary-format raw-in-base64-out \
  --payload '{"channel":"C0XXXXXXXXX","summary":"本番環境へのデプロイ(release v1.2.3)","timeoutMinutes":30}' \
  /dev/null

チャンネルにボタン付きメッセージが投稿されます。

CleanShot 2026-07-30 at 23.14.19@2x

待機中の実行は RUNNING ステータスとして確認できます。手元の AWS CLI(2.31.39)には durable execution 系のサブコマンドがまだ入っていなかったため、AWS SDK for JavaScript から確認しました(ListDurableExecutionsByFunction / GetDurableExecution API)。

{"summary":"本番環境へのデプロイ...","Status":"RUNNING","Started":"2026-07-30T09:02:36.386Z"}

6. curl で受け口を単体テストする

Slack app の Interactivity を設定する前に、コールバック完了の一連の流れを curl で検証できます。Slack が送る interaction payload と同じ形式(application/x-www-form-urlencodedpayload フィールド)を Function URL に POST するだけです。

callbackId は手順5で投稿されたメッセージのボタン value に入っているので、conversations.history API から取り出します。

# 直近メッセージからcallbackIdを抽出(要 channels:history スコープ)
curl -s -H "Authorization: Bearer $SLACK_BOT_TOKEN" \
  "https://slack.com/api/conversations.history?channel=$CHANNEL_ID&limit=5" \
  -o hist.json
CALLBACK_ID=$(jq -r '.messages[0].blocks[] | select(.type=="actions") | .elements[] | select(.action_id=="approve") | .value' hist.json)

# ボタン押下相当のpayloadをPOST
jq -nc --arg v "$CALLBACK_ID" \
  '{type:"block_actions",user:{id:"UTEST",username:"curl-test"},actions:[{action_id:"approve",value:$v}],response_url:"https://example.com/dummy"}' \
  > payload.json
curl -s -o /dev/null -w "HTTP %{http_code}\n" -X POST "$FUNCTION_URL" \
  -H "Content-Type: application/x-www-form-urlencoded" \
  --data-urlencode "payload@payload.json"

実行すると HTTP 200 が返り、数秒後に durable function が再開して「curl-test さんが承認しました。後続処理を実行します」が Slack に投稿されました。却下側(action_id: "reject")も同様に動きました。

なお、シェルで JSON を変数経由で扱うと zsh の echo\n を実際の改行に展開して jq のパースが壊れるため、上記のようにファイル経由で渡すのが安全です。

7. Interactivity を設定してボタンで動かす

Slack app の Interactivity & Shortcuts を有効化し、Request URL に CallbackUrl(Function URL)を設定します。保存時には前述の ssl_check 検証が走るため、受け口の Lambda をデプロイした後でないと保存できません。

あらためて承認フローを起動し、「承認」を押すと、元のメッセージが押下結果に差し替わり、durable function が再開して完了通知が投稿されます。

CleanShot 2026-07-30 at 23.15.17@2x

「却下」を押した場合も同様に、却下として再開します。

完了した実行は Status: SUCCEEDED になり、Result フィールドにハンドラの戻り値 {"status":"approved","by":"..."} が記録されます。

8. タイムアウトの挙動を確認する

タイムアウト5分の設定で放置してみると、ちょうど5分後に CallbackTimeoutError の catch 節が動き、自動却下メッセージが投稿されました。

CleanShot 2026-07-30 at 23.16.49@2x

GetDurableExecutionHistory API でイベント履歴を見ると、サスペンドと再開の流れがよく分かります。

ExecutionStarted     09:02:37
CallbackStarted      09:02:38   ← waitForCallback 開始
StepStarted          09:02:38   ← submitter(Slack投稿)
StepSucceeded        09:02:40
InvocationCompleted  09:02:40   ← ここで1回目の呼び出しが終了(サスペンド)
CallbackTimedOut     09:07:38   ← 5分後にコールバックが期限切れ
StepStarted          09:07:39   ← notify-timeout step(リプレイで再開)
StepSucceeded        09:07:40
ExecutionSucceeded   09:07:40   ← ハンドリングされたので実行自体は成功

タイムアウトしても catch していれば実行ステータスは SUCCEEDED になる、というのがポイントです。

ここで1つ罠を踏みました。callback の timeout に60分を指定した際、durableConfigexecutionTimeout(1時間)と同時に期限が来る状態になり、実行全体が TIMED_OUT ステータスで強制終了しました。この場合、CallbackTimeoutError の catch は実行されず、自動却下の通知も飛びません。callback の timeout は必ず executionTimeout より短く設定する必要があります。

9. 待機中の課金を確認する

durable functions の関数は構造化 JSON のプラットフォームログを出力します。承認までに約72秒待機した実行の platform.report を見ると、課金対象は2回の短い呼び出しだけでした。

10:35:43  billed: 3642ms  ← 1回目(Slack投稿してサスペンドするまで)
10:36:57  billed: 1478ms  ← 2回目(コールバック受信後のリプレイ・完了処理)

待機中の約72秒は duration 課金の対象外です。承認待ちが数時間・数日になっても、コンピュート課金は変わりません。

なお、durable functions 固有の課金として、durable operations(実行開始・step 完了・wait 作成などの操作単位)が $8.00/100万操作、チェックポイントのデータ書き込みが $0.25/GB、保持が $0.15/GB-月かかります(us-east-1、2026-07-30 時点の料金ページより。東京リージョンの単価は料金ページで確認できます)。今回の承認フローはイベント履歴ベースで1実行あたり12イベント程度、操作単価の単純計算で 0.0001 ドル未満なので、課金への影響はごく小さい範囲です。

検証結果の考察

Step Functions の waitForTaskToken を使った定番構成(ステートマシン + API Gateway + Lambda 2つ)と比べて、今回の構成では次が不要になりました。

  • ステートマシンと ASL 定義(フロー全体が TypeScript のコードになった)
  • API Gateway(Function URL で代替)
  • TaskToken の受け渡し設計(callbackId をボタンの value に埋めるだけ)

一方で、Step Functions にあるビジュアルなフロー表現や220以上のサービス統合はありません。公式の使い分けガイドでは、durable functions は「Lambda 内でのアプリケーション開発」に最適化されたもの、Step Functions は「AWS サービス横断のワークフローオーケストレーション」向けのものと位置づけられています。判断基準としては、関与する AWS サービスの数のほかに、標準的なプログラミング言語で書きたいか、ビジュアルデザイナー/ASL で定義したいか、非技術者がワークフローを確認する必要があるか、といった複数の観点が挙げられています。今回のような承認フローは Lambda 内のアプリケーションロジックそのものなので、durable functions に向いたユースケースだと言えます。

制約としては以下を押さえておく必要があります。

  • durable execution は関数の新規作成時のみ有効化可能(後付け不可)
  • 呼び出しにはバージョンやエイリアス付きの ARN が必須、長時間待機は非同期呼び出しが前提
  • コードはリプレイ前提の決定的な書き方が必要(外部 I/O は step や submitter の中に置く)

試してみた感想

waitForCallback の submitter に Slack 投稿を書くだけで「投稿と待機」がセットになる API 設計は直感的です。Step Functions で TaskToken を環境変数やペイロード経由で引き回していた頃と比べると、フローの見通しが良くなりました。承認タイムアウトの扱いも try/catch で書けるので、「期限切れなら自動却下して Slack に通知」のような分岐が普通のコードとして表現できるのが気に入りました。

一方、ssl_check への応答や2種類のタイムアウトの関係は、実際に動かさないと気づきにくいものでした。タイムアウト値の設計は最初に決めておくのが良さそうです。

まとめ

  • Lambda durable functions の waitForCallback で、Step Functions を使わずに Slack 承認ボタンの待機・再開が実装できました
  • 構成は Lambda 2つ + Function URL + Slack app のみ。待機中のコンピュート課金はありません
  • 承認・却下・タイムアウトの3経路と、二度押し時のエラーハンドリングまでコードで完結します
  • ハマりどころは「ssl_check への 200 応答」「IAM の Resource は 関数ARN:*」「Result の Uint8Array」「callback timeout と executionTimeout の関係」の4点です

誰かの参考になれば幸いです。


参考リンク:

この記事をシェアする

関連記事