手元の Mac で毎日20回以上動かしていた定期バッチを、クラウドへ逃がすことにしました。
動機は単純で、ノートPCを常時起動させ続けるのをやめたかったからです。
選んだのは EC2 ではなく Lightsail、構築は画面操作ではなく CloudFormation です。
この記事では、その判断理由と、実際に踏んだ落とし穴を書きます。
記事中の IP アドレス・ホスト名はすべてダミーです(RFC 5737 の文書用レンジ)。
この記事は3本シリーズの 1 本目です。
あわせて読む: AWS 上で Claude Code を cron から動かす/Linux サーバで Claude Code のアカウントを切り替える
なぜ EC2 ではなく Lightsail か
要件は「従量課金を避けたい」でした。EC2 は時間課金なので、止め忘れやスペック変更で
請求が動きます。Lightsail は月額固定で、転送量も込みです。
Lightsail small_3_0 | EC2 t4g.small | |
|---|---|---|
| 月額 | 12 USD(固定) | 約12 USD + EBS + 転送(変動) |
| 転送 | 3TB 込み | 別課金 |
| 固定IP | 無料(インスタンスに紐づく限り) | Elastic IP |
「クラウドらしさ」は EC2 のほうがありますが、1台を24時間動かし続けるだけなら
Lightsail のほうが要件に素直です。
CloudFormation は Lightsail を扱えるのか
扱えます。記憶で語らず、実機で確認しました。
aws cloudformation describe-type --type RESOURCE \
--type-name AWS::Lightsail::Instance --query TypeName --output text
# → AWS::Lightsail::Instance
AWS::Lightsail::Instance と AWS::Lightsail::StaticIp が使えます。
つまり月額固定のまま、構成をコードで持てます。
テンプレート
AWSTemplateFormatVersion: "2010-09-09"
Description: バッチ実行基盤。Lightsail インスタンス1台と固定IP。
Parameters:
InstanceName:
Type: String
Default: batch-runner
BundleId:
Type: String
Default: small_3_0 # 2GB / 2vCPU / 60GB / 3TB転送
KeyPairName:
Type: String
SshAllowedCidr:
Type: String
AllowedPattern: '^\d{1,3}(\.\d{1,3}){3}/\d{1,2}$'
Resources:
Instance:
Type: AWS::Lightsail::Instance
Properties:
InstanceName: !Ref InstanceName
BlueprintId: ubuntu_24_04
BundleId: !Ref BundleId
AvailabilityZone: ap-northeast-1a
KeyPairName: !Ref KeyPairName
Networking:
Ports:
# このホストは何も配信しないので 80/443 は開けない
- FromPort: 22
ToPort: 22
Protocol: tcp
AccessDirection: inbound
Cidrs:
- !Ref SshAllowedCidr
UserData: |
__BOOTSTRAP__
StaticIp:
Type: AWS::Lightsail::StaticIp
Properties:
StaticIpName: !Sub "${InstanceName}-ip"
AttachedTo: !Ref Instance
Outputs:
StaticIpAddress:
Value: !GetAtt StaticIp.IpAddress
__BOOTSTRAP__ は、デプロイ用スクリプトが初期構築シェルを流し込むための目印です。
テンプレートに初期構築の中身を直接書くと、手元のスクリプトと二重管理になって
必ず食い違うので、真実源は1本にします。
# lightsail.yaml の __BOOTSTRAP__ 行を bootstrap.sh の中身で置き換える
for line in template:
if line.strip() == '__BOOTSTRAP__':
indent = line[:len(line) - len(line.lstrip())]
for b in bootstrap_lines:
out.append(indent + b if b else '') # 空行にインデントを付けない
else:
out.append(line)
踏んだ落とし穴
ここからが本題です。4つとも、事前に調べても出てこなくて実際に踏みました。
1. Lightsail の名前は「リソース種別をまたいで」一意
SSH 鍵ペアを batch-runner という名前で作ったあと、同名のインスタンスを作ろうとして
こうなりました。
Resource handler returned message: "Some names are already in use: batch-runner
(Service: Lightsail, Status Code: 400)"
鍵ペアとインスタンスは別のリソースなので名前が衝突するとは思っていませんでした。
鍵ペアは batch-runner-key、固定IPは batch-runner-ip のように分けます。
2. 作成に失敗したスタックは ROLLBACK_COMPLETE で居座る
上のエラーでスタックは ROLLBACK_COMPLETE になり、この状態は更新を受け付けません。
中身は空(全部ロールバック済み)なので、消してから作り直すのが正解です。
デプロイスクリプトに自動化を入れました。
STATUS="$(aws cloudformation describe-stacks --stack-name "$STACK" \
--query 'Stacks[0].StackStatus' --output text 2>/dev/null || true)"
if [ "$STATUS" = "ROLLBACK_COMPLETE" ] || [ "$STATUS" = "REVIEW_IN_PROGRESS" ]; then
aws cloudformation delete-stack --stack-name "$STACK"
aws cloudformation wait stack-delete-complete --stack-name "$STACK"
fi
3. ★Lightsail は UserData の「前に」自前のスクリプトを連結する
これが一番厄介でした。インスタンスは起動したのに初期構築が何も実行されていない。
しかも自前のログファイルすら作られていません。
原因はこれです。
$ sudo head -3 /var/lib/cloud/instance/scripts/part-001
#!/bin/sh
echo Lightsail: Starting Instance Initialization.
cat > /etc/ssh/lightsail_instance_ca.pub << EOF
Lightsail は自前の初期化スクリプト(#!/bin/sh で始まる)を UserData の前に連結します。
その結果、こちらが書いた #!/bin/bash はファイルの途中に来てただのコメントになり、
全体が /bin/sh(Ubuntu では dash)で実行されます。
私のスクリプトはログ取りにプロセス置換を使っていました。
exec > >(tee -a "$LOG") 2>&1 # dash には無い構文
dash はこれを解釈できず、Syntax error: redirection unexpected で即死します。
cloud-init の記録を見ると 0.023秒で終わっていました。
cc_scripts_user.py[WARNING]: Failed to run module scripts_user
RuntimeError: Runparts: 1 failures (part-001) in 1 attempted commands
対策は「UserData 側は POSIX sh で書き、中身をファイルに落としてから bash を名指しする」です。
# ここから自前。Lightsail の初期化スクリプトに連結されるので POSIX sh で書く。
mkdir -p /opt/app
cat > /opt/app/bootstrap.sh <<'BOOTSTRAP_EOF'
#!/bin/bash
... 本体 ...
BOOTSTRAP_EOF
chmod +x /opt/app/bootstrap.sh
/bin/bash /opt/app/bootstrap.sh # shebang に頼らない
副産物として、あとから手で流し直せるファイルが残るのも都合が良いです。
調べ方も書いておきます。「UserData が動いていない」と思ったら、まずこの2つを見ます。
sudo cat /var/lib/cloud/instance/scripts/part-001 # 実際に流れた中身
sudo grep -i fail /var/log/cloud-init.log # 失敗の記録
4. UserData は初回起動の一度きり
bootstrap.sh を直してスタックを更新しても、再実行されません(インスタンスも
作り直されません)。cloud-init の scripts_user は once-per-instance だからです。
なので初期構築スクリプトは何度流しても壊れないように書き、流し直しは手で行います。
ssh batch-runner 'sudo bash /opt/app/bootstrap.sh'
SSH の穴は22番だけ、送信元も絞る
このホストは何も配信しません(Webは別サーバ)。なので 80/443 は開けず、22番だけを、
それも自分の固定IPからに限定しました。
自宅回線のIPは変わりうるので締め出される可能性がありますが、AWS の認証情報さえあれば
1行で開け直せます。この復旧経路があるなら、開けっぱなしにする理由はありません。
aws lightsail put-instance-public-ports --region ap-northeast-1 \
--instance-name batch-runner \
--port-infos fromPort=22,toPort=22,protocol=TCP,cidrs=203.0.113.77/32
おまけ:鍵ペアは「公開鍵だけ」を渡す
aws lightsail create-key-pair は秘密鍵を API 応答で返します。
手元で作って公開鍵だけ import するほうが素直です。
ssh-keygen -t rsa -b 4096 -N '' -f ~/.ssh/lightsail_batch-runner-key
aws lightsail import-key-pair --key-pair-name batch-runner-key \
--public-key-base64 "$(cat ~/.ssh/lightsail_batch-runner-key.pub)"
--public-key-base64 という名前ですが、OpenSSH 形式の公開鍵をそのまま渡します。
もう一つの落とし穴:ssh-agent に鍵を貯めすぎている
構築直後、繋がりませんでした。
Received disconnect from 203.0.113.10 port 22:2: Too many authentication failures
IdentityFile を書いていても、ssh-agent に載っている鍵が先に全部試されます。
手元の agent には7個載っていて、サーバ側の試行回数上限(既定6回)に達していました。
Host batch-runner
HostName 203.0.113.10
User ubuntu
IdentityFile ~/.ssh/lightsail_batch-runner-key
IdentitiesOnly yes # ★これが要る
結果
StackStatus : CREATE_COMPLETE
StaticIpAddress : 203.0.113.10
月額 : 12 USD(固定)
構成はすべてコードにあるので、作り直しは deploy_stack.sh の一発、
内容確認だけなら --plan で変更セットを表示して捨てられます。
画面をぽちぽちして作ると、半年後に「なぜこの設定なのか」が誰にも分からなくなります。
1台しかないからこそ、コードで持つ価値があります。
まとめ
- CloudFormation は Lightsail を扱える(
AWS::Lightsail::Instance/StaticIp) - Lightsail の名前はリソース種別をまたいで一意。鍵ペアと衝突させない
- UserData には Lightsail 自身のスクリプトが前置され、dash で走る。
bash 前提の構文は書けない。ファイルに落として/bin/bashで名指しする - UserData は初回のみ。初期構築は冪等に書き、流し直しは手で
BundleIdは作成時のみ指定可。プラン変更は作り直しになるssh-agentに鍵が多いとIdentitiesOnly yesが必須
シリーズ記事
Mac の定期バッチを AWS へ移した一連の記録です。インフラを作る → その上で Claude Code を動かす → 利用上限で止まらないようにする、の順に読むと流れがつながります。
- CloudFormation で Lightsail を立ち上げる(この記事)
月12ドル固定のバッチ実行基盤をコードで作る - AWS 上で Claude Code を cron から動かす
サブスク認証のまま、API 課金なしで - Linux サーバで Claude Code のアカウントを切り替える
利用上限で止まらないバッチ基盤
Analyzegear
