CloudFormation で Lightsail を立ち上げる — 月12ドル固定のバッチ実行基盤を作る

AWS
B!

手元の 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_0EC2 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::InstanceAWS::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 を動かす → 利用上限で止まらないようにする、の順に読むと流れがつながります。

  1. CloudFormation で Lightsail を立ち上げる(この記事)
    月12ドル固定のバッチ実行基盤をコードで作る
  2. AWS 上で Claude Code を cron から動かす
    サブスク認証のまま、API 課金なしで
  3. Linux サーバで Claude Code のアカウントを切り替える
    利用上限で止まらないバッチ基盤
B!
← 一覧へ戻る