手元の Mac で毎日21回動かしていた Claude Code のバッチを、AWS のサーバへ移しました。
「Claude をサーバで動かす=API 従量課金」だと思われがちですが、サブスクリプションの
認証のまま動かせます。
この記事は、その具体的なやり方と、移して初めて分かった落とし穴の記録です。
記事中の IP アドレス・ホスト名はすべてダミーです(RFC 5737 の文書用レンジ)。
この記事は3本シリーズの 2 本目です。
あわせて読む: CloudFormation で Lightsail を立ち上げる/Linux サーバで Claude Code のアカウントを切り替える
前提:何を動かしているか
Claude Code を -p(headless)で起動し、手順書を渡して自律実行させるバッチです。
claude -p "$(cat prompt.txt)" \
--model claude-opus-5 \
--permission-mode auto \
--max-budget-usd 45
1日21スロット、1回あたり数分〜数十分。これをノートPCで回し続けるのが嫌になった、
というのが移行の動機です。
認証:claude setup-token
サーバで動かすための鍵はこれ1本です。
ssh -t batch-runner 'claude setup-token'
ブラウザで承認すると、1年有効の長命トークンが発行されます。
✓ Long-lived authentication token created successfully!
Your OAuth token (valid for 1 year):
sk-ant-oat01-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
Store this token securely. You won't be able to see it again.
Use this token by setting: export CLAUDE_CODE_OAUTH_TOKEN=<token>
これはサブスクリプションの認証であって API キーではありません。
API の従量課金は発生せず、サーバ代(この構成では月12ドル)だけで済みます。
★トークンは「表示されるだけ」で保存されない
一番の罠がここです。setup-token は値を表示するだけで、どこにも保存しません。~/.claude/.credentials.json も作られません。自分で置き場所を決める必要があります。
cron から認証をどう読ませるか
CLAUDE_CODE_OAUTH_TOKEN を export すればいいのですが、cron は .bashrc も.profile も読みません。cron は $SHELL -c コマンド で起動するだけだからです。
素直に効く手が1つあります。BASH_ENV です。
bash は非対話・非ログインで起動したとき、$BASH_ENV が指すファイルを読み込みます。
# ~/.claude/secrets.sh (パーミッションは 600)
export CLAUDE_CODE_OAUTH_TOKEN="$(cat ~/.claude/accounts/main.token)"
SHELL=/bin/bash
HOME=/home/ubuntu
PATH=/home/ubuntu/.local/bin:/usr/local/bin:/usr/bin:/bin
BASH_ENV=/home/ubuntu/.claude/secrets.sh
45 3,16 * * * /home/ubuntu/batch/job-a/run.sh
05 6,18 * * * /home/ubuntu/batch/job-b/run.sh
crontab に書いた環境変数はジョブに引き継がれるので、BASH_ENV もそのまま効きます。
効くかどうかは実物で確かめる
「たぶん効く」で夜間バッチに任せると、朝には全滅しています。
2分後に走る使い捨てのジョブを仕込んで確かめました。
cat > /tmp/crontest.sh <<'EOS'
#!/bin/bash
{
echo "BASH_ENV=${BASH_ENV:-未設定}"
echo "token_len=${#CLAUDE_CODE_OAUTH_TOKEN}" # ★値そのものは書かない
} > /tmp/crontest.txt
EOS
chmod +x /tmp/crontest.sh
{ crontab -l; echo "$NEXT * * * * /tmp/crontest.sh"; } | crontab -
BASH_ENV=/home/ubuntu/.claude/secrets.sh
token_len=108
これで「cron → bash → 環境変数 → claude」の鎖がつながったと確認できました。
★ワークスペースを信頼済みにしないと、許可設定が全部捨てられる
移行後の最初のバッチのログに、こんな行がありました。
Ignoring 285 permissions.allow entries from .claude/settings.json:
this workspace has not been trusted. Run Claude Code interactively here once and
accept the trust dialog, or set projects["/path/to/project"].hasTrustDialogAccepted: true
許可設定285件が丸ごと無視されていました。
対話なら trust ダイアログに答えれば済みますが、cron に人はいません。
たまたまその回は作業ゼロのバッチだったので通りましたが、実作業があれば
権限待ちで止まっていました。~/.claude.json に書いて解決します。
import json, pathlib
p = pathlib.Path.home() / ".claude.json"
d = json.loads(p.read_text()) if p.exists() else {}
pj = d.setdefault("projects", {})
for path in ["/home/ubuntu/works/myproject"]:
e = pj.setdefault(path, {})
e["hasTrustDialogAccepted"] = True
e.setdefault("hasCompletedProjectOnboarding", True)
p.write_text(json.dumps(d, ensure_ascii=False, indent=2))
これは「動くかどうか」ではなく「静かに劣化するかどうか」の問題なので、
起動確認の項目に入れておくべきです。私はプリフライトの検査に加えました。
OUT="$(cd "$PROJECT" && claude -p 'ok と2文字だけ返して' --max-turns 1 2>&1)"
printf '%s' "$OUT" | grep -q 'has not been trusted' \
&& echo "[NG] 未信頼で許可設定が捨てられている" \
|| echo "[OK] 許可設定が効いている"
実際どれくらいのマシンが要るか
移行前に一番読めなかったのがここでした。結論から言うと、驚くほど小さくて足ります。
1分ごとにメモリを記録して実測しました。
USED=$(free -m | awk 'NR==2{print $3}')
FREE=$(free -m | awk 'NR==2{print $7}')
SWAP=$(free -m | awk 'NR==3{print $3}')
read -r N MAX <<EOF
$(ps -eo rss,args --no-headers | grep 'bin/claude' | grep -v grep \
| awk '{n++; if ($1+0 > m) m = $1+0} END {printf "%d %d\n", n+0, int(m/1024)}')
EOF
余談:最初
pgrep -fcでプロセス数を数えていたのですが、0件のとき「0」を出力した上で
終了コード1を返すため、|| echo 0を付けていると 0 が二重に出て記録が壊れました。psから数えるほうが素直です。
結果(2GB メモリのインスタンス):
| 時刻 | 使用MB | 空きMB | swap | claude | 最大RSS |
|---|---|---|---|---|---|
| 18:40 | 395 | 1512 | 0 | 1 | 48 |
| 18:41 | 574 | 1333 | 0 | 1 | 267 |
| 18:43 | 570 | 1337 | 0 | 1 | 266 |
claude 1プロセスあたり約330MB、swap の使用はゼロ。 2GB で十分でした。
念のため 2GB のスワップファイルは張ってありますが、使われていません。
★サブエージェントの同時実行数はバージョンで変わる
移行後、あるバッチの所要時間が 約500秒 → 984秒 に伸びました。
作業量は同じ(処理件数も同一)です。
ログに理由が書いてありました。
サブエージェントの同時実行上限が20体で、21体目以降はキューされず即エラーになりました。
45体を一度に投入できないため、20体 → 15体 → 9体 + 1 のバッチ投入で回しています。
45チャンクに分割する処理が、20体ずつ3波に分かれていたのです。
手元の Mac は少し古いバージョンで、45体を一度に起動できていました。
つまりこれは「AWS が遅い」のではなく、Claude Code のバージョン差です。
CPU もメモリも余っていました。環境変数で上げられます。
CLAUDE_CODE_MAX_CONCURRENT_SUBAGENTS=45
移行で性能が落ちたように見えたら、まずリソースを疑う前に処理の並列度を確認する
のが正解でした。メモリと CPU の実測があったので誤診を避けられました。
Lambda ではダメなのか
聞かれそうなので書いておきます。このワークロードでは無理でした。
Lambda の実行時間上限は **900秒(15分)**で、引き上げられません。
実績を集計するとこうなりました。
| バッチ | 平均 | 最長 | 判定 |
|---|---|---|---|
| A | 3,403秒 | 14,644秒 | ✗ |
| B | 1,036秒 | 2,014秒 | ✗ |
| C | 896秒 | 1,629秒 | ✗ |
| D | 165秒 | 627秒 | ○ |
12本中7本が超過します。しかも「たまに超える」ではなく、A は26回中25回が超過でした。
さらに、接続先が送信元IPを許可制にしている場合、Lambda には固定IPがないので
VPC + NAT Gateway が要ります。東京リージョンで 0.062 USD/時 = 月約45 USD。
バッチの実行費を1円も払わない段階で、サーバ1台(12 USD)の3.7倍です。
15分制限のない ECS Fargate でも、固定IPの問題は同じです。
実行時間が長く・頻度が高く・送信元IPの固定が要るワークロードは、
素直に小さな箱を1台常時起動させるほうが安く単純でした。
プリフライトを作る
夜中に静かに全滅するより、今ここで気づきたい。11項目の検査を作りました。
=== 1. タイムゾーン ===
[OK] Asia/Tokyo(現在 2026-09-14 11:02)
=== 4. claude の認証 ===
[OK] secrets.sh を読んだ
[OK] claude が応答した
[OK] 許可設定(settings.json の allow)が効いている
=== 7. 接続先への ssh ===
[OK] 繋がった / PHP 8.3.x
=== 9. 形態素解析用 venv ===
[OK] sudachipy あり
...
=== 全項目 OK。cron を入れてよい ===
タイムゾーンは特に忘れがちです。cron は OS のタイムゾーンで動くので、
UTC のままだと全バッチが9時間ずれます。
sudo timedatectl set-timezone Asia/Tokyo
まとめ
claude setup-tokenでサブスク認証のままサーバで動く。API 課金は発生しない- トークンは表示されるだけで保存されない。置き場所は自分で決める
- cron から読ませるなら
BASH_ENV。.bashrcも.profileも読まれない hasTrustDialogAcceptedを立てないと許可設定が全部捨てられる。
エラーにならず静かに劣化するので、起動確認の項目に入れる- メモリは 1プロセス約330MB。2GB で足りた
- 遅くなったらまず並列度を疑う。
CLAUDE_CODE_MAX_CONCURRENT_SUBAGENTS - Lambda は15分制限で載らない。固定IPが要るなら NAT の費用がサーバ代を超える
シリーズ記事
Mac の定期バッチを AWS へ移した一連の記録です。インフラを作る → その上で Claude Code を動かす → 利用上限で止まらないようにする、の順に読むと流れがつながります。
- CloudFormation で Lightsail を立ち上げる
月12ドル固定のバッチ実行基盤をコードで作る - AWS 上で Claude Code を cron から動かす(この記事)
サブスク認証のまま、API 課金なしで - Linux サーバで Claude Code のアカウントを切り替える
利用上限で止まらないバッチ基盤
Analyzegear
