はじめに
前回の観測編では、AWS に48時間だけハニーポットを置いて、来たアクセスを WAF・ALB・nginx のログで追いかけました。
この記事はその裏側で、環境をどう作って、どう消したか を記載します。
以下まとめです。
- 構築も削除も GitHub Actions のボタン1つ。スマホからでも承認まで完結する
- AWS への認証は OIDC。GitHub にアクセスキーを1つも置いていない
- Terraform は bootstrap / base / honeypot の3つに分けて、課金の大きい部分だけ先に消せるようにした
- 本番の前にリハーサルを1回通して、事前にミスに気づけた
こちらも観測編と同じく、設計やコードは Claude と壁打ちしながら作っています。
Terraform のコードは慣れることを重点において学習しながら実施し、GitHub Actions は Claude Code に投げました。
なぜ Actions から作って消すのか
ハニーポットは「置きっぱなし」が一番危ないもので費用もかかり続けますし、放置した EC2 が乗っ取られて踏み台になるのが危険です。
なので、次の3つを重点にして Claude と共に決めました。
- 消すのが簡単であること
- 何かあったらすぐ止められること
- AWS の鍵を GitHub に置かないこと
1と2は GitHub Actions、3は OIDC で解決しています。
※OIDC は最近知ったので積極的に使うようにしています。
全体像

Actions に用意した3つのワークフロー(接続確認・構築削除・緊急隔離)
Terraform を3つに分けた
| スタック | 中身 | いつ作る / 消す |
|---|---|---|
| bootstrap | state 用 S3、OIDC プロバイダ、Actions 用ロール3つ | 手元 PC で最初に1回。全部終わったら最後に消す |
| base | ALB ログ用 S3、ロググループ、GuardDuty と通知、予算アラート | 観測の前に作る。分析が終わってから消す |
| honeypot | VPC、EC2、ALB、WAF | 観測の48時間だけ。終わったらすぐ消す |
1つにまとめると、destroy したときにログも一緒に消えてしまいます。お金がかかるのは ALB・WAF・EC2 の側なので、honeypot だけ先に消せば、ログを残したまま課金の大半を止められるように分けました。
bootstrap だけ手元 PC で管理しているのは、「state を置く S3 バケットを作るスタック」の state は、まだ存在しないバケットには置けないからです。
state の同時書き込みを防ぐロックには、以前は DynamoDB のテーブルが必要でした。Terraform 1.10 からは use_lockfile = true を書くだけで S3 だけでロックできるようになったので、DynamoDB は使っていません。
OIDC でアクセスキーを置かない
アクセスキー方式との違い
よくあるのは、IAM ユーザーのアクセスキーを GitHub Secrets に入れて、Actions がそれで AWS に入る方式です。ただ、このキーには期限がなく、漏れたら気づくまで使われ放題です。
OIDC 方式では、GitHub がジョブごとに「このジョブは <OWNER>/<REPO> の main で動いています」と書いた署名付きのトークンを発行します。AWS はそれを確認して、1時間で切れる一時的な鍵を渡します。
① ジョブ開始(permissions: id-token: write でトークンを要求できる)
② GitHub が ID トークン(JWT)を発行
sub = repo:<OWNER>/<REPO>:ref:refs/heads/main など
③ そのトークンを付けて AWS STS の AssumeRoleWithWebIdentity を呼ぶ
④ STS が「本当に GitHub の署名か」「sub が信頼ポリシーの条件に合うか」を確認
⑤ OK なら1時間だけ有効な一時認証情報を返す
GitHub Secrets に入っているのはロールの ARN だけで、これは漏れても単体では使えません。
ロールを3つに分けた
| ロール | 使えるジョブ | 権限 |
|---|---|---|
| plan | main ブランチのワークフロー | 読み取り専用 |
| apply | Environment honeypot-apply で承認されたジョブだけ |
base / honeypot を作る・消すのに必要な分だけ |
| emergency | main ブランチのワークフロー |
Project=honeypot の EC2 の隔離・停止だけ |
apply ロールは強い権限を持つので、Environment の承認を通ったジョブしか使えないようにしています。ジョブに environment: を書くと、トークンの sub が ...:environment:honeypot-apply に変わるので、信頼ポリシーでそれを条件にしています。
apply ロールでは、さらに次のように絞りました。
- EC2 や WAF などは東京リージョンのときだけ
- IAM は「EC2 用のロールを1つ作る」ことと「SSM と CloudWatch Agent の管理ポリシーを付ける」ことだけ。管理者権限のロールを作って乗っ取る、という権限昇格ができない
- S3 は state のバケットと ALB ログのバケットだけ
緊急隔離のロールだけ承認なしにしているのは、本当に必要なときに承認待ちで止まっていては意味がないからです。

