CATHODE COASTaccess to tools
10 min read

コスト対策の自動シャットダウンが、作業中のGPUインスタンスを落とした話

24時間$28のGPUインスタンスに自動停止を仕込んだら、起動9分後に落ちました。原因は `|| echo 0` のフォールバック。安全装置をfail-openで設計する話。

shareB!

はじめに

EC2のGPUインスタンス(g6.xlarge)は東京リージョンで $1.1672/時です。24時間で約4,300円、30日連続なら13万円を超えます。前回まで使っていたVPSが月1,000円弱だったので、桁が2つ違います。

「ちょっと試して放置」が一度でも起きたら痛い。そこでアイドル状態が続いたら自動で停止する仕組みを入れました。

結果、起動から9分後にインスタンスが落ちました。 アイドルどころか、モデルの検証作業をしている最中です。

原因はシェルスクリプトの1行で、しかも修正版にも同じ構造の欠陥が残っていました。安全装置を作るのは思ったより難しい、という話です。

執筆時点(2026年7月)、Ubuntu 24.04 LTS での記録です。


先にまとめ

  1. || echo 0 が失敗を「正常な値」に変換した。 ファイルが存在しないときのフォールバック値が、判定式で最も破壊的な分岐を引いていました。
  2. 安全装置は fail-open で設計する。 判定できないときは「停止しない」に倒すべきでした。逆にしたせいで、必ず停止する仕組みになっていました。
  3. ドライランが安全装置の1段目しか通っていなかった。 検証したつもりで、肝心の判定ロジックは1行も実行していませんでした。

最終的に動いた版は記事の後半に置いています。


何を作ろうとしたか

やりたかったことは単純です。

  • Ollamaへのアクセスが60分間なければ、インスタンスを停止する
  • 停止であって終了ではないので、EBSは残り次回すぐ再開できる
  • cronで10分おきにチェックする

最初に書いたのがこれです。

#!/bin/bash
# Ollamaへの最終アクセスから60分経過したら自動停止
IDLE_LIMIT=3600
LAST=$(stat -c %Y /var/log/nginx/access.log 2>/dev/null || echo 0)
NOW=$(date +%s)
if [ $((NOW - LAST)) -gt $IDLE_LIMIT ]; then
  logger "idle-shutdown: shutting down after ${IDLE_LIMIT}s idle"
  /sbin/shutdown -h now
fi

Ollamaの前段にnginxを置く構成にする予定だったので、そのアクセスログの更新時刻を最終アクセス時刻として使う発想です。stat -c %Y でmtimeをUNIX時刻で取り、現在時刻との差を見る。

cronに登録します。

echo '*/10 * * * * root /usr/local/bin/idle-shutdown.sh' | sudo tee /etc/cron.d/idle-shutdown

一見、問題なさそうに見えました。


9分後に落ちた

モデルの検証をしていたら、SSHセッションが切れました。再接続できません。コンソールを見るとインスタンスが stopped になっています。

再起動してログを確認しました。

sudo journalctl -b -1 | grep -i "idle-shutdown\|shutdown" | tail -20
Jul 28 00:41:13 ip-172-31-x-x systemd[1]: Starting finalrd.service ...
Jul 28 00:50:01 ip-172-31-x-x CRON[1523]: (root) CMD (/usr/local/bin/idle-shutdown.sh)
Jul 28 00:50:01 ip-172-31-x-x root[1528]: idle-shutdown: shutting down after 3600s idle
Jul 28 00:50:01 ip-172-31-x-x systemd[1]: Stopping finalrd.service ...
Jul 28 00:50:02 ip-172-31-x-x systemd[1]: Reached target shutdown.target - Shutdown.

起動が00:41、cronの初回実行が00:50、その瞬間に停止。

idle-shutdown: shutting down after 3600s idle というログが、自分で書いたスクリプトから出ています。9分しか経っていないのに「3600秒アイドル」と判定されました。

