AWS Blocks で週次 Slack 通知を実装し、ローカル検証から段階デプロイまで動かす

社内のクラウドセキュリティ環境の運用では、未対応の Asana チケットが担当者の手元で滞留しがちだった。
そこで、毎週月曜の朝に未対応チケットを担当者の Slack チャンネルへ通知するシステムを作った。
この通知システムの実行基盤を、当初書いていた Terraform から AWS Blocks へ移し替え、ローカルでの動作確認からサンドボックス検証、段階デプロイまでを一通り動かした。
その作業の記録を残す。

AWS Blocks とは何か

AWS Blocks は、アプリケーションコードの中で宣言した部品からインフラを生成する TypeScript 向けのフレームワークである。
CronJobAppSetting のような Building Block をコード上でインスタンス化すると、対応する AWS リソースが裏側で組み立てられる。
開発者は EventBridge Scheduler や Lambda、SSM Parameter Store を直接配線せず、やりたいこと(週次で関数を実行する、設定値を安全に保持する)を宣言する。

この性質が、今回のシステムと相性が良かった。
通知ロジックそのものは Asana と Slack の API を叩くだけで、必要なインフラは「定期実行」と「シークレットの保管」に限られる。
Terraform で EventBridge と Lambda と IAM ロールを個別に記述していたときは、アプリのコードとインフラの記述が別のファイルに分かれ、片方を変えるともう片方の追従が要った。
Building Block では、その二つが同じ TypeScript の中にある。

Building Block でインフラを宣言する

移行の中心は、インフラ定義を aws-blocks/index.ts の数十行に畳み込むことだった。
ここでは三種類の Building Block を使う。
構造化ログを出す Logger、設定値とシークレットを保持する AppSetting、定期実行する CronJob である。

ログ出力は Logger を一つ置くだけで CloudWatch Logs に接続される。

typescript
const scope = new Scope('asana-notify');

const logger = new Logger(scope, 'notify', {
  level: (process.env.LOG_LEVEL as LogLevel | undefined) ?? 'info',
  retention: 30,
});

Asana と Slack のトークンは AppSettingsecret: true を付けて宣言する。
これは SSM Parameter Store の SecureString に対応する。
値そのものはコードにもデプロイ時のテンプレートにも乗らず、デプロイ後に SSM へ投入する。

typescript
const asanaToken = new AppSetting<string>(scope, 'asana-token', {
  name: '/asana-notify/asana-token',
  secret: true,
});

通知対象のプロジェクトや dry-run の切り替えのように、秘匿しなくてよい設定値も同じ AppSetting で扱う。
secret を付けなければ通常の String パラメータになる。
初期値は synth 時の環境変数から取り、運用中は SSM 側で書き換えられる。

週次実行は CronJob で表す。
スケジュール式とタイムゾーンを渡し、ハンドラに実行したい処理を書く。

typescript
export const weekly = new CronJob(scope, 'weekly', {
  schedule: 'cron(0 9 ? * MON *)',
  timezone: 'Asia/Tokyo',
  description: '未対応 Asana チケットを週次で Slack に通知する',
  handler: async (event) => {
    logger.info('weekly_cron_triggered', { scheduledTime: event.scheduledTime });
    await runOnce();
  },
});

この CronJob が、デプロイ時に EventBridge Scheduler のルールと、それを受ける Lambda を生成する。
cron(0 9 ? * MON *)Asia/Tokyo の組み合わせで、毎週月曜 09:00 JST に発火する。
ハンドラから呼ぶ runOnce() は通知処理を 1 回実行する関数で、後述のローカル実行と E2E からも同じものを使う。

通知ロジック本体(src/)は AWS に依存しない素の TypeScript のまま残した。
Asana のクライアント、Slack のクライアント、担当者から通知先を解決するロジックは、Building Block を一切知らない。
AWS への依存を aws-blocks/ の薄い層に閉じ込めたことで、ロジックは単体テストだけで検証でき、基盤の差し替えもこの層の書き換えで済む。