apply の前に承認待ちで止まる(Review deployments)
緊急隔離をスマホから
観測編で書いた「入られにくくする」「すぐ気づく」「止める」の3段の守りのうち、「止める」の部分です。スマホで GitHub の Actions を開いて、確認欄に ISOLATE と入力して実行すると、次の順に動きます。
- EC2 のセキュリティグループを、ルールが1つもない隔離用のものに差し替える
- ALB のターゲットから外す
- 調査用のスナップショットを取る(オフにもできる)
- EC2 を停止する
スナップショットを取るのは、乗っ取られたときに「何をされたのか」を後から調べられるようにするためです。隔離したあとは honeypot ごと destroy するので、EC2 のディスクも一緒に消えてしまいます。その前にディスクの中身を残しておけば、どこから入られて何を置かれたのかを、落ち着いてから確認できます。
リハーサルで踏んだ罠
本番の48時間を始める前に、「base を作る → honeypot を作る → 動作確認 → スマホから緊急隔離 → honeypot を消す」を1回通しました。ここで3つ詰まりました。どれも本番中に踏んでいたら困るものだったので、リハーサルをやってよかったと思っています。
1. OIDC で入れない(sub の形式が違った)
最初の接続確認が Not authorized to perform sts:AssumeRoleWithWebIdentity で失敗しました。
sub は「どのリポジトリのジョブか」を表す項目で、AWS 側の信頼ポリシーはこれを見て入れるかどうかを決めます。
確認用のワークフロー(OIDC whoami)でトークンの中身を表示して、信頼ポリシーに書いた sub と比べてみました。
# 信頼ポリシーに書いていた形
repo:<OWNER>/<REPO>:ref:refs/heads/main
# 実際に GitHub から届いたトークンの形
repo:<OWNER>@<OWNER_ID>/<REPO>@<REPO_ID>:ref:refs/heads/main
AWS のポリシーに書いていた sub の形式と、GitHub から届いたトークンの形式が違っていました。
GitHub から届いたトークンは、オーナーとリポジトリの名前の後ろに数値の ID が付く新しい形式になっていて、従来の形式で書いていた信頼ポリシーの条件と一致しなかったのが原因です。信頼ポリシーの sub を新しい形式に書き直したら、すぐに通りました。
GitHub の変更で、2026年7月15日以降に作ったリポジトリはこの形式になるそうです。リポジトリを消して同じ名前で作り直されても ID は変わるので、「同じ名前のリポジトリを作って他人のロールを引き受ける」攻撃を防げます。
2. バケット名の先頭に見えない文字が入った
base の plan が InvalidBucketName で失敗しました。
バケット名の先頭に BOM が付いていたのが原因でした。ぱっと見何で上手くいかないのか謎で、結構詰まった部分です。
BOM が入ったのは、手元の PC で bootstrap の output(バケット名やロールの ARN)を GitHub の Secrets に登録したときでした。Windows PowerShell で、terraform output | gh secret set ... のようにパイプで渡すと、値の先頭に BOM が付くようで、Claude に相談したところ --body で渡すように変えてみろとのことで、実施したら上手くいきました。
# パイプで渡すと BOM が付く(NG)
terraform output -raw tf_state_bucket | gh secret set TF_STATE_BUCKET
# --body で渡す(OK)
gh secret set TF_STATE_BUCKET --body (terraform output -raw tf_state_bucket)
普段使うコマンドラインは基本的に Linux が殆どなので、こういった落とし穴に躓くと知らない発見があっておもしろいですね。
3. 15分たっても 502 Bad Gateway
honeypot を作って curl を送ると、ずっと 502 Bad Gateway が返ってきました。
502 は「ALB は動いているが、後ろの EC2 から応答がない」という意味です(Server: awselb/2.0 なので ALB が返している)。そこで ALB → ターゲットグループ → EC2 の中、と手前から順に見ていき、Session Manager で EC2 に入って /var/log/cloud-init-output.log を見ました。
すると、EC2 の起動時に自動で実行される user_data のスクリプト(パッチを当てて nginx を入れる処理)の中で、dnf -y upgrade が Killed で止まっていました。
起動時のメモリ不足のようです。最小構成(t4g.nano、メモリ 512MB)で作ったことで引っかかりました。
user_data の最初、dnf を動かす前に 1GB のスワップ(メモリが足りないときにディスクを代わりに使う領域)を作るようにしたら、nginx まで無事に入りました。
# user_data の先頭(抜粋)
dd if=/dev/zero of=/swapfile bs=1M count=1024
chmod 600 /swapfile
mkswap /swapfile
swapon /swapfile
echo '/swapfile none swap sw 0 0' >> /etc/fstab
費用の見張り
AWS Budgets で予算を $6.5(約1,000円)にして、超えそうになったらメールが来るようにしました。実際にかかったのは観測編に書いたとおり 400〜500円ほどで、予算の半分以下に収まりました。
resource "aws_budgets_budget" "honeypot" {
name = "${var.project}-monthly"
budget_type = "COST"
limit_amount = tostring(var.budget_usd)
limit_unit = "USD"
time_unit = "MONTHLY"
notification {
comparison_operator = "GREATER_THAN"
threshold = 50
threshold_type = "PERCENTAGE"
notification_type = "ACTUAL"
subscriber_email_addresses = [var.alert_email]
}
notification {
comparison_operator = "GREATER_THAN"
threshold = 80
threshold_type = "PERCENTAGE"
notification_type = "ACTUAL"
subscriber_email_addresses = [var.alert_email]
}
notification {
comparison_operator = "GREATER_THAN"
threshold = 100
threshold_type = "PERCENTAGE"
notification_type = "FORECASTED"
subscriber_email_addresses = [var.alert_email]
}
}
予算額は変数 budget_usd で $6.5 を渡しています。実際の費用が50%・80%を超えたとき(ACTUAL)と、月末の予測が100%を超えそうなとき(FORECASTED)にメールが来ます。
撤収
撤収も Actions から、作ったのと逆の順に消しました。
- ログを手元に退避する(観測編を参照)
- honeypot を destroy(ここで課金の大半が止まる)
- 分析が終わったら base を destroy(GuardDuty もここで止まる)
- 最後に手元 PC で bootstrap を destroy(この記事を書き終えてから)
GuardDuty は無料期間の30日が過ぎても自動では止まりません。消し忘れると課金が続くので、base の destroy で止めるようにしています。
まとめ
作って消す仕組みについて分かったことは以下です。
- 消すのが簡単だと、怖い検証にも手を出しやすい。スマホから destroy と隔離ができるだけで、外出中の不安がだいぶ減った
- OIDC にすると、GitHub に AWS の鍵を置かずに済む。入れるかどうかは AWS 側の信頼ポリシーで決まる
- スタックを分けると、環境の部品ごとで追加や削除が容易
- リハーサルは必ずやる。今回の3つは、どれも本番中に踏んでいたら観測が止まっていた
観測編とあわせて、ハニーポットを「安全に置いて、ちゃんと片付ける」までを一通り体験できました。