原因: nginxをまだ入れていなかった

LAST=$(stat -c %Y /var/log/nginx/access.log 2>/dev/null || echo 0)

このスクリプトを仕込んだ時点で、nginxはまだインストールしていませんでした。 構築手順の順序として、Ollamaの動作確認を先に済ませ、nginxは後回しにしていたのです。

ファイルが存在しないので stat は失敗し、|| echo 0 により LAST=0 になります。そして。

NOW - LAST = 1785xxxxxxx - 0 = 約17億秒
17億 > 3600  →  条件成立  →  shutdown

アイドル時間に関係なく、cronの初回実行で必ず停止する構造でした。9分後に落ちたのは、たまたま10分間隔のcronが最初に回ったタイミングだったからです。

|| echo 0 の何が悪かったのか

command 2>/dev/null || echo 0 はシェルスクリプトで頻出のイディオムです。「失敗したら0にしておく」という、ごく普通の書き方に見えます。

しかし今回の文脈では、0という値がUNIX時刻の1970年1月1日を意味しました。

「取得できませんでした」  →  「最終アクセスは55年前です」

エラーを握りつぶした結果、エラーが「極端に古いタイムスタンプ」という有効な値に化けたわけです。そしてその値は、判定式において最も破壊的な分岐を選びます。

フォールバック値を書くとき、その値が後続の判定式でどう振る舞うかまで考えていませんでした。0は「不明」を意味しません。0は0です。


被害と、被害を免れたもの

対象結果
作業中のセッション❌ 中断
swap(インスタンスストア)❌ 消失。再作成が必要
パブリックIP❌ 変更
モデル17GB(EBSデータボリューム)無傷
ルートボリューム(EBS)✅ 無傷

モデルが残ったのは、データを別のEBSボリュームに置いていたからです。

$ df -h /mnt/models && ollama list
Filesystem      Size  Used Avail Use% Mounted on
/dev/nvme1n1     98G   17G   77G  18% /mnt/models
NAME           ID              SIZE     MODIFIED
qwen3.6:27b    a50eda8ed977    17 GB    2 hours ago

17GBの再ダウンロードを免れました。ここは構築時の判断が効きました。

一方、swapはインスタンスストア(物理NVMe)に置いていたので消えました。これは想定どおりの挙動なので、再作成するだけです。

sudo mkswap /dev/nvme2n1 && sudo swapon /dev/nvme2n1

もっと悪い結果になり得た

あとで気づいたのですが、これは運が良かっただけでした。

EC2には instanceInitiatedShutdownBehavior という属性があります。OS内部から shutdown したときの挙動を決めるもので、既定値は stop です。

もしこれが terminate に設定されていたら、shutdown -h now でインスタンスが終了していました。 ルートボリュームは DeleteOnTermination=true なので、OS・NVIDIAドライバ・Ollamaの設定がすべて消えます。

aws ec2 describe-instance-attribute \
  --instance-id i-0xxxxxxxxxxxxxxxx \
  --attribute instanceInitiatedShutdownBehavior \
  --region ap-northeast-1

自動シャットダウンを仕込むなら、この属性の確認をセットにすべきでした。OS内部からシャットダウンを叩く仕組みを作る以上、それが何を引き起こすかを先に確認する必要があります。


修正版その1: まだ同じ欠陥が残っていた

原因が分かったので、安全装置を足して書き直しました。

#!/bin/bash
set -u
IDLE_MIN=60
MIN_UPTIME=1800   # 起動30分以内は絶対に停止しない
 
# 安全装置1: 起動直後は何もしない
UP=$(cut -d. -f1 /proc/uptime)
[ "$UP" -lt "$MIN_UPTIME" ] && exit 0
 
# 安全装置2: SSHログイン中なら停止しない
who | grep -q . && exit 0
 
# 安全装置3: サービスが動いていなければ判断しない
systemctl is-active --quiet ollama || exit 0
 
