Kiro Crew を Amazon Lightsail の 1 台に載せて、cron と Slack Bot を 動かしてみた。
構築そのものは半日で終わったが、そこから「安定して動き続ける」までに踏んだ落とし穴のほうが圧倒的に多かった。
検証環境は Amazon Linux 2023 / ap-northeast-1、kirocrew 0.1.3、kiro-cli 2.16.2、gh 2.96.0。
以下は全部この構成での実測値にもとづく。
その前に: 公式に kirocrew cloud がある
先に身も蓋もない話をしておく。Kiro Crew は AWS 上への常駐を公式にサポートしている。
kirocrew cloud doctor # 前提条件と AWS 到達性をチェック
kirocrew cloud iam-policy # 最小権限の IAM ポリシーを出力
kirocrew cloud launch # プロビジョニング + 設定 + ダッシュボードを開く
kirocrew cloud connect # SSM トンネルでダッシュボードに接続
kirocrew cloud stop|start # 課金の一時停止・再開
kirocrew cloud destroy # インスタンスと全 AWS リソースを削除
launch 一発で EC2 のプロビジョニングから設定、ダッシュボード接続まで面倒を見てくれる。
接続は SSM トンネル経由なので、静的 IP も 22/443 の開放も要らない。
それでも Lightsail を選ぶ理由は、月額固定でコストが読めることと、EC2/SSM を使いたくない場合くらいしかない。
手組みを選ぶというのは、この記事に書いてあるハマりどころを全部自分で踏むという意味だ。
そのうえで読み進めてほしい。
山場 1: メモリではなく CPU が先に詰まる
プラン選定の話は「メモリが何 GB 必要か」で語られがちで、自分も最初はそう考えていた。
実際に効いたのは vCPU の数だった。
Kiro Crew は埋め込みモデル(qwen3-embedding-0.6b.gguf)を llama-cpp-python の in-process ランタイムで常時ロードする。
公式ドキュメントに "Embeddings are always on ... there is no way to disable" と明記されていて、切ることができない。
なので「Kiro CLI プロセス単体 160MB 程度」で見積もると必ず足りなくなる。
2026-08-08 に $24(4GB)プランで測った実測値がこれ。
- ゲートウェイ常駐のみ、エージェント未実行の状態で kirocrew の RSS 合計が約 1.46GB
- システム全体では 2.4GB 使用 / 空き 1.0GB
つまり $12(2GB)プランはアイドルの時点でほぼ埋まる。ここまでは「メモリの話」で説明がつく。
問題はここから。Lightsail のプランは、$5 から $24 まで vCPU が 2 で横並びになっている。
プランを上げてもコアは増えない。
そして埋め込み推論(
embeddings.py の _infer_loop)は CPU バウンドで、
これに MCP サーバや Playwright の起動が重なると 2 コアは即座に飽和する。
実際に起きたのは、メモリ 44%・空き 2.1GB とまったく余裕があるのに、
load average が 50 まで跳ね、SSH が入れなくなるという状態だった。
ssh: connect to host ... Connection timed out during banner exchange
やっかいなのは、ポート 22 への TCP 接続自体は成功することだ。
疎通テストでは何の異常も見えない。時間を溶かしやすいポイントだと思う。
山場 2: loop-stall ——「勝手に再起動する」の正体
高負荷の状態で放っておくと、ゲートウェイが Main process exited, code=exited, status=1/FAILURE で落ち、
systemd が 10 秒後に復帰させる、というのを繰り返すようになった。
ログには理由が出ない。外から見ると「勝手に再起動している」としか見えない。
正体は Kiro Crew 自身のウォッチドッグだった。
dashboard/loop_watchdog.py が、イベントループが一定時間(既定 25 秒)応答しないと
faulthandler を撃って自分自身を終了させる。異常終了ではなく、設計された自衛動作だ。
Restart=on-failure の systemd が起こし直すので、再起動として観測される。
ダンプは ~/.kiro/crew/logs/crash-dumps/loopstall-*.txt に残る。
そして、この既定 25 秒が 2 vCPU には厳しすぎる。
エージェントを 1 回動かしただけで、コマンドを 1 つも実行できないまま落ちた。監査ログは空のままだった。
対処は閾値の引き上げ
dashboard.loop_stall_exit_after_secs を上げるのが本命の対処になる。
Kiro Crew 自身の設定説明文にも "Raise it on a slow machine" とあり、非力な環境で上げることは想定内の使い方だ。
cp ~/.kiro/crew/config.json ~/.kiro/crew/config.json.bak
python3 - <<'EOF'
import json, pathlib
p = pathlib.Path.home() / ".kiro/crew/config.json"
d = json.loads(p.read_text())
d["dashboard"]["loop_stall_exit_after_secs"] = 180
p.write_text(json.dumps(d, ensure_ascii=False, indent=2) + "\n")
EOF
sudo systemctl restart kirocrew
値は config/loader.py の LOOP_STALL_EXIT_AFTER_MIN / _MAX で 10〜300 にクランプされる。
起動時にしか読まれないので、書き換えたら再起動が要る。
説明文には「デスクトップアプリの liveness probe が約 20 秒で先に kill するので、
20 秒超はヘッドレス構成でしか効かない」と書かれているが、
Lightsail は systemd 常駐のヘッドレスなので probe は存在せず、この制約は当たらない。
25 秒から 90 秒に変えた時点での効果がこれ。変更点はこの 1 つだけで、
CPUQuota にも playwright-mcp にも手を付けていない。
| 閾値 25 秒 | 閾値 90 秒 | |
|---|---|---|
| 再起動 | 実行中に loop-stall で終了 | 通常運用では 0 |
| 実行できたコマンド | 0 件(監査ログ空) | 完走 |
| load average | 58 | 0.21 |
| 新規ダンプ | 実スタック付き(真の stall) | ヘッダのみ |
90 秒でも足りず、180 秒に上げた
安定したように見えたが、2026-08-10 07:47 JST に再発した。
load average 48.48、メモリ 75%、
NRestarts が 1 回。
ここからの切り分けが、この一連の作業でいちばん面白かった部分になる。
まずダンプの 1 行目を読む。
Timeout (0:01:30)!
90 秒を丸ごと使い切ってから撃たれている。つまり閾値がまだ足りていない、と 1 行で判断できる。
逆に、設定値より早い数字が出ていれば別の原因を疑うことになる。
ここを読むだけで方針が決まるので、ダンプは必ず先頭から見るのがいい。
次にスタックを読む。
| スレッド | 停止位置 |
|---|---|
| MainThread(イベントループ) |
asyncio/futures.py:406 _chain_future → _base.py:419 add_done_callback → threading.py:272 __enter__(ロック取得待ち)
|
| ワーカー |
apps/dev_mode.py:198 _stat_sentinel → threading.py:275 __exit__(同じロックの解放中)
|
| 別スレッド |
embeddings.py:1038 _infer_loop(埋め込み推論が CPU を占有)
|
MainThread がロック取得待ちで止まっているのを見ると、反射的にデッドロックを疑いたくなる。
だが、ロックを手放そうとしている側(
__exit__)と取ろうとしている側(__enter__)が両方写っているなら、
それは進めない状態ではない。待てば必ず進む状態で、進まないのは
埋め込み推論に CPU を奪われてスケジュールされていないだけだ。
つまり、ここでコードを追ってデッドロックを探すのは丸ごと無駄になる。対処は閾値の引き上げのほうだ。
ロック待ちが写っていてもデッドロックとは限らない、というのはクラッシュダンプの読み方として
単体で覚えておく価値があると思う。
そこで 90 秒から 180 秒(上限 300 の半分)に引き上げた。
1 分平均 48・5 分平均 31 という数字は、数十秒では収まらない持続的なスパイクを示している。
上げすぎないほうがいい理由もある。閾値を伸ばすと、本当にデッドロックしたときの復帰もその分遅れる。
180 秒は「数分続く CPU スパイクは見逃すが、真の wedge は 3 分以内に復帰する」という妥協点として選んだ。
さらに再発するなら 240 → 300 と段階的に上げることになるが、300 が天井なので、
そこまで行ったら閾値ではなく負荷側(常駐 MCP を減らす、cron を別インスタンスに逃がす)で削るしかない。
効かなかった対処
良さそうに見えて効かなかったものも書いておく。こちらのほうが役に立つかもしれない。
CPUQuota / CPUWeight では loop-stall は止まらない
systemd の drop-in で CPU を絞れば、暴走しても OS 側に余裕が残るはず——と考えて入れた。
CPUWeight=50 # 競合時の取り分。既定 100 → 50 で sshd 等に譲る
CPUQuota=180% # 上限。2 コアのうち 1.8 コアまで
結果として、loop-stall は止まらなかった。
ウォッチドッグが撃つ原因はプロセス内部でイベントループが埋め込み推論スレッドに負けることなので、
cgroup の上限では解決しない。むしろ絞りすぎると取り分が減って、停止しやすくなる。
CPUQuota=180% でも SSH は守れなかった
0.2 コアを OS に空けておけば sshd は生き残る、という想定も外れた。
この設定を入れた状態でも load average は 58 まで上がり、SSH は banner exchange でタイムアウトした。
その負荷の下では、残り 0.2 コアで sshd はハンドシェイクを終えられない。
CPUWeight=50 は競合時にしか効かず平常時の性能を落とさないので残す価値があるが、
CPUQuota は再起動が増えるようなら緩めるか外してよい、という位置づけに落ち着いた。
AllowedCPUs でのコア固定も勧めない
AllowedCPUs=0 にすればもう 1 コアが OS 専用になり、SSH は確実に確保できる。
ただし loop-stall はプロセス内部の競合が原因なので、使えるコアを半分にすると
イベントループと埋め込み推論の奪い合いが激しくなり、かえって停止しやすくなる。
「SSH では入れるようになるが、落ちるのは止まらない」という、いちばん残念な結果になりやすい。
ハマりどころ 9 連発
以下は上の山場とは独立した、構築中に踏んだもの。
1. Playwright MCP が Node.js 18 で起動しない
AL2023 の nodejs パッケージは 18 が入る。Kiro Crew の playwright-mcp は Node 20 以上を要求するので、
Playwright requires Node.js 20 or higher. で MCP サーバの起動に失敗する。
sudo dnf install -y --allowerasing nodejs22 nodejs22-npm git jq
sudo alternatives --set node /usr/bin/node-22
nodejs22 は nodejs と /usr/bin/node を奪い合うので --allowerasing が要る。
そして両方入った状態では優先度が同じ(100)で 18 が選ばれてしまうため、
alternatives --set での明示が必要になる。
厄介なのは、これが kirocrew doctor には出ないこと。MCP プローブで初めて分かる。
2. Vector Memory が ❌ になる(libllama.so の欠落)
Vector Memory: runtime ❌ vendored runtime failed to load は kirocrew 0.1.3 の Linux パッケージング不備。
同梱の
_vendor/llama_cpp_libs/linux_x86_64/ に libggml*.so.0 はあるのに libllama.so が入っていない。
macOS の
.dylib と Windows の .dll は同梱済みなので、Linux だけ落ちている形になる。
同一バージョンの公式プリビルド wheel から不足分だけ補って直した
(PyPI には sdist しか無いので
--extra-index-url でプリビルド index を指定する)。
ggml の SHA-256 が一致することを確認してから
libllama.so と libmtmd.so をコピーする。
拡張子は
.so で、.so.0 では読まれない。
この修正は venv の中にあるので、アップデートのたびに消える。再適用が要る。
3. sudo kirocrew service install で全部壊れる
インストーラは、呼び出したユーザーで動く unit を生成する設計になっている。
root 権限が要る部分(unit の配置と
systemctl)だけ内部で sudo に昇格する。
なので sudo を付けて実行すると User=root / HOME=/root の unit ができあがり、
認証情報も設定も埋め込みモデルも
/home/ec2-user/.kiro にあるので、次のように壊れる。
-
kiro-cli not found in PATHでエージェントのセッションが生成されない(unit の PATH が/root/.local/binになる) -
kirocrew tokenが HTTP 403 を返す(ゲートウェイと CLI が別の設定ディレクトリを見ている)
そもそも sudo kirocrew は secure_path に ~/.local/bin が無いので command not found になる。
これ自体が「sudo で実行する想定ではない」ことの裏付けになっている。正解は sudo を付けないこと。
4. Slack Socket Mode が invalid_auth
~/.kiro/crew/.env を読むのは shell ではなく Kiro Crew 自身のパーサで、引用符を外さない。
SLACK_APP_TOKEN="xapp-..." # ← ダブルクォート込みで Slack に送られる
この状態だと apps.connections.open が invalid_auth を返し、Socket Mode に繋がらない。
切り分けにくいのは、同じファイルを source した curl では通ってしまう点だ(shell は引用符を外すので)。
「トークンは有効なのにゲートウェイだけ落ちる」という症状になる。
疑わしいときは Kiro Crew 自身に読ませて、値の長さと先頭を見るのが早い。
さらに紛らわしいのが、自作スクリプト用に別で置く ~/.env は逆にクォートしてよいこと
(こちらは shell が
source するので、値に空白が入るならむしろ必要)。
同じ
.env という名前で作法が逆になるので、置き場所を分けたうえで覚えておく必要がある。
5. kirocrew chat がツール実行で固まる
サンドボックスの prewarm_backend() は、ゲートウェイの起動経路でしか呼ばれない
(
mcp_gateway/gatewayd.py と slack/gateway.py のみ)。
| 経路 | ツール実行 | 理由 |
|---|---|---|
| ゲートウェイ(サービス常駐・cron・Slack・ダッシュボード) | 動く | 起動時に prewarm 済み |
kirocrew chat / kirocrew doctor などの CLI
| 拒否される | prewarm されずコールドキャッシュに当たる |
CLI からツールを使わせると sandbox.wrap_argv → denied となり、応答が返らないまま固まる。
24/7 運用はすべてゲートウェイ経路なので実運用には支障しないが、
手元での疎通確認は
kirocrew chat ではなく kirocrew cron trigger で行う必要がある。
kirocrew doctor が MCP Tools: ❌ Sandbox backend unavailable を出すのも同じ理由で、
バックエンド自体(
unshare ベースの namespace 方式)は健全だ。bubblewrap を入れても改善しない。
6. kirocrew update が動かない
このサブコマンドの実体は KIROCREW_PROJECT_DIR の git ツリーに対する fetch + reset --hard + リビルドで、
git クローンから動かしている環境専用だった。
インストーラ(
cli.sh)で入れた環境では KIROCREW_PROJECT_DIR not set と出て終了コード 1 で止まる。
何も壊さないが、何も更新もしない。
正しい更新手順はインストーラの再実行。
データホーム
~/.kiro/crew は venv ~/.kiro/crew-venv と別ディレクトリに分離されているので、
設定・認証・履歴は消えない。触られるのは venv 側だけなので
service install のやり直しも不要。
ただし前述のとおり、venv 側にある
libllama.so の修正は毎回やり直しになる。
7. 異常終了に気付けない
Restart=on-failure が付いた unit は、クラッシュしても systemd が再起動するだけで failed 状態に入らない。
OnFailure= は failed 状態への遷移でしか発火しないので、
StartLimitBurst(5 分に 3 回)を使い切るまで一度も通知されない。
つまり watchdog による毎回の再起動を丸ごと取りこぼす。
代わりに ExecStopPost= を使う。メインプロセスの終了ごとに必ず走るので、
systemd が渡す
$SERVICE_RESULT を見て success(正常停止)なら黙って抜ける、という作りにした。
8. loop-stall の誤報
これは自分で踏んだ。Kiro Crew は起動のたびに、ヘッダだけのダンプ(155 バイト)を必ず作る。
なので「ダンプが存在すること」で判定すると、ただの再起動を毎回 loop-stall として通知してしまう。
実際にイベントループが固まったときだけ faulthandler が全スレッドのスタックを追記するので、
^Thread 0x を含むかどうかで見分ける。
もう 1 つ、ファイル名の時刻はクラッシュ時刻ではなくプロセス起動時刻だ。
起動時に開いたファイルへ後から追記されるので、
通知に載った
loopstall-20260809T103401Z.txt(Aug 9 19:34 JST 起動)が
Aug 10 07:47 のクラッシュを指す、ということが普通に起きる。
実際の発生時刻は mtime で見る。
9. New Relic がログを転送しない
インストーラが /etc/newrelic-infra/logging.d/discovered.yml を空ファイルで置いていく。
ログ設定ローダーがこれをパースできず、
failed to load log configs を数秒おきに吐き続けて、
ログ転送が一切動かない。リネームして無効化すれば直る。
sudo mv /etc/newrelic-infra/logging.d/discovered.yml \
/etc/newrelic-infra/logging.d/discovered.yml.disabled
sudo systemctl restart newrelic-infra
ついでに、既定の転送対象は cloud-init.log と newrelic-cli.log だけで肝心の Kiro Crew が入っていないので、
systemd ジャーナルを直接拾わせる設定を足す必要がある。
ダッシュボードは、2 vCPU 固定という制約上メモリより先に CPU が詰まるので、load average を最上段に置いた。
まとめ
Lightsail で Kiro Crew を動かすなら、loop_stall_exit_after_secs の引き上げは実質必須だと思っている。
変更点 1 つで効果が実測できた(load 58 → 0.21、実行コマンド 0 件 → 完走)唯一の対処がこれだった。
逆に言えば、それ以外はぜんぶおまけだ。
そして、ここまで読んで面倒だと思ったなら、素直に kirocrew cloud を使ったほうがいい。
Kiro Crew の実行ログを Backlog にフックで自動投稿する
こちらは別の話。Kiro Crew が処理を終えるたびに、その内容を Backlog の課題にコメントとして残している。
プロンプトで頼む方式はやめた
最初は「作業が終わったら Backlog にコメントしてね」とプロンプトに書いていた。守られないことがある。
エージェントの判断に依存する部分をログ収集に使うべきではないので、
kiro-cli の
stop フックで機械的に発火させる形に変えた。
postToolUse で投稿してはいけない
素直に考えると postToolUse で投稿したくなるが、これはツール呼び出しのたびに発火する。
コメントが量産されて、実行ログとして読めなくなる。
2 段構えにするのが要点だった。
| ファイル | イベント | 役割 |
|---|---|---|
mark-activity-post.sh
| postToolUse | 「このセッションでツールを使った」印を残すだけ |
backlog-log-stop.sh
| stop | 印があれば Backlog にコメントする |
投稿は stop(処理の完了時に 1 回)だけ。postToolUse は印を置くに留める。
こうすると、実作業を伴った処理だけが記録され、ツールを一度も使わなかった単なる会話は残らない。
なお ~/.kiro/hooks/ 配下の実行可能な *.sh は、ゲートウェイ起動時に自動で取り込まれる。
イベントはファイル名の接尾辞で決まる(
-stop.sh なら stop、-post.sh なら postToolUse)。
先頭 5 行以内の
# event: ヘッダーでも指定でき、そちらがファイル名より優先される。
フックの失敗でエージェントを止めない
フックは常に exit 0 で抜け、原因は別ログ(~/.kiro/crew/backlog-log-hook.log)に残す。
Backlog の障害やネットワーク断で本体が巻き添えになるのを防ぐため。
ほかに決めた約束は以下。
- 空の応答は投稿しない(中断で終わった処理はログとして意味がない)
- 印はセッションごとに stop フックが削除する(溜まり続けない)
- 長い応答は切り詰める(既定 3000 文字)
- 本文は引数ではなく標準入力で渡す(改行・引用符・記号を安全に扱うため)
Backlog 操作は MCP ではなく bee を使う
フック側は機械的に bee を叩けばいいが、エージェント自身が Backlog を操作するときの手段も決めておく必要がある。
放っておくと
backlog-mcp-server を追加しようとしたり、curl で API を直接叩いたりする。
Backlog MCP は Docker を必要とする。コンテナ 1 つ分の常駐コストが、
2 vCPU 固定の Lightsail では埋め込み推論と競合して、前半で書いた loop-stall を誘発する側に働く。
なので bee(Backlog の公式 CLI)に寄せた。
恒久ルールは steering ではなく learn に入れる
このルールをどこに書くか、で一度失敗している。
エージェント設定の resources は file://.kiro/steering/**/*.md という相対パスで、
セッションごとの作業ディレクトリ(
~/workplace/kirocrew-workspace/<session>/)を基準に解決される。
~/.kiro/steering/ に置いても読まれない。全体に効かせたいルールには向かない。
kirocrew learn add は --negative で「やらないこと」も書ける。
kirocrew learn add \
'Backlog を操作するときは必ず bee CLI を使う。認証は ~/.beerc に設定済み。' \
--category tool \
--negative 'backlog-mcp-server などの Backlog 用 MCP サーバを追加・起動しないこと。Docker が必要で、2 vCPU 固定の Lightsail では CPU を圧迫し、loop-stall によるゲートウェイの強制再起動を誘発する。'
learn の内容はデータホームの ~/.kiro/crew/memory.db に入るので、アップデートでも消えない。
エージェント設定の
prompt は venv 内のファイルを指しているため、こちらは更新で失われる。
消えてほしくないルールは learn、というのが今の使い分けになっている。