はじめに:PoCは「動いた日」が一番危ない
当社(合同会社ジョインクラス)では、Claude Code を中心に記事公開・SNS配信・営業パイプライン・朝のダイジェスト生成などを launchd のジョブ17本以上で回しています。自動化率はおよそ98%です。
ただ、最初から安定していたわけではありません。PoC の段階では「手元で1回動いた」だけで満足していて、本番に乗せた直後にこんな事故が続きました。
- cron が macOS のフルディスクアクセス制限で全ジョブ黙って失敗していた
- Zenn に1日に複数コンテンツを push してレートリミットに当たり、デプロイが失敗した
- git の
index.lockが残って、後続のジョブが全部コケる状態になった
どれも共通しているのは「失敗したことに誰も気づかない」ことでした。PoC と本番の違いは機能ではなく、失敗したときにどうなるかです。
この記事では、僕が実際に .company/scripts/ で運用しているコードをベースに、PoC を本番に乗せるときに足した3つの部品を紹介します。
- リトライ — 一時的な失敗を吸収する
- コスト上限 — 暴走したときに財布を守る
- 監視(自己修復つき) — 気づけない失敗を拾う
全部 bash + jq + curl だけで書けるので、今日そのまま手元に持ち込めます。
前提:共通関数を1ファイルに集める
まず、すべてのジョブが source する common.sh を用意しています。ログ・Slack通知・リトライ・コスト追跡をここに寄せておくと、ジョブ側は業務ロジックだけ書けば済みます。
# 各ジョブの先頭
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
source "$SCRIPT_DIR/common.sh"
Slack通知は Incoming Webhook を叩くだけです。Webhook URL 未設定時に黙って成功扱いにしないのがポイントです。
# Slack通知
notify_slack() {
local message="$1"
if [ -z "${SLACK_WEBHOOK_URL:-}" ]; then
log_error "SLACK_WEBHOOK_URL not set"
return 1
fi
curl -sf -X POST \
-H "Content-Type: application/json" \
-d "$(jq -n --arg text "$message" '{text: $text}')" \
"$SLACK_WEBHOOK_URL" >/dev/null
}
jq -n --arg でJSONを組み立てているのは、メッセージに " や改行が入っても壊れないようにするためです。文字列連結でJSONを作ると、AIが生成したテキストを流した瞬間に壊れます(実際に壊れました)。
1. リトライ:3回まで、それ以上は人間に渡す
実際に動いているコードはこれです。
# リトライ実行(最大3回)
retry() {
local max_attempts=3
local attempt=1
local cmd="$@"
while [ $attempt -le $max_attempts ]; do
if eval "$cmd"; then
return 0
fi
log_info "リトライ $attempt/$max_attempts: $cmd"
attempt=$((attempt + 1))
sleep 2
done
log_error "3回失敗: $cmd"
return 1
}
使い方:
retry "git -C \"$ZENN_DIR\" push origin main" || notify_slack ":x: Zenn push 3回失敗"
設計で決めたこと
- 回数は3回で固定。社内ルールとして「サブエージェントが失敗したら最大3回リトライ、それでもダメなら承認キューにエスカレーション」と決めています。無限リトライは障害を隠すだけです。
-
リトライしてはいけない処理を分ける。メール送信・SNS投稿のような冪等でない対外アクションは
retryで包みません。二重送信の方が、送れないより被害が大きいからです。
落とし穴(正直に書きます)
このコードは本番で動いていますが、改善余地があります。
-
local cmd="$@"+evalは、引数に空白やクォートを含むとき壊れやすい。新しく書くなら配列で受けて"$@"で直接実行する方が安全です。 -
sleep 2固定なので、APIのレート制限には弱い。外部APIを叩くなら指数バックオフにしてください。
# 改善版:配列で受ける + 指数バックオフ
retry_v2() {
local max_attempts=3 attempt=1 wait=2
until "$@"; do
if [ $attempt -ge $max_attempts ]; then
log_error "${max_attempts}回失敗: $*"
return 1
fi
log_info "リトライ $attempt/$max_attempts (${wait}s後): $*"
sleep "$wait"
attempt=$((attempt + 1)); wait=$((wait * 2))
done
}
retry_v2 git -C "$ZENN_DIR" push origin main
検証方法
わざと失敗するコマンドで、3回試行してから非ゼロで返るかを確認します。
source .company/scripts/common.sh
retry_v2 false; echo "exit=$?" # → リトライログが2回出て exit=1
retry_v2 true; echo "exit=$?" # → exit=0
2. コスト上限:月次トラッカーと2段階の閾値
PoC ではコストを気にしませんが、ジョブが17本、毎日回り始めると話が変わります。ループのバグ1つでAPI課金が青天井になり得ます。
当社ではAPI呼び出しのたびに track_cost を呼び、JSONファイルに月次で積算しています。
# コスト追跡
track_cost() {
local api_name="$1"
local cost_usd="${2:-0.01}"
local tracker="$COMPANY_DIR/scripts/cost-tracker.json"
if [ ! -f "$tracker" ]; then
echo '{"month":"'$(date '+%Y-%m')'","total_usd":0,"calls":{}}' > "$tracker"
fi
local current_month=$(date '+%Y-%m')
local stored_month=$(jq -r '.month' "$tracker")
# 月が変わったらリセット
if [ "$current_month" != "$stored_month" ]; then
echo '{"month":"'"$current_month"'","total_usd":0,"calls":{}}' > "$tracker"
fi
# コスト加算
local new_total=$(jq --arg cost "$cost_usd" '.total_usd += ($cost | tonumber)' "$tracker")
local new_calls=$(echo "$new_total" | jq --arg api "$api_name" '.calls[$api] = ((.calls[$api] // 0) + 1)')
echo "$new_calls" | jq . > "$tracker"
# 閾値チェック
local total=$(echo "$new_calls" | jq '.total_usd')
if (( $(echo "$total >= 200" | bc -l) )); then
notify_slack ":rotating_light: [AI-CEO] 月間コスト上限到達: \$${total}。自動実行を停止します。"
return 1
elif (( $(echo "$total >= 160" | bc -l) )); then
notify_slack ":warning: [AI-CEO] 月間コスト警告: \$${total} / \$200"
fi
return 0
}
ジョブ側では、実行前に check_cost_limit で止めます。
check_cost_limit || { log_error "コスト上限のためスキップ"; exit 0; }
# ... API呼び出し ...
track_cost "openai-image" 0.04
ポイント
- 80%で警告、100%で停止の2段階。いきなり止まると朝のダイジェストまで止まるので、警告の時点で人間が判断できる余地を残しています。
-
callsをAPI別に数える。上限に達したとき「どのジョブが食っているか」がjq '.calls' cost-tracker.json一発で分かります。 -
exit 0で抜ける。上限による停止は「異常」ではなく「仕様通りの停止」なので、監視側でエラー扱いにしないようにしています。 - 小数比較は bash 単体ではできないので
bc -lを使っています。
落とし穴
- 複数ジョブが同時に
track_costを呼ぶと、読み→書きの間に競合して加算が消えることがあります。同時実行が多いならflock(macOSならbrew install flockかshlock)で囲ってください。 - 自分で見積もった単価を渡す方式なので、請求額と一致はしません。月1回、実請求と突き合わせて単価を補正する運用が必要です。
検証方法
# 上限直前の状態を作って挙動を確認
echo '{"month":"'$(date +%Y-%m)'","total_usd":199.99,"calls":{}}' > .company/scripts/cost-tracker.json
source .company/scripts/common.sh
track_cost test 0.02; echo "exit=$?" # → Slackに上限通知、exit=1
3. 監視:自己修復できるものは直し、できないものだけ通知する
最後が auto-monitor.sh です。launchd で定期実行し、問題を add_fix(自動修復した)と add_issue(人間が見るべき)に分けて Slack に流します。
全スクリプトの構文チェック
AIにスクリプトを書き換えさせる運用だと、構文エラーが混入したまま次の実行時刻を迎える事故が起きます。bash -n で毎回全件チェックしています。
for script in "$SCRIPT_DIR"/*.sh; do
if [ -f "$script" ] && [ "$script" != "$0" ]; then
if ! bash -n "$script" 2>/dev/null; then
add_issue "スクリプト構文エラー: $(basename "$script") — 手動修正が必要"
log "SYNTAX ERROR: $script"
fi
fi
done
残留した git lock の自己修復
冒頭の「index.lock が残って後続が全滅」の対策です。10分以上古いロックだけ消します。新しいロックは実行中の可能性があるので通知のみ。
for repo in "$HOME/workspace/zenn-content-company" "$HOME/workspace/qiita-content" "$PROJECT_ROOT"; do
LOCK="$repo/.git/index.lock"
if [ -f "$LOCK" ]; then
# 10分以上古いロックファイルは削除
LOCK_AGE=$(( $(date +%s) - $(stat -f %m "$LOCK") ))
if [ "$LOCK_AGE" -gt 600 ]; then
rm -f "$LOCK"
add_fix "$(basename $repo)のgitロックファイルを削除(${LOCK_AGE}秒経過)"
else
add_issue "$(basename $repo)にgitロックファイルあり(${LOCK_AGE}秒前)"
fi
fi
done
stat -f %m は macOS(BSD) の書き方です。Linux では stat -c %Y に読み替えてください。
ディスクとWebサイトの死活
DISK_USAGE=$(df -h / | tail -1 | awk '{print $5}' | sed 's/%//')
if [ "$DISK_USAGE" -gt 90 ]; then
add_issue "ディスク使用率${DISK_USAGE}%(90%超過)"
fi
HTTP_CODE=$(curl -so /dev/null -w "%{http_code}" -m 30 "$site" 2>/dev/null || echo "000")
-m 30 のタイムアウトを必ず付けます。付けないと、相手が応答しないときに監視ジョブ自体が刺さり、監視が止まっていることに気づけないという本末転倒になります。
launchd に載せる(cron ではなく)
macOS で cron を使っていた頃、フルディスクアクセスの制限でジョブが全滅していました。エラーも出ません。launchd に全面移行して解決しています。
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key><string>com.joinclass.auto-monitor</string>
<key>ProgramArguments</key>
<array>
<string>/bin/bash</string>
<string>/Users/you/workspace/one-ceo/.company/scripts/auto-monitor.sh</string>
</array>
<key>StartInterval</key><integer>3600</integer>
<key>StandardOutPath</key><string>/tmp/auto-monitor.out.log</string>
<key>StandardErrorPath</key><string>/tmp/auto-monitor.err.log</string>
</dict>
</plist>
cp com.joinclass.auto-monitor.plist ~/Library/LaunchAgents/
launchctl load ~/Library/LaunchAgents/com.joinclass.auto-monitor.plist
launchctl list | grep joinclass # 2列目が直近の終了コード。0以外なら要確認
launchctl start com.joinclass.auto-monitor # 手動で即時実行して動作確認
StandardErrorPath は必ず指定してください。指定しないと、失敗時の出力がどこにも残りません。
本番化チェックリスト
PoC を本番に乗せる前に、僕が今は必ず確認している項目です。
- 失敗したとき誰に・どこに通知されるか決まっている
- リトライ対象は冪等な処理だけ。対外送信はリトライしない
- リトライ回数に上限があり、超えたら人間にエスカレーションされる
- 月次コストの警告閾値と停止閾値がある
- 外部呼び出しに全部タイムアウトがある
- ロック・一時ファイルの残留を自動で掃除する
- スケジューラのログ出力先が設定されている
- 上記それぞれを「わざと失敗させて」確認した
おわりに
PoC は「できるか」を確かめるもの、本番は「失敗しても困らないか」を確かめるものだと考えています。当社でも、機能そのものより、この記事で紹介したような地味な部品の方がよほど時間がかかりました。
ただ、一度作れば自動で回ります。仕組みへの初期投資は大きいですが、そのぶん人間は「判断」だけに集中できるようになりました。
launchd ジョブの設計、Hooks との組み合わせ、自動化全体を止めずに回し続ける運用は、書籍『Claude Code 全自動化バイブル』で詳しく解説しています。コスト管理やルール設計を深掘りしたい方は『CLAUDE.md設計パターン』もどうぞ。
書籍一覧はこちら: https://zenn.dev/joinclass?tab=books