# Ollamaへのリクエストが直近IDLE_MIN分にあったか
HITS=$(journalctl -u ollama --since "-${IDLE_MIN} min" -q 2>/dev/null | grep -c "/api/" || true)
[ -z "$HITS" ] && exit 0
[ "$HITS" -gt 0 ] && exit 0
 
logger "idle-shutdown: no API activity for ${IDLE_MIN}min, shutting down"
/sbin/shutdown -h now

判定材料をnginxのログからOllamaのjournalに変え、3つの安全装置を追加しました。これで大丈夫だと思いました。

が、同じ構造の欠陥が残っています。

HITS=$(journalctl ... | grep -c "/api/" || true)
[ "$HITS" -gt 0 ] && exit 0
 shutdown

grep -c はマッチが0件のとき 0 を返します。そしてjournalctlが何らかの理由で読めなかった場合も 0 になります。

「アクセスが0件だった」        →  HITS=0  →  停止(正しい)
「ログが読めなかった」          →  HITS=0  →  停止(誤り)

判定材料が存在しないことを、アイドルと解釈するという構造がそのまま残っていました。判定材料をnginxからjournalに変えただけで、同じ罠を再現していたわけです。

ドライランが役に立たなかった

さらに悪いことに、動作確認も不十分でした。

$ sudo bash -x /usr/local/bin/idle-shutdown.sh
+ set -u
+ IDLE_MIN=60
+ MIN_UPTIME=1800
++ cut -d. -f1 /proc/uptime
+ UP=167
+ '[' 167 -lt 1800 ']'
+ exit 0

起動167秒だったので、1つ目の安全装置で抜けています。 肝心の判定ロジックは1行も実行されていません。

「ドライランを流したので大丈夫」と思っていましたが、検証できていたのは「起動直後は停止しない」という1点だけでした。安全装置を足すと、皮肉なことに本体のロジックに到達しにくくなり、テストが困難になります。


修正版その2: 判定不能を明示的に扱う

問題の本質は「アクセス0件」と「判定不能」を区別できないことでした。カウントではなく最終アクセス時刻を見て、それが数値として取れたかを検証する形に変えました。

#!/bin/bash
set -u
IDLE_MIN=60
MIN_UPTIME=1800
 
# 安全装置1: 起動直後は停止しない
UP=$(cut -d. -f1 /proc/uptime)
[ "$UP" -lt "$MIN_UPTIME" ] && exit 0
 
# 安全装置2: SSHログイン中は停止しない
who | grep -q . && exit 0
 
# 安全装置3: サービスが動いていなければ判断しない
systemctl is-active --quiet ollama || exit 0
 
# 最終ログ時刻を取得(数値が取れなければ判定不能 → 停止しない)
LAST=$(journalctl -u ollama -n 1 -o short-unix -q 2>/dev/null | awk '{print int($1)}')
case "$LAST" in
  ''|*[!0-9]*) logger "idle-shutdown: journal unreadable, skipping"; exit 0 ;;
esac
 
NOW=$(date +%s)
IDLE=$(( NOW - LAST ))
[ "$IDLE" -lt $(( IDLE_MIN * 60 )) ] && exit 0
 
logger "idle-shutdown: idle for ${IDLE}s, shutting down"
/sbin/shutdown -h now

最初の失敗と同じ「最終アクセス時刻との差」方式に戻りましたが、判定不能時の扱いが逆になっています。

最初の版最終版
判定材料nginxのログファイルのmtimeOllamaのjournalの最終行
取得失敗時echo 0必ず停止数値でなければ 停止しない
起動直後発火する30分間は無効
SSH接続中落ちる落ちない

case で数値以外を弾いているのがポイントです。空文字列も、エラーメッセージも、*[!0-9]* に引っかかって早期リターンします。「0」というマジックナンバーを一切使わない形にしました。

判定材料があることを先に確認する