ローカルでの動作確認

実 AWS に触れる前に、手元で挙動を確かめた。
AWS Blocks は、各 Building Block のモック実装を持つローカル開発サーバを備えている。
npm run dev を起動すると、AppSetting はローカルファイルに、CronJob はプロセス内のタイマーに置き換わり、AWS アカウントなしで全体が動く。

bash
npm run dev

手元での確認には実トークンを使う場面が出てくる。
これをシェルの履歴や設定ファイルに残さないよう、Asana と Slack のトークンは macOS のキーチェーンに預けた。
scripts/setup-app-creds.sh がプロンプトで受け取った値を security add-generic-password でキーチェーンへ保存し、画面にもファイルにも平文を残さない。

bash
./scripts/setup-app-creds.sh   # 一度だけ。値はプロンプトで入力(画面に表示されない)

ローカル実行の前に load-app-creds.sh を source すると、キーチェーンの値が環境変数へ展開される。
すでに環境変数があればそちらを優先し、無いときだけキーチェーンから補う。
これで、各コマンドにトークンを直書きせずに済む。

bash
source ./scripts/load-app-creds.sh   # ASANA_ACCESS_TOKEN / SLACK_BOT_TOKEN を読み出して export

週次のスケジュールを待っていては確認に丸一週間かかる。
そこで、通知処理を 1 回だけ手動で叩く invoke:local を用意した。
これは CronJob のハンドラと同じ runOnce() を直接呼ぶだけのスクリプトである。
トークンはキーチェーンから読めているので、誤って実際の Slack へ投稿しないよう DRY_RUN=true だけを付けて送信を抑止する。

bash
DRY_RUN=true \
ASANA_PROJECT_GIDS="111,222" SLACK_FALLBACK_CHANNEL_ID=C0123 \
npm run invoke:local

実行すると、何件のチケットを誰の Slack チャンネルへ通知したか(または抑止したか)の集計が JSON で返る。
ここで一点つまずいた。
ローカルの設定値は .bb-data/ に永続化されるため、環境変数を変えても前回の値が残ることがある。
反映されないときは rm -rf .bb-data でリセットすると直る。

集計の件数が合っていても、Slack 上での見た目までは数字からはわからない。
そこで、通知メッセージの Block Kit を投稿前に確認する preview:slack を挟んだ。
このスクリプトは固定のサンプルデータから Block Kit の JSON を組み立て、各メッセージについて Block Kit Builder のプレビュー URL を出力する。
URL をブラウザで開けば、実際に Slack へ表示される見た目をそのまま確認できる。

bash
npm run preview:slack   # Block Kit の JSON とプレビュー URL を出力(送信はしない)

見た目を確認したら、同じスクリプトでテスト用チャンネルへ実際に投稿し、通知内容まで突き合わせた。
SLACK_PREVIEW_CHANNEL を渡すと、サンプルメッセージをそのチャンネルへ送る(SLACK_BOT_TOKEN は source 済みのものを使う)。
投稿後は conversations.history で自分の投稿を読み返し、送ったブロック数と内容が一致するかを自動で検証する。

bash
SLACK_PREVIEW_CHANNEL=<テスト用チャンネル> npm run preview:slack

読み返し検証まで通ることで、Block Kit の組み立てと Slack への投稿が、見た目と内容の両面で正しいことを確認できた。

最後に、固定サンプルではなく実際の Asana データで通した。
DRY_RUN=false にして invoke:local を実行すると、送信抑止が外れ、実 Asana から取得したチケットがローカルのプロセスから実際の Slack へ流れる。

bash
DRY_RUN=false \
ASANA_PROJECT_GIDS="111,222" SLACK_FALLBACK_CHANNEL_ID=<テスト用チャンネ> \
npm run invoke:local

