はじめに
自律エージェント管理レイヤーの Kiro Crew を、AWS のプライベートサブネットに置いた EC2 に、インターネットに素で公開せず・固定費を極力かけない形で立ててみました。今回は実際に詰まった箇所を補いながらまとめていきます。
Kiro Crew は kiro-cli を唯一の LLM 経路として使う「エージェントを常駐させる基盤」で、ダッシュボード(Gateway)をポート 5476 で待ち受けます。会話でエージェントに仕事を頼める運用アシスタント型です。
今回のゴールは、この基盤を 入口を作らず(インバウンド・ゼロ)・使うときだけ起動する省コスト構成で EC2 に立てること。ALB や NAT Gateway、独自ドメインといった「常時課金する重い部品」を避けつつ、外から認証付き HTTPS で到達できるところまでを作ります。
まずは結論から
- EC2 は Public IP なし・SSH 非公開にし、管理は SSM セッションマネージャーに統一
- インバウンド通信はALB ではなく VPC origins を採用
- アウトバウンド通信はNAT Gateway ではなく NAT インスタンスを採用
- 起動時間を削減するため、EC2 の起動・停止をワンクリックで
Kiro Crew ダッシュボード
今回立てる Kiro Crew のダッシュボードはこんな画面です。ここに会話で仕事を頼むと、常駐したエージェントが調査や定型作業をこなしてくれます。
それでは本題に入っていきます。
全体像
まずは今回構成の全体像とそれらを採用した狙いなどについて述べます。
設計判断サマリー
今回、Kiro Crew をクラウドに立ち上げるに当たり、一貫して「常時課金する重い部品を避け、入口を作らず、使うときだけ起動する」方針でサービスを選択しています。素直な構成(ALB・NAT Gateway)と、今回の選択を1枚で対比すると次のとおりです。
| 論点 | 素直な構成 | 今回の選択 | 狙い |
|---|---|---|---|
| インバウンド | ALB + authenticate-cognito
|
CloudFront VPC origins + Lambda@Edge の Cognito | 独自ドメイン・ACM 証明書が要らず、ALB の固定費(月約 $18)も消える |
| アウトバウンド | NAT Gateway | NAT インスタンス(t4g.nano) |
NAT Gateway の固定費(月約 $45)を避け、起動・停止だけで経路が戻る |
| 管理経路 | SSH / 踏み台 | SSM セッションマネージャー(Public IP なし・SSH 非公開) | 入口(インバウンド)をそもそも作らない |
| コスト運用 | 常時起動 | /ops ページから起動・停止をワンクリック | 「使うときだけ起動」で時間課金を稼働分だけに(個人利用で月約 $35) |
「素直な構成」でも動きますが、ALB と NAT Gateway だけで月 $60 超が使っていなくてもかかります(いずれも時間課金分。NAT Gateway はこれに加えてデータ処理料 $0.062/GB が別途かかります)。個人が常用する環境では、この固定費を削るかどうかが月額を大きく左右します。
なぜプライベート EC2 なのか
そもそも、なぜローカルでも Web 版でもなく「プライベート EC2 に Kiro Crew を立てる」なのか。ここを先に整理します。
Kiro にはいくつかの顔があります。Kiro IDE は人が主導して開発する場所、Kiro CLI はコマンドとして使うもの、Kiro Web & Mobile は Kiro 管理のクラウドに仕事を任せる形、そして今回の主役 Kiro Crew は kiro-cli をベースに、自分たちの環境にエージェントを「住まわせる」マルチエージェントの運用プラットフォームです。
そのうえで、今回の置き場所を選んだ理由が3つあります。
- IDE ではなく Crew: やりたいのは常時コードを編集する開発ではなく、調査・集計・定型作業を会話で頼むこと。この用途では常駐エージェント型の Crew が合う
- Web 版ではなく自前: 扱うデータやトークンを、外部 SaaS ではなく自分の管理する環境に置きたい。この用途では Web 版は適さない
- ローカルではなくサーバ: PC を閉じても処理を続けてほしい。スペック(主にメモリ)や OS(Linux 前提の挙動)の制約からも、サーバ側に集約するほうが素直
この記事はまず自分ひとりが、公開せず・固定費を抑えて使える個人環境を作る、という範囲に絞っています。複数人への展開はここでは扱いません。
AWSアーキテクチャ図
到達点の構成を1枚にまとめると次のとおりです。日常利用は CloudFront + Cognito 認証を通って VPC origins 経由で Kiro Crew に届き、EC2 からの外向き通信(アウトバウンド)は NAT インスタンス → IGW を経由します。
なお、構築・保守の管理経路はこの図には描いていませんが、別途 SSM ポートフォワード(ポート 5476) で手元から直接つなぎます。
ポイントは、EC2 に入口を作らない(インバウンド・ゼロ)、外への通信は NAT インスタンス、手元からの管理は SSM のポートフォワード、そして日常のアクセスは CloudFront + Cognito 認証で受ける、という切り分けです。ダッシュボードもエージェントも、素の EC2 としては一切インターネットに露出しません。
主要リソース
| 項目 | 値 |
|---|---|
| VPC |
10.10.0.0/16(2 AZ 構成、public/private を各2サブネット) |
| Crew EC2 |
t3a.large(2 vCPU / 8 GiB / x86_64)→ 後に r6a.large(16 GiB)へ |
| OS / ディスク | Amazon Linux 2023 / EBS gp3 60 GB(暗号化)+ swap 4 GB |
| NAT |
t4g.nano の NAT インスタンス(NAT Gateway より安価) |
| 入口 | CloudFront VPC origins(ALB・独自ドメインなし)+ Cognito 認証 |
| 管理 | Public IP なし・SSH 非公開・SSM セッションマネージャーのみ |
EC2 は意図的に x86_64 を選びました。Kiro Crew が同梱する CPython などが x86_64 向けで、Graviton(ARM)にすると再インストールの手間が増えるためです。
構築の実際
構築は「ネットワーク → NAT → EC2 → Kiro Crew インストール → サービス化」の順です。依存関係の都合で、IAM ロール・セキュリティグループ・パブリックルートは最初のインスタンスを起動する前に用意しておく必要があります。これらが揃っていないと、起動したインスタンスに SSM セッションマネージャーで接続できないためです。
各節では、設計の説明に続けて実際に使った設定値を載せます。手順そのものは AWS の基本操作なので省きますが、値をたどれば同じ構成を再現できるようにしてあります。
ネットワーク
2 AZ 構成で、public / private を各2サブネット作ります。日常アクセスはすべて CloudFront 経由なので public サブネットに置くのは NAT インスタンスだけで、Crew EC2 は private サブネットに閉じ込めます。
ルートテーブルは、public は IGW へ、private は NAT インスタンスの ENI へ向けます(0.0.0.0/0 → eni-xxxx)。private 側を ENI 指定にしておくと、NAT を停止・起動してもルートを張り替えずに済みます。
| 項目 | 設定値 |
|---|---|
| VPC | 10.10.0.0/16 |
| public サブネット |
10.10.0.0/24(1a)、10.10.1.0/24(1c) |
| private サブネット |
10.10.10.0/24(1a)、10.10.11.0/24(1c) |
| ルートテーブル(public) | 0.0.0.0/0 → IGW |
| ルートテーブル(private) | 0.0.0.0/0 → NAT インスタンスの ENI |
セキュリティグループは2つです。Crew EC2 用はインバウンドを一切開けないのが要点で、SSM はアウトバウンドで成立するため 22 番すら不要です(CloudFront からの受信は VPC origin 作成後に追加)。
| SG | インバウンド | 用途 |
|---|---|---|
| Crew EC2 用 | なし(VPC origin 作成後に TCP 5476 ← CloudFront サービス管理 SG を追加) | Crew 本体 |
| NAT 用 | 全プロトコル ← 10.10.10.0/24、10.10.11.0/24(private サブネット) |
private からの中継を受ける |
NAT 用 SG のインバウンドを「private サブネットから全プロトコル許可」と広めにしているのは、443/80 だけに絞ると DNS over TCP や NTP が抜けて切り分けづらい不具合になるためです。送信元を private サブネットの CIDR に限っているので、外部には開いていません。
SSM ロール設計
EC2 の管理は SSM セッションマネージャーに統一します。SSH も踏み台も使わず、Public IP も付けないので、インスタンスを操作する唯一の経路が SSM です。これを成立させるため、EC2 には SSM 接続に必要な AmazonSSMManagedInstanceCore を含む IAM ロールを与えます。管理の生命線なので、このロールは外せません。
- AWS 管理ポリシー
AmazonSSMManagedInstanceCore(アタッチするだけ)
このマネージドポリシー1枚で SSM セッションマネージャーに接続でき、Kiro Crew の動作に必要な権限もこれで足ります。基本はこれだけで動きますが、1点だけ締めておきたい穴があるので、次でそこを塞ぎます。
Deny 設計で Parameter Store を締める
AmazonSSMManagedInstanceCore には、接続用の権限(ssmmessages:* など)だけでなく、ssm:GetParameter を Resource: "*" で許す権限も含まれています。SSM は「インスタンス接続」と「Parameter Store 読み取り」が同じサービスに同居しているため、接続のために付けたロールが、ついでにアカウント内のすべての Parameter Store を読める状態になってしまいます。Kiro Crew はこの EC2 上で自律的にシェルや AWS CLI を実行し、しかも外部入力(プロンプト)で駆動されるため、他プロジェクトの秘密まで読み取れる状態は無視できません。
そこで、マネージドポリシーを外すのではなく、明示的な Deny をインラインポリシーで重ねます(Deny は Allow に優先し、AWS 側のポリシー更新も受け続けられます)。
{
"Version": "2012-10-17",
"Statement": [{
"Sid": "DenyParameterStoreOutsideOwnPath",
"Effect": "Deny",
"Action": ["ssm:GetParameter", "ssm:GetParameters", "ssm:GetParametersByPath", "ssm:GetParameterHistory"],
"NotResource": [
"arn:aws:ssm:ap-northeast-1:<ACCOUNT_ID>:parameter/kiro-crew/*",
"arn:aws:ssm:ap-northeast-1::parameter/aws/service/*"
]
}]
}
SSM 接続用の権限(ssmmessages:* など)は Deny に含めていないので、セッションマネージャーは引き続き使えます。
このロールを EC2 に付けておけば、SSH も Public IP も使わずに SSM セッションマネージャーで中に入れます。ここから先は、NAT インスタンスも Crew 本体も、このロールを付けた各インスタンスに SSM で接続して作業していきます。シェルで中に入るコマンドはこれです。
aws ssm start-session --region ap-northeast-1 --target <INSTANCE_ID>
接続すると既定で ssm-user になります(後述のインストール手順では ec2-user に切り替えます)。
NAT インスタンス
まず「NAT とは何か」から整理します。プライベートサブネットの EC2 は Public IP を持たず、外(インターネット)から直接は到達できません。ですが、OS アップデートや curl、各種 API 呼び出しなど EC2 側から外に出ていく通信(アウトバウンド) は必要です。この「中からは出られるが、外からは入れない」を実現する仕組みが NAT(Network Address Translation) です。プライベート側の通信を NAT が受け取り、自分のパブリック IP に置き換えて外へ中継します。戻りの応答は NAT が記憶していて、元の EC2 に返します。
AWS で NAT を用意する方法は2つあります。
| 方式 | 中身 | 課金 | 特徴 |
|---|---|---|---|
| NAT Gateway | AWS マネージドの NAT サービス | 時間課金(月約 $45)+ データ処理料。停止できず削除のみ | 冗長・高スループット。手間はかからないが固定費が高い |
| NAT インスタンス | 自前の EC2 を NAT として動かす | EC2 の時間課金のみ(t4g.nano なら数百円/月、停止すればゼロ) |
自分で設定が要る分安く、起動・停止できる |
今回は「使うときだけ起動する省コスト構成」が目的なので、停止できて安い NAT インスタンスを選びました。NAT インスタンスは「NAT の役割を持たせた普通の EC2」です。普通の EC2 と違う設定が2つだけあり、それがそのまま次のハマりどころになります。
通信の向きを一段抽象化すると、こういう非対称な関係になります。アウトバウンド(中から外)は NAT が中継して通し、インバウンド(外から中への接続)は通しません。外とのやりとりは NAT インスタンスが一手に引き受けます。
戻りの応答が EC2 まで返るのは、NAT が「どの通信を中継したか」を覚えているためです。あくまで中から始めた通信の応答だけが戻り、外から新規に接続を張ることはできません。
NAT インスタンスは SSM で接続できるのでキーペアなしで起動できますが、「普通の EC2」と違う設定が2つ必要で、どちらもハマりやすいポイントです。
1. 送信元/送信先チェックを無効化する
NAT は他人(private 側の EC2)のパケットを中継するのが仕事です。ところが EC2 は既定で「自分宛て/自分発でないパケットは破棄する」安全チェックが有効で、このままだと中継パケットが捨てられ、外に出られません。そこでこのチェックを無効化します。
設定時の注意が2つあります。起動テンプレートや RunInstances には項目がなく、ModifyInstanceAttribute(コンソールなら作成後の操作)でしか設定できません。しかもコンソールのチェックボックスは表記が逆で、「送信元/送信先チェック: 停止」にチェックを入れると無効化になります。
2. OS 側で IP 転送と NAT を設定する
Linux を NAT として動かすには、IP フォワーディング(受け取ったパケットを別のインターフェースへ転送する)と NAT(送信元 IP を自分の IP に書き換える マスカレード)を OS 側で有効にします。今回は nftables で設定しました。
ここで OS 特有のハマりどころがあります。今回の OS は Amazon Linux 2023 で、nftables が最初から起動済みです。そのためユーザーデータで systemctl enable --now nftables としても、すでに起動しているので設定ファイルが読み込まれません。enable(自動起動の有効化)と restart(設定の読み直し)を分けて書きます。
systemctl enable nftables
systemctl restart nftables # 直前に書いた設定を読み込ませる
NAT 経由で外に出られているかは、Crew EC2 から次のように確認できます。返る IP が NAT のパブリック IP なら成功です。
curl https://checkip.amazonaws.com
Kiro Crew のインストールとサービス化
CLI と Crew のインストール
EC2 に SSM で接続し、ec2-user に切り替えてからインストールします(SSM は既定で ssm-user としてログインするため)。
sudo su - ec2-user # -(ハイフン)で HOME/PATH を正しく設定する
curl -fsSL https://cli.kiro.dev/install | bash # Kiro CLI を先に入れる
curl -fsSL https://download.crew.kiro.dev/cli.sh | sh # Kiro Crew
EC2 には画面もブラウザもないので、ログイン画面を開けません。そこで、認証用の URL とコードを端末に表示し、それを手元の PC やスマホのブラウザで承認する方式(デバイスフロー)を使います。素の kiro-cli login は EC2 上でブラウザを開こうとして失敗するため、--use-device-flow を付けます。
kiro-cli login --use-device-flow
表示された URL とワンタイムコードを、手元の PC やスマホのブラウザで承認すればログイン完了です。
ログインに使う IAM Identity Center などのトークンには有効期限があり、切れると Crew が LLM を呼べなくなります。その場合は kiro-cli login --use-device-flow を再実行します。
setup と cgroup
kirocrew setup でワークスペースやタイムゾーンを設定します。ここで「AWS 上に Kiro Crew を起動しますか?」と聞かれますが、N(いいえ) にします(2台目の EC2 が立ってしまうため)。
kirocrew doctor が出す警告のうち最も重要なのが cgroup の設定です。
Kiro Crew は仕事を分担するために子プロセス(サブエージェント)を次々と立ち上げます。放っておくとメモリやプロセス数を際限なく使いかねないので、Crew はこれらに「メモリはここまで、プロセス数はここまで」という上限をかけて暴走を防ぎます。この上限は Linux の cgroup(プロセスごとに資源の上限を設ける仕組み)で効かせるのですが、cgroup を使うには、その利用者(ec2-user)専用の systemd の管理枠が立ち上がっている必要があります。
ところがこの管理枠は通常「ユーザーがログインしている間だけ」存在し、SSM 接続を抜けると消えてしまいます。常駐サービスとして動かす Crew では困るので、ログインしていなくても管理枠を立てたままにする設定(linger)を入れます。
sudo loginctl enable-linger ec2-user
ls -la /run/user/1000/bus # srw-rw-rw-. ec2-user が出れば OK
サービス登録
このあとは「使うときだけ EC2 を起動し、終わったら停止する」運用にします。すると起動のたびに Crew を立ち上げ直す必要が出てきますが、毎回 SSM で入って手で起動するのは面倒ですし、止め忘れ・立て忘れの元です。そこで Crew を systemd サービスとして常駐させ、OS が起動したら自動で立ち上がるようにします。
kirocrew service install # /etc/systemd/system/kirocrew.service を作成し起動
kirocrew service status
kirocrew logs -f
これで Crew は kirocrew gateway --no-open を ec2-user で動かし、127.0.0.1:5476 で待ち受けます。前節の linger と組み合わさることで、EC2 を起動しさえすれば、誰もログインしていなくても Crew が自動で立ち上がって受付状態に戻ります。こまめに停止・起動しても、起動の操作だけで使える状態になるわけです(この自動復帰の全体像は後述の /ops の節でまとめます)。
SSM ポートフォワード
ここまで各インスタンスには SSM で「シェルを操作する」接続をしてきましたが、SSM にはもう1つ、手元の PC のポートをインスタンスのポートに転送する使い方(ポートフォワード)があります。
ここでは CloudFront を作り込む前に、Crew がちゃんと起動して応答するかを一度確認しておきます。日常アクセスは後述の CloudFront 経由にしますが、動作確認の段階では SSM ポートフォワードが手軽です。PC 側でトンネルを張ります。
aws ssm start-session \
--region ap-northeast-1 \
--target <INSTANCE_ID> \
--document-name AWS-StartPortForwardingSession \
--parameters '{"portNumber":["5476"],"localPortNumber":["5476"]}'
これで、手元のブラウザから http://localhost:5476(PC のポート)に出した通信が、トンネルを通って EC2 の 5476(Crew が待ち受けるポート)に届きます。ダッシュボードを開くにはトークンが要るので、EC2 側で発行します。
kirocrew token --ttl 8h
こちらのコマンドで出力された URL(トークン付き) に手元のブラウザからアクセスすることで、Kiro Crew が立ち上がります。(もちろん、ここで会話を始めれば Kiro Crew をそのまま利用できます)
「ループバックからのアクセスはトークン不要」と見える説明がありますが、SSM トンネル経由ではループバックに見えてもトークンが必須でした。手元のブラウザは EC2 側のローカル条件を満たせないためです。kirocrew token で発行して使います。
--region を付け忘れると、PC の既定リージョンが東京でない場合に TargetNotConnected という分かりにくいエラーになります。
CloudFront VPC origins と Cognito 認証
SSM ポートフォワードは手元から動作確認する用途には手軽ですが、使うたびにトンネルを張り直すのは日常使いには重いです。そこで、通常アクセスは CloudFront + Cognito 認証で受けます。独自ドメインも ALB も使わず、ブラウザからそのまま認証付き HTTPS で届く形にします。
入口を EC2 に持たせない構成
この構成の肝は、プライベートな EC2 の手前に 自分で管理する公開リソース(ALB など)を置かないことです。インターネットに面する入口は AWS マネージドの CloudFront 1枚に集約し、その先の EC2 には外から触れる口を持たせません。自分で守るべき公開面を増やさないので、SG・証明書・ドメイン・WAF といった管理対象もその分減ります。
EC2 への外部からの着信が成立しないのは、次の3つが揃っているためです。
- EC2 に公開 IP の経路がない。 Public IP を付けず、プライベートサブネットに置いています。インターネットから EC2 の IP へパケットを飛ばす経路がそもそも存在しません。
-
EC2 のセキュリティグループのインバウンドが「CloudFront のサービス管理 SG だけ」。
0.0.0.0/0の 443 のような「外から直接叩く穴」を一切開けていません。許可しているのはCloudFront-VPCOrigins-Service-SG...という CloudFront 専用の送信元だけで、それ以外はすべて SG で落ちます。 - CloudFront から EC2 への経路も、インターネットを通らない VPC 内の専用線。 後述の VPC origins により、CloudFront はサービス管理 ENI を使って VPC 内で EC2 に直結します。EC2 を公開ネットワークに出す必要がありません。
インターネットに面しているのは AWS マネージドの CloudFront だけで、自分で管理する公開リソースはありません。EC2 は公開 IP も外向きの SG 穴も公開経路も持たない——これが「インバウンド・ゼロ」の中身です。
ALB との違い
素直に考えると「ALB + authenticate-cognito」ですが、これには2つの壁があります。1つは authenticate-cognito が HTTPS リスナー限定で、HTTPS には ACM 証明書、ACM には自分のドメインが要る点。独自ドメインを用意しない方針だと、ALB + Cognito は成立しません。もう1つは、ALB 自体が インターネットに面する公開リソースになる点です。EC2 をプライベートに置いても、今度は ALB という守るべき公開面が1枚増えます。
VPC origins 構成との違いを並べると、「公開リソースをゼロにできる」ことがはっきりします。
| ALB 構成(素直な案) | VPC origins 構成(今回) | |
|---|---|---|
| 公開 IP を持つ手前の部品 | ALB(インターネット向け) | なし(CloudFront のみが公開面) |
| EC2 のインバウンド | ALB の SG からの 443 | CloudFront サービス管理 SG のみ |
| EC2 ↔ 手前の経路 | VPC 内(ALB → EC2) | VPC 内(ENI 直結) |
| インターネットに面する自前リソース | ALB(守る対象が増える) | ゼロ(CloudFront は AWS マネージド) |
| 独自ドメイン | 実質必須(HTTPS / ACM) | 不要 |
ALB 構成でも EC2 自体はプライベートに置けますが、ALB という公開リソースが1枚増える分、守る面(WAF・SG・証明書・ドメイン)も増えます。VPC origins は公開面を CloudFront 1枚に寄せ、自前の公開リソースをゼロにできます。これが前節の「入口を EC2 に持たせない」を支えています。
VPC origins の設定
本題に入る前に VPC origins とは何かを整理します。CloudFront は「オリジン(配信元)」のコンテンツを世界中のエッジにキャッシュ・中継する CDN です。従来、このオリジンにプライベートサブネットの EC2 を直接指定することはできませんでした。間に ALB や NLB のような「インターネット側から CloudFront が到達できる受け口」を置く必要があったのです。
VPC origins(2024年11月に登場)は、この受け口を不要にする機能です。CloudFront が VPC の中に サービス管理 ENI(CloudFront 専用のネットワークインターフェース) を自動で作り、そこを通してプライベートサブネットの EC2 へ直接つなぎます。EC2 に Public IP を付けず、インターネットにも露出しないまま、CloudFront 経由でだけ到達できる形になります。
ざっくり言うと、「ALB を置かずに、CloudFront とプライベート EC2 を VPC 内の専用線で直結する」のが VPC origins です。今回のように ALB を避けたい(=固定費と独自ドメインを避けたい)構成と相性が良い機能です。
VPC origins を使うと、プライベートサブネットの EC2 を直接オリジンにできます。ALB を挟む必要がありません。要点は次のとおりです。
| 項目 | 内容 |
|---|---|
| 対応オリジン | ALB / NLB / プライベートサブネットの EC2 |
| 東京リージョン | 対応。ただし apne1-az3 を除く
|
| サブネット要件 | 空き IPv4 が1つ以上。CloudFront がサービス管理 ENI を作る(IP 追加費用なし) |
| NACL | インバウンドは評価されない。アウトバウンドのみエフェメラルポート許可が必要 |
| 使えないもの | origin request / response の Lambda@Edge トリガー(認証を viewer-request に置く理由。後述) |
2025年11月の新しい定額プランでは VPC origins が Business($200/月)以上です。無料プランでは VPC origins が使えず今回の構成が組めないため、Pay as you go(従量課金) を選びます。Pay as you go は時間課金がなく、使わない期間に無効化すれば実質ゼロです。
オリジン側 EC2 のセキュリティグループは、VPC origin 作成時に自動生成されるサービス管理 SG(CloudFront-VPCOrigins-Service-SG...)からのインバウンドだけを許可します。これで EC2 は、自分の CloudFront からの通信だけを受け付け、それ以外はすべて遮断されます。
キャッシュポリシーの落とし穴
CloudFront のディストリビューションを作成するとき、ウィザードが VPC origin 向けの既定キャッシュポリシーを自動で当ててくれます。ところがこの既定ポリシーは、クエリ文字列を転送しません。ログイントークンを ?token=<JWT> で渡す設計だと、トークンが届かず永久にログインできません。
Behaviors → Default (*) → Edit で、次のように差し替えます。
| 項目 | ウィザードの既定 | 変更後 |
|---|---|---|
| Cache policy | UseOriginCacheControlHeaders |
Managed-CachingDisabled |
| Origin request policy | なし | Managed-AllViewer |
Managed-AllViewer はビューワーの全ヘッダー・全 Cookie・全クエリ文字列を転送するので、Host ヘッダーもクエリ文字列もオリジンに届きます。
ディストリビューションの設定値はこの通りです。
| 項目 | 設定値 |
|---|---|
| 料金プラン | Pay as you go(定額プランは VPC origins が Business 以上のため不可) |
| ドメイン | CloudFront 既定ドメイン(独自ドメイン・ACM なし) |
| WAF | なし |
| オリジン | VPC origin(private の Crew EC2、HTTP 5476) |
ビヘイビア(/) |
Cache=Managed-CachingDisabled、OriginRequest=Managed-AllViewer、viewer-request=Lambda@Edge |
Lambda@Edge と Cognito
ここまでで CloudFront から EC2 まで通信が届くようになりました。続いて、その入口に認証を載せていきます。今回のキーになるのが Lambda@Edge です。
まず Lambda@Edge とは何かから整理します。通常の Lambda は特定のリージョンで動きますが、Lambda@Edge は CloudFront のエッジ(世界中の配信拠点)でリクエストに割り込んで実行される Lambda です。CloudFront を通る1リクエストの流れには4つのタイミングがあり、どこで割り込むかを選べます。
| トリガー | 実行タイミング |
|---|---|
| viewer-request | ブラウザ → CloudFront に届いた直後(オリジンに行く前) |
| origin-request | CloudFront → オリジンに取りに行く直前 |
| origin-response | オリジン → CloudFront に返ってきた直後 |
| viewer-response | CloudFront → ブラウザに返す直前 |
今回は「オリジン(EC2)に届く前に認証を挟みたい」ので、viewer-request を使います。ここで未認証を弾いてしまえば、認証されていないリクエストはそもそも EC2 まで到達しません。
VPC origins では origin-request / origin-response トリガーは使えません。認証を viewer-request に置くのは、この制約とも噛み合った選び方です。
認証は cognito-at-edge を viewer-request トリガーの Lambda@Edge に載せます。未認証のリクエストは Cognito のマネージドログインへ 302 でリダイレクトされ、認証済みだけがオリジンに届きます。ユーザー管理は Cognito User Pool で行い、個人利用ならユーザーは1人です。
Cognito 側の設定値はこの通りです。独自ドメインは使わず、マネージドログイン用の prefix ドメイン(xxxxx.auth.ap-northeast-1.amazoncognito.com、無料)を使います。
| 項目 | 設定値 |
|---|---|
| User Pool | サインインは Email、セルフサインアップ無効(管理者がユーザーを作成) |
| アプリクライアント |
シークレットあり(cognito-at-edge が confidential client として使う)、OAuth は Authorization code grant |
| コールバック URL | https://xxxxxxxx.cloudfront.net |
| ドメイン | Cognito prefix ドメイン(無料) |
| トークン有効期限 | ID / Access 60分、Refresh 30日 |
この認証フローを図にすると次のとおりです。初回は Cognito への往復(302)が挟まり、認証後は Cookie を持ったリクエストだけがオリジンの EC2 まで届きます。
302 で飛ばされた先が、この Cognito のマネージドログイン画面です。ここを通過したリクエストだけがオリジンの EC2 に届きます。
Lambda@Edge には通常の Lambda と違う制約が多く、ここが一番ハマりやすい箇所です。
- 環境変数が使えない。 User Pool ID などの設定はコードに焼き込みます(設定すると CloudFront への関連付けが失敗する)
- 実行ロールの信頼ポリシーに
lambda.amazonaws.comとedgelambda.amazonaws.comの両方が必要 - 関数は us-east-1 に作る。関連付けられるのは発行済みバージョン(
$LATESTは不可)
認証ロジックは awslabs の cognito-at-edge に任せるので、自分で書くのは「設定を渡して呼び出すだけ」のごく短いコードです。設定値(User Pool ID など、クライアントシークレットを含む)は別ファイルに切り出し、実値のファイルは git 管理から外します。ほぼこのまま使える形なので、サンプルとして全文を載せます。
// config.js(設定値だけを置く。実値のファイルは git 除外)
module.exports = {
region: 'ap-northeast-1',
userPoolId: 'ap-northeast-1_xxxxxxxxx',
userPoolAppId: 'xxxxxxxxxxxxxxxxxxxxxxxxxx',
userPoolAppSecret: 'xxxxxxxxxxxxxxxxxxxx', // アプリクライアントのシークレット
userPoolDomain: 'xxxxxxxx.auth.ap-northeast-1.amazoncognito.com',
};
// index.js(Lambda@Edge のハンドラ本体)
const { Authenticator } = require('cognito-at-edge');
const config = require('./config');
const authenticator = new Authenticator({
region: config.region,
userPoolId: config.userPoolId,
userPoolAppId: config.userPoolAppId,
userPoolAppSecret: config.userPoolAppSecret,
userPoolDomain: config.userPoolDomain,
cookieExpirationDays: 30, // アプリクライアントの Refresh トークン有効期限に合わせる
disableCookieDomain: true, // Cookie をこの CloudFront ホストだけに絞る(サブドメインに広げない)
httpOnly: true, // ダッシュボードは JS から Cookie を読まないので HttpOnly にできる
sameSite: 'Lax', // Cognito ログインからの戻り(トップレベル遷移)で Cookie を送れる
logLevel: 'silent', // 後述の理由で silent 固定
});
// viewer-request で全リクエストを認証。未認証は Cognito ログインへ 302
exports.handler = async (event) => authenticator.handle(event);
これを cognito-at-edge と一緒に zip に固めてアップロードします(npm install した node_modules ごと固めるか、esbuild などでバンドルします)。
cognito-at-edge の logLevel を error 以上に上げると、失敗時ログに Authorization: Basic <base64(clientId:secret)> が出ます。Base64 は可逆なので実質シークレットの平文出力です。logLevel: 'silent' にしておきます。
これで、独自ドメインも ALB もなしに、ブラウザから https://xxxxxxxx.cloudfront.net を開くと Cognito ログインが挟まり、認証後に Kiro Crew のダッシュボードに到達できます。PC でもスマホでも、追加のトンネルなしで届きます。
/ops 起動停止ボタン
個人利用なので、コストは「使うときだけ起動」で抑えます。起動・停止そのものは、AWS CLI なら2インスタンス(Crew と NAT)をまとめて1コマンドで叩けます。
# 起動
aws ec2 start-instances --region ap-northeast-1 --instance-ids <CREW_ID> <NAT_ID>
# 停止
aws ec2 stop-instances --region ap-northeast-1 --instance-ids <CREW_ID> <NAT_ID>
これを手元で打ってもいいのですが、PC を開いて CLI を叩くのは地味に面倒で、面倒だと止め忘れて課金が続きます。スマホからだと一手間でもあります。
そこで、既存の CloudFront と Cognito に /ops というページを相乗りさせました。ボタンを押すと Lambda の Function URL 経由で EC2(と NAT インスタンス)が起動・停止します。認証は既存の Cognito にそのまま乗るので、PC でもスマホでも安全にワンクリックで操作でき、ルートテーブルの操作も不要です。止め忘れのハードルを下げることが、そのまま省コストに効きます。
実際の /ops ページがこちらです。スマホからでも、このボタンひとつで EC2 と NAT を起動・停止できます。
「使うときだけ /ops で起動 → 用が済んだら停止」を習慣にすると、EC2 の時間課金は稼働時間分だけになります。後述のコスト試算で、常時稼働と個人利用で月額が大きく変わるのはこの運用が効くためです。
起動・停止だけで戻る仕組み
「ボタンで起動しただけで、また外にも中にもつながる」のは当たり前のようですが、これは NAT Gateway ではなく NAT インスタンスを選んだからこそ成立しています。
-
Crew は自動で待ち受けに戻る:
kirocrew service installで systemd に登録済みで、loginctl enable-lingerも効かせてあるので、インスタンスを起動すれば OS 起動時にサービスが自動で立ち上がり127.0.0.1:5476で待ち受けます。ログインしていなくても起動しただけで受付状態です -
アウトバウンドは NAT の IP に依存しない: プライベートサブネットのルートは NAT のパブリック IP ではなく ENI(
0.0.0.0/0 → eni-xxxx)を指しています。停止・起動をまたいで ENI ID・プライベート IP・送信元/宛先チェック無効化・ルートは保持され、変わるのはパブリック IP だけ。ルートは停止中blackhole、起動で自動的にactiveへ戻るので、IP が変わってもルートテーブルを触る必要がありません - インバウンドはそもそも NAT の IP を経由しない: 外からの到達は CloudFront の VPC origin(サービス管理 ENI 経由)と SSM で、いずれも NAT のパブリック IP とは無関係です
図にすると、起動の一発で「中(Crew の待ち受け)」も「外(アウトバウンド)」も自動で元に戻る様子が追えます。変わるのは NAT のパブリック IP だけで、ルートが指すのは ENI なので触る必要がありません。
これが NAT Gateway だと当たり前ではなくなります。NAT Gateway には「停止」がなく削除しかできないため、起こすたびに作り直してルートテーブルの向き先を差し替える手数と事故のリスクが増えます。
403 が3回出るのは正常
この /ops は一発で通ったわけではなく、動かしながら層を積んで調整しました。多層防御(Cognito 継承・Function URL の AWS_IAM 認証・OAC のリソースポリシー・実行ロールの2台限定)を1枚ずつ足していく過程で、/ops/ は3回 403 を返します。しかもそれぞれ原因が違います。
| 段階 | 403 の理由 |
|---|---|
| Function URL を直接開く | 署名なしリクエストのため(AWS_IAM 認証) |
| /ops* ビヘイビアを追加した直後 | Lambda のリソースポリシー未設定のため |
| (リソースポリシー投入後) | 開通 |
全層が揃って初めて開くので、途中の 403 は失敗ではなく正常な通過点です。ここを「壊れた」と誤認して設定を戻すと、かえって遠回りになります。動かしながら「どの 403 か」を切り分けるのが近道でした。
/ops を構成する要素の設定値はこの通りです。
| 要素 | 設定値 |
|---|---|
| ops Lambda | 東京、Python、EC2 と NAT の起動/停止と状態取得を処理 |
| Function URL | 認証タイプ AWS_IAM(署名なしリクエストは拒否) |
| OAC | CloudFront → Function URL を SigV4 署名。Lambda のリソースポリシーでこのディストリビューションからのみ許可 |
CloudFront ビヘイビア(/ops*) |
オリジン=Function URL、viewer-request=Lambda@Edge(Cognito を継承)、Cache 無効 |
| 実行ロール |
ec2:StartInstances / StopInstances を対象2インスタンスの ARN に限定、DescribeInstances のみ全体 |
これでパソコンからでもスマホからでも、好きなときに起動・停止できる構成が整いました。
インスタンスサイジングとコスト
今回の構成について、コストの試算をしておきます。
メモリ不足とインスタンス変更
まずはインスタンスのサイズを確定させます。
Kiro Crew の環境構築後、しばらく運用してみたところ、サブエージェントの起動が「メモリ不足」で拒否される事象が出ました。実際にはまった、この構成ならではの落とし穴です。
原因は Kiro Crew の spawn_min_memory_gb: 4.0 設定で、物理空きメモリが 4 GB を切るとサブエージェントの起動を拒否します。t3a.large(8 GiB)では Crew 自身でほぼ埋まり、空きが約 1.4 GiB しか残らず、サブエージェントの起動がこの閾値で止まりました。
スワップを足しても解決しません。 Crew が見ているのは物理空き RAM であって、スワップではないためです。RAM そのものを増やすしかありません。
対処として 16 GiB に増やします。x86_64 を維持したいので(CPython や MCP バイナリが x86_64)、r6a.large(2 vCPU / 16 GiB)を選びました。
インスタンスタイプの変更には停止が必要です。いきなり停止してもよいのですが、Kiro Crew は状態を SQLite に持つので、先に kirocrew サービスを止めて 書き込みを確定(WAL をチェックポイント)させてから停止すると安全です。
# EC2 側: サービスを止めてから OS をシャットダウン
sudo systemctl stop kirocrew
sudo shutdown -h now
停止したら、コンソールまたは CLI でインスタンスタイプを r6a.large に変更して起動します。
# 手元(パソコン)側: タイプ変更して起動
aws ec2 modify-instance-attribute --region ap-northeast-1 \
--instance-id <CREW_ID> --instance-type r6a.large
aws ec2 start-instances --region ap-northeast-1 --instance-ids <CREW_ID>
起動後は systemd により Crew が自動で立ち上がります。free -h で空きメモリを確認し、約 11 GiB 確保できていればサブエージェントも問題なく起動します。
コスト試算
確定したインスタンス(Crew = r6a.large、NAT = t4g.nano)をもとに月額を2パターンで概算しました。個人利用は、平日の夜と週末がっつり利用で稼働時間は月200Hrを想定しています。
| 項目 | 構成 | 常時稼働 | 個人利用 |
|---|---|---|---|
| EC2 Kiro Crew |
r6a.large(2 vCPU / 16 GiB) |
約 $100 | 約 $27 |
| EBS | gp3 68 GB | $6.5 | $6.5 |
| EC2 NAT インスタンス | t4g.nano |
約 $5 | 約 $1 |
| その他 | CloudFront / Cognito / Lambda 等 | ほぼ $0 | ほぼ $0 |
| 合計(概算) | 約 $110/月 | 約 $35/月 |
ポイントは2つあります。
ひとつは、EBS だけは稼働時間に関係なく課金されること。EC2 と NAT は「起動した時間 × 単価」ですが、EBS は停止中もボリュームが存在する限り課金されます。表の個人利用で EC2 が約 \$27、NAT が約 \$1 まで下がっても EBS の $6.5 は満額残るのはこのためです。つまり止めている間の待機コストは EBS の約 $6.5/月だけになります。
もうひとつは、時間課金のほとんどが EC2 Crew に集中すること。NAT は $1、その他(CloudFront / Cognito / Lambda)はリクエスト課金のみで1ユーザーなら月数セント程度です。結局この構成の月額は「r6a.large を何時間動かすか」でほぼ決まります。
もし「素直な構成」で ALB と NAT Gateway を使うと、ALB で月およそ \$18、NAT Gateway で月およそ \$45 が常時上乗せされます。いずれも時間課金分で、トラフィックがゼロでも発生。NAT Gateway はさらにデータ処理料 \$0.062/GB が通信量に応じて乗ります。
ハマりどころ一覧
最後に構築中に実際につまずいたハマりポイントをおさらいしておきます。どれも「エラーメッセージからは原因が読み取りにくい」やっかいなタイプのものです。
| 事象 | 何が起きるか | 回避策 |
|---|---|---|
| NAT の送信元/送信先チェック | 無効化しないと NAT 経由で外に出られない。コンソールは表記が逆(チェックを入れると無効化) |
ModifyInstanceAttribute で設定。起動テンプレート・RunInstances には項目がない |
| Amazon Linux 2023 の nftables |
enable --now だけでは設定が読み込まれず NAT 転送が効かない |
enable と restart を分けて、設定を読み直させる |
| SSM トンネルのトークン | 「ループバックはトークン不要」と誤解してダッシュボードにアクセスできない | リモートのブラウザはローカルシークレットを提示できないため、トンネル経由はトークン必須 |
--region 未指定 |
SSM 接続が TargetNotConnected という分かりにくいエラーで失敗 |
aws ssm start-session に --region ap-northeast-1 を明示 |
| VPC origin のキャッシュポリシー | 既定だとクエリ文字列が落ち、?token=<JWT> が届かず永久にログインできない |
Managed-CachingDisabled + Managed-AllViewer に差し替え |
| Lambda@Edge の環境変数 | 設定すると CloudFront への関連付けが失敗する | User Pool ID などの設定はコードに焼き込む |
| Lambda@Edge のリージョン・バージョン | 関連付けに失敗する | 関数は us-east-1 に作り、発行済みバージョンを関連付ける($LATEST 不可) |
/ops の 403 |
多層防御を積む途中で 403 が3回出る。原因がそれぞれ違う | 全層が揃うまでは正常な通過点。戻さずに「どの 403 か」を切り分ける |
| スワップでメモリ対策 | Crew は物理空き RAM を見るためスワップを足しても解決しない | 実メモリを増やす(16 GiB の r6a.large へ) |
まとめ
Kiro Crew をプライベートなクラウド環境に立ち上げ、実際に使えるところまで構築してみました。
インターネットに素の EC2 を公開しないまま、CloudFront と Cognito で認証付き HTTPS を用意し、固定費を抑えて Kiro Crew を常駐させられました。ALB も NAT Gateway も独自ドメインも使わず、入口は作らず、使うときだけ起動して止められる省コストな環境です。
割と応用が効く構成だと思うので、Kiro Crew のみならず「プライベート EC2 に何かを常駐させたいが公開はしたくない」場面があれば、CloudFront VPC origins + Lambda@Edge という選択肢を思い出してもらえれば幸いです。
なお、本記事の内容は JAWS-UG AI/ML #43 の LT でも発表しました。スライドは以下です。