Ollamaがjournalにアクセスログを出すかどうかも、事前に確認しておくべきでした。

$ curl -s http://localhost:11434/api/tags > /dev/null
$ journalctl -u ollama --since "-5 min" -q | grep "/api/" | tail -5
Jul 28 01:04:18 ip-172-31-x-x ollama[651]: [GIN] 2026/07/28 - 01:04:18 | 200 |     229.445µs |       127.0.0.1 | GET      "/api/tags"

出ていました。Ollamaは内部でGINを使っているので、リクエストログがそのままjournalに流れます。これが確認できて初めて、判定ロジックが成立します。

最初の失敗は、この確認を一度もしないまま「nginxのログを見る」と決めたことに尽きます。


ハマりどころまとめ

症状原因対処
起動直後にインスタンスが停止監視対象ファイルが存在せず || echo 0 が発動フォールバック値が判定式でどう働くか確認する
修正しても同じ罠grep -c の0が「0件」と「読めない」を兼ねる判定不能を明示的に検出して早期リターン
ドライランで異常が出ない安全装置の1段目で抜けているbash -x の実行経路を最後まで追う
作業中に落ちるSSH接続を考慮していないwho | grep -q . && exit 0
停止のはずが終了したinstanceInitiatedShutdownBehaviorterminate事前に属性を確認する

やってみての所感

fail-close と fail-open を意識していなかった

安全装置の設計には2つの方向があります。

  • fail-close: 判定できないときは「危険側」に倒す(今回なら停止する)
  • fail-open: 判定できないときは「安全側」に倒す(今回なら停止しない)

ファイアウォールなら fail-close が正解です。判定できないなら通さない。しかしコスト保護の自動停止は fail-open が正しいでした。

理由は損害の非対称性です。

誤動作損害
誤って停止した作業が飛ぶ。復旧に時間がかかる
誤って動き続けた時間単価ぶんの課金(1時間で181円)

間違って動き続けるほうが、圧倒的に軽い。 1時間の無駄な課金より、検証作業が中断して環境を作り直すコストのほうが高いのです。

この判断を最初にしていれば、|| echo 0 は書かなかったはずです。

「安全装置を足す」だけでは足りない

修正版で3つの安全装置を追加しましたが、本体の判定ロジックに同じ欠陥が残っていました。ガードを増やすことと、判定を正しくすることは別の作業です。

しかも安全装置を足したことで、ドライランが本体に到達しなくなり、欠陥が見えにくくなりました。防御を厚くすると検証が難しくなるという、なかなか厄介なトレードオフがあります。

自動化する前に手動で運用する

そもそも、構築の初日に自動シャットダウンを入れる必要があったのか、という反省もあります。

作業中はターミナルの前にいるので、aws ec2 stop-instances を叩けばいい。自動化が要るのは「使ったあと忘れる」パターンで、それは運用が安定してからの話でした。

構築中に自動化を入れると、自動化が構築を妨害します。 順序を間違えました。


まとめ

GPUインスタンスのコスト対策として入れた自動シャットダウンが、起動9分後に作業中のインスタンスを落としました。原因は stat ... || echo 0 の1行で、存在しないファイルに対するフォールバック値が、判定式で必ず停止側に倒れる構造になっていました。

修正版でも同じ構造の欠陥を再現し、しかもドライランでは検出できませんでした。最終的に「判定不能を明示的に検出する」形にして落ち着いています。

一番の学びは、フォールバック値には意味があるということでした。|| echo 0 の0は「不明」ではなく「1970年1月1日」であり、その値が判定式で何を引き起こすかまで含めて設計する必要があります。エラーを握りつぶすと、エラーは消えるのではなく、有効な値に化けて別の場所で爆発します。

なお、この一件でモデルの17GBが無傷だったのは、データを別のEBSボリュームに置いていたからです。構築時に効かなかった判断が、事故のときに効きました。


参考リンク