本当に投稿される以上、宛先を絞ることが前提になる。
SLACK_FALLBACK_CHANNEL_ID にテスト用のチャンネルを指定し、ASANA_PROJECT_GIDS も検証用のプロジェクトに限ることで、本番チャンネルや無関係な担当者へ通知が飛ぶのを避けた。
実行後は、メッセージがそのチャンネルへ届くこと、Slack Block Kit のレイアウトが崩れていないこと、チケットの名称と URL が正しく差し込まれていることを目視で確認した。

ここまでをひとつながりでたどると、キーチェーンからのトークン取得、Asana からのチケット取得、通知先の解決、Block Kit の組み立て、Slack への投稿と読み返しまで、デプロイ前に通しで確認できる。
トークンの権限と、Asana と Slack 双方の API 応答まで含めた動作を、この段階で保証できた。

この通し確認が手元のコマンドだけで完結することは、開発の進め方そのものを変える。
型チェック、単体テスト、ドライランの集計、Block Kit のプレビュー、実投稿の読み返しが、いずれもローカルで実行でき、結果がテキストや JSON で返る。
合否が機械的に判定できる形で揃っているため、コーディング、テスト、デバッグ、修正のサイクルを AI エージェントに回させられる。
エージェントがコードを書き、npm testinvoke:local を自分で叩き、失敗したログを読んで直し、また実行する、という反復を、AWS アカウントに触れず手元で閉じたまま続けられる。

なお、ドライランと同じく DRY_RUN の値も .bb-data/ に残る。
true から false への切り替えが効かないときは、同じく rm -rf .bb-data でリセットしてから実行する。

通知ロジック自体は vitest の単体テストで固めてある。
担当者から Slack ユーザーと通知先チャンネルを解決する部分、フォールバックの集約、Slack Block Kit の組み立てを、24 件のケースで検証している。

bash
npm run typecheck
npm test

型チェックと単体テストが通り、invoke:local の集計も期待どおりだった段階で、実 AWS へ進んだ。

サンドボックスへのデプロイ

次に、使い捨ての環境を実 AWS 上に立てて、生成されるリソースが意図どおりかを確かめた。
AWS Blocks の npm run sandbox は、エフェメラルなスタックをデプロイする。
ローカルのモックではなく本物の EventBridge Scheduler と Lambda、SSM パラメータが作られるので、IAM 権限やスケジュール式の妥当性まで確認できる。

実行には、対象アカウントとリージョンで一度だけ CDK ブートストラップが要る。

bash
npx aws-cdk bootstrap aws://<ACCOUNT_ID>/<REGION>   # 初回のみ
npm run sandbox

サンドボックスのスタックには、破棄しやすいよう全リソースに削除可能ポリシーを当ててある。
検証が済んだら片付ける。

bash
npm run sandbox:destroy

ここで生成物を確認したことで、CronJob が EventBridge のルールへ、secret: trueAppSetting が SecureString パラメータへ、それぞれ落ちることを実物で裏取りできた。

段階デプロイを CI に組む

恒久環境への反映は、ブランチの昇格で進む仕組みにした。
main から deployment/dev、そして deployment/prd の順に昇格する。
dev と prd は別の AWS アカウントを想定し、GitHub Environment ごとに変数とシークレットを分けて持つ。

各段階のゲートは次のように働く。

  • main へ merge:セキュリティスキャンの CLI でコードと生成されるインフラを検証し、成功すれば deployment/dev への PR を自動で作る。
  • deployment/dev へ merge:dev 環境へデプロイし、実際の Asana と Slack を相手に E2E を回し、成功すれば deployment/prd への PR を自動で作る。
  • deployment/prd へ merge:prd 環境へデプロイする。

認証は GitHub OIDC で行い、環境ごとの IAM ロールを引き受ける。
このロールの作成と AWS_DEPLOY_ROLE_ARN の登録は、scripts/setup-oidc-role.sh で環境別に自動化した。

特定の環境へ手で流す場合は、APP_ENV を指定して npm run deploy を呼ぶ。
デプロイ自体は値を持たないので、その後にトークンを SSM へ投入してはじめて通知が成立する。

bash
APP_ENV=dev ASANA_PROJECT_GIDS="111,222" SLACK_FALLBACK_CHANNEL_ID=C0123 npm run deploy
aws ssm put-parameter --name /asana-notify/asana-token --type SecureString --value "$ASANA_ACCESS_TOKEN" --overwrite
aws ssm put-parameter --name /asana-notify/slack-token --type SecureString --value "$SLACK_BOT_TOKEN" --overwrite

CI ではこの手順を踏まず、GitHub の Environment Secrets に置いたトークンを、デプロイ後に同じ SSM パラメータへ同期している。
コードとテンプレートにシークレットを残さない方針を、ローカルとCIの両方で揃えた。

採用できるのは TypeScript の場合に限られる

ここまでの体験は快適だったが、適用できる範囲には前提がある。
AWS Blocks は TypeScript を前提にしたフレームワークである。
売りである、フロントエンドがバックエンドのメソッドを型付きの関数として直接呼べる仕組みは、型がクライアントとサーバの境界を貫いて流れることで成り立つ。
ローカルでモックとして動く仕組みも、AppSettingCronJob といった Building Block が TypeScript のクラスとして実装されていることに依存する。
今回 Lambda 上で動いたのも Node のランタイムだった。

この前提が崩れるのが、アプリのロジックを Python で書きたい場合である。
処理本体が Python だと、型が境界を越える恩恵も、Building Block をそのまま import してモックで動かす流れも、import { api } from 'aws-blocks' のように呼び出す形も使えない。
Python のコードを Lambda で動かすこと自体は AWS の機能として可能だが、それは AWS Blocks の枠の外で、ランタイムやパッケージング、設定値の受け渡しを自分で配線する作業に戻ることを意味する。
つまり AWS Blocks が肩代わりしていた部分が、Python を対象にした途端にほとんど効かなくなる。

対象言語ごとに、Blocks の主な機能が使えるかを並べると次のようになる。
はそのまま使える、 は動くが本来の利点を欠く、× は枠の外で自分で組む、を表す。

機能TypeScriptJavaScriptPythonGo・Java など
Building Block の宣言からのリソース生成××
ローカルのモック実行(npm run dev / invoke:local××
フロントとバックを貫くエンドツーエンドの型×××
フロントからの型付き呼び出し(import { api }××
同じコードのままデプロイ(deploys unchanged)××
関数の実行ランタイムNodeNodeLambda を自前構成Lambda を自前構成

JavaScript は Building Block の宣言とローカル実行までは動くが、型がないためエンドツーエンドの型安全という中心的な利点を失う。
Python や Go、Java は、処理本体を Lambda で動かすこと自体は AWS の機能として可能でも、Building Block の宣言もモックも型付き呼び出しも使えない。
ランタイムやパッケージング、設定値の受け渡しを自分で配線することになり、AWS Blocks が肩代わりしていた部分がほぼ残らない。

今回のシステムは通知ロジックも含めて TypeScript で書いていたため、この前提に素直に乗れた。
逆に、機械学習や既存の Python 資産を中心に据えるプロジェクトでは、この道具の旨味は出にくい。
言語の選択を AWS Blocks に合わせられるかどうかが、採否の分かれ目になる。

移行してわかったこと

Terraform から AWS Blocks へ移したことで、インフラの記述量が目に見えて減った。
EventBridge と Lambda と IAM を別々に書いていた箇所が、CronJob 一つの宣言に置き換わったためである。
アプリのコードとインフラの宣言が同じ TypeScript に同居するので、片方を変えたときのもう片方の追従が要らなくなった。

一方で、生成されるリソースが宣言から自動で決まる以上、それが本当に意図どおりかは実環境で確かめたくなる。
ローカルのモックで素早く挙動を確認し、サンドボックスで生成物を裏取りし、CI の段階昇格で恒久環境へ広げる、という三段構えがこの確かめを支えた。
宣言を簡潔にする道具ほど、宣言と生成物の対応を見る手順を用意しておくと安心して使える。

この記事をシェア