手元の Mac や小さなサーバーで、毎日のデータ取り込みや AI の処理を定期実行している方向けに、定期実行のネットワーク待ちを「どこに置き・何を宛先にし・上限をどう数えるか」を、実際につまずいた記録から説明します。読み終わると、自分のシェルスクリプトに、スリープ明けでも失敗しにくい待ちの処理を入れられるようになります。
題材は、筆者がこのサイトの運用で試した記録です。AI が毎日記事を書く仕組みを Mac の launchd(ローンチディー:macOS に入っている、決まった時刻にプログラムを動かす仕組み)で動かしています。
この記事で分かること
- スリープ明けの定期実行が、ネットワークの復帰前に動く理由
- 到達待ちを、最初に通信する処理より前に置く理由
- 2 つの宛先で 1 つの上限を分け合う数え方と、最悪の待ち時間の計算
- 秒数の 08 や 030 を bash が 8 進数と読む落とし穴と、やり直しの回の二重の待ち
起きたこと:ネットワークが戻る前に起動し、その日は 1 本も作れなかった
2026-11-08、毎日の実行で AI を呼び出す命令が、API(エーピーアイ:プログラムどうしがやり取りする窓口)のサーバーに届かずに失敗しました。エラーは ENOTFOUND(宛先の名前から住所を引けなかったという意味)で、待つことなく即座に終わりました。その日は記事が 1 本もできませんでした。
対策として、ネットワークに届くまで待つ処理を入れ、起動を 06:30 と 13:30 の 1 日 2 回にしました(1 回目が失敗した日は 13:30 の回がやり直します)。ところが待ちの置き場所が悪く、しばらくは 06:30 の回が待ちまで進めずに失敗し、13:30 のやり直しに頼っていました。この記事は、その置き場所と、待ち時間の数え方を直した記録です。
AI の毎日の実行全体の作りは、launchd と claude -p で毎日の自動実行を組んだ構築ログと、同時実行数と待ち上限を決め直した構築ログにまとめています。この記事は「依存先がまだ使えないうちに動いてしまう問題」に絞ります。
なぜ起きるのか:スリープ明けに、予定の処理がすぐ動く
Apple の公式文書によると、launchd の StartCalendarInterval(決まった時刻に動かす設定)で予定した処理は、予定時刻に Mac がスリープしていた場合、起きた時に実行されます(Apple: Scheduling Timed Jobs)。
つまり、処理が動き始める時刻は「Mac が起きた直後」になりえます。先の ENOTFOUND も、起きた直後でネットワークがまだ使えなかったために起きたと考えられます。
同じ文書には、cron(クーロン:Unix 系で昔から使われる定期実行の仕組み)や電源オフの時の動きも書かれています。
| 予定時刻の状態 | launchd(StartCalendarInterval) | cron |
|---|---|---|
| スリープ中 | 起きた時に実行する | 実行しない。次の予定時刻まで待つ |
| 電源オフ | 実行しない。次の予定時刻まで待つ | 実行しない。次の予定時刻まで待つ |
原因:到達を待つ処理が、最初に通信する処理より後にあった
最初に入れた待ちは、次の順番で動いていました。
直す前: git fetch → 依存パッケージの準備 → API への到達待ち → 本処理(AI の呼び出し)
git fetch(ギット フェッチ:GitHub などにある最新の変更を手元に取り寄せる命令)も、ネットワークを使います。到達待ちはその後ろにあったので、ネットワークが戻る前の回は、待ちにたどり着く前に git fetch で失敗していました。
図 1 のとおり、待ちを入れただけでは、待ちより前にある通信は守れません。到達待ちは、スクリプトの中で最初にネットワークを使う処理より前に置きます。
直し方 1:待ちを最初の通信の前に移し、宛先を 2 つにする
直した後の順番は次のとおりです。
直した後: 到達待ち(API と GitHub)→ git fetch → 依存パッケージの準備 → 本処理
宛先は 2 つにしました。AI の呼び出しが使う API と、git fetch が使う GitHub です。片方だけを確かめると、もう片方を使う処理がその回で失敗するため、2 つの両方に届いて初めて先へ進みます。
「届いた」の判定では、HTTP の 状態コード(サーバーが返す 200 や 404 などの結果の番号)を見ないことにしました。筆者の実装では、401 や 404 が返っても、名前の解決と暗号化の接続が通っていれば届いたとみなします。確かめたいのは「ネットワークが戻ったか」で、「ページが正しく開けるか」ではないためです。
形をそろえた例を示します。URL はダミーの値です。
# 宛先は、このあと実際に使う相手をすべて入れる(例の URL はダミー)
PROBE_URLS=("https://api.example.com/" "https://github.com/")
PROBE_INTERVAL=30 # 確認の間隔(秒)
PROBE_MAX_TIME=10 # 1 回の確認の最長(秒)
# LIMIT(上限の秒数)と wait_for_network は後の節で示す。
# 実際のスクリプトでは、この呼び出しより上に置く。
# 1. まず待つ(最初の通信より前)
wait_for_network || { echo "ネットワークに届きませんでした" >&2; exit 1; }
# 2. ここから先で初めて通信する
git fetch --quiet origin || exit 1
curl の公式マニュアルによると、—connect-timeout は接続の段階だけを制限し、その時間内につながればそのまま続きます(curl man page)。接続の後で応答が遅れると、—connect-timeout だけでは確認が長引くおそれがあります。そこで筆者の実装では、1 回の確認全体を 10 秒で打ち切るつもりで、—connect-timeout ではなく —max-time に秒数を渡しています。
直し方 2:2 つの宛先で 1 つの上限を分け合う(待ち時間の計算)
待つ時間には上限を付けます。上限が無いと、ネットワークが戻らない日に処理が終わらず、次の日の起動と重なってしまうからです。筆者の設定は次のとおりです。
| 項目 | 値 | 意味 |
|---|---|---|
| 上限 | 1,800 秒(30 分) | 2 つの宛先を合わせて、これ以上待たない |
| 確認の間隔 | 30 秒 | 届かなかった時に休む時間 |
| 1 回の確認の最長 | 10 秒 | 1 つの宛先の確認を打ち切る時間 |
上限は、宛先ごとに 30 分ではありません。2 つの宛先が 1 つの上限を分け合うので、宛先を増やしても待つ時間の上限は変わりません。
図 2 のとおり、API に届かない周は、GitHub を確かめずにそのまま休みます。この場合の 1 周は、確認 10 秒と休み 30 秒で最長 40 秒です。API に届いて GitHub に届かない周は、10 秒 + 10 秒 + 30 秒で最長 50 秒になります(筆者が設定値から計算)。
数え方を間違えると、上限を超えて待つ
直す前は、待った時間として sleep(指定した秒数だけ止まる命令)の 30 秒だけを足していました。確認にかかる最長 10 秒は数えていなかったので、上限 1,800 秒でも実際は最大約 40 分待つことがありました。
筆者が設定値から計算すると、次のようになります。宛先が 1 つで 1 周に 1 回確認する前提で、この前提だと記録にある「約 40 分」と一致します。
直す前: 数えるのは sleep の 30 秒だけ
上限に届くまでの周回 = 1,800 ÷ 30 = 60 周
1 周の実際の最長 = 確認 10 秒 + sleep 30 秒 = 40 秒
最悪の待ち = 60 × 40 = 2,400 秒 = 40 分
今は、bash の SECONDS(シェルが起動してからの経過秒数が入る変数)を使い、開始からの経過秒で数えます。さらに、確認の最長と休みを、残りの秒数より長くしません。こうすると、最後の周が上限をはみ出さず、待ちは 1,800 秒で必ず終わります。
wait_for_network() {
local start=$SECONDS remaining t pause url ng
while :; do
ng=""
for url in "${PROBE_URLS[@]}"; do
remaining=$((LIMIT - (SECONDS - start)))
[ "$remaining" -gt 0 ] || { ng=$url; break; }
t=$((remaining < PROBE_MAX_TIME ? remaining : PROBE_MAX_TIME))
curl -sS -o /dev/null --max-time "$t" "$url" || { ng=$url; break; }
done
[ -z "$ng" ] && return 0 # 2 つとも届いた
remaining=$((LIMIT - (SECONDS - start)))
[ "$remaining" -gt 0 ] || return 1 # 上限に達した
pause=$((remaining < PROBE_INTERVAL ? remaining : PROBE_INTERVAL))
sleep "$pause"
done
}
図 3 の値を表にすると次のとおりです(筆者が設定値から計算)。3 つ目の行は、後の「つまずいた点 2」で説明します。
| 場合 | 最悪の待ち(秒) | 分 |
|---|---|---|
| 数え方を直す前(sleep だけ数える) | 2,400 | 40 |
| 数え方を直した後(経過秒で数える) | 1,800 | 30 |
| 動き直した回が待った分を引かない場合 | 3,600 | 60 |
上限まで届かなければ失敗として終わり、完了の印を作りません。筆者は、届かない宛先と上限 3 秒で動かし、git fetch を実行せずに 終了コード(命令が成功したら 0、失敗したら 0 以外を返す番号)1 で終わることを確かめました。
つまずいた点 1:08 と 030 が 8 進数として読まれた
上限の秒数は、環境変数で変えられるようにしていました。数字だけかを ^[0-9]+$ という 正規表現(文字の並びの型を表す書き方)で検査していましたが、この検査は 08 や 030 も通します。
bash の公式マニュアルには、先頭が 0 の定数は 8 進数として解釈すると書かれています(GNU Bash Manual: Shell Arithmetic)。8 進数(0〜7 の 8 種類の数字で数を表す書き方)には 8 や 9 がありません。
図 4 の内容を表にすると次のとおりです。
| 書いた値 | bash の読み方 | 上限 | 起きること |
|---|---|---|---|
| 30 | 10 進数の 30 | 30 秒 | 意図どおり |
| 08 | 8 進数として読もうとして失敗 | 計算できない | 待ちの塊が丸ごと飛ばされ、終了コード 0 のまま次へ進む |
| 030 | 8 進数の 30(3 × 8 = 24) | 24 秒 | 意図の 8 割で打ち切る |
| 10#030 | 10 進数として読む | 30 秒 | 意図どおり |
08 の場合が特に危険です。筆者の手元の Mac(bash 3.2.57)で、次の小さなスクリプトを動かして確かめました。bash の版によって動きが違うことがあるので、自分の環境でも同じスクリプトで確かめてください。
L=08
if true; then
W=$((L))
echo inner-wait
fi
echo next-step-git-fetch
line 3: 08: value too great for base (error token is "08")
next-step-git-fetch
エラーの 1 行は出ますが、if の塊(待ちに当たる部分)は丸ごと飛ばされ、次の行(git fetch に当たる部分)へ進みました。スクリプト全体の終了コードは 0 で、失敗として記録されません。
同じマニュアルは、数を「基数#数」の形で書くと 2〜64 の基数で読ませられると定めています。10# を付ければ、先頭に 0 があっても 10 進数として読まれます。
LIMIT="${NET_WAIT_LIMIT_SECONDS:-1800}"
[[ "$LIMIT" =~ ^[0-9]+$ ]] || { echo "秒数は整数で指定してください" >&2; exit 1; }
LIMIT=$((10#$LIMIT)) # 08 は 8、030 は 30 として読む
外から受け取った数を bash の計算に使う時は、検査の後に 10# を付けて 10 進数に直してから使います。
つまずいた点 2:やり直しの回が上限を二重に待つ
筆者のスクリプトは、git fetch の後に「手元より新しい版のスクリプトがあれば、それで動き直す」処理を持っています。動き直した回も、先頭から到達待ちを通ります。
1 回目の回 : 到達待ち(最大 1,800 秒)→ git fetch → 新しい版がある → 動き直す
動き直した回 : 到達待ち(もう一度、最大 1,800 秒)→ git fetch → 本処理
合計 : 最大 2 × 1,800 = 3,600 秒(60 分)
1 回の起動で、上限の 2 倍まで待ちえたわけです。直した後は、1 回目が待った秒数を環境変数で次の回に渡し、上限から引いた残りだけ待ちます。
残りが 0 秒の時は、待ちを省いて先へ進みます。動き直す判定は git fetch の後にあり、1 回目で 2 つの宛先に届いたことを確かめ済みだからです。この近道は「動き直した回」の印がある時だけに限り、1 回目の回が宛先を試さずに進むことを防いでいます。
つまずきやすい点
- 待ちを入れたのに失敗する: 待ちより前に通信する処理(git fetch、パッケージの取得など)が残っていないかを確かめます。
- 上限を宛先ごとに数える: 宛先が増えるほど待ちが長くなります。開始からの経過秒で 1 つの上限を分け合います。
- 電源オフでも取り戻すと思い込む: launchd が取り戻すのはスリープ中だった場合だけで、電源が切れていた時は次の予定時刻まで動きません(Apple: Scheduling Timed Jobs)。
- 試し実行で 30 分止まる: 筆者の実装では、試し実行(dry-run)の時は待たず、2 つの宛先を 1 回ずつ試して記録するだけにしています。
自分のスクリプトに入れる時の確認項目
| 確認すること | 確かめ方 | 外すと起きること |
|---|---|---|
| 待ちが最初の通信より前にある | 待ちより前の通信を探す | 待ちに着く前に失敗する |
| 宛先が、後で使う相手をすべて含む | 本処理の通信相手と突き合わせる | 片方を使う処理が失敗する |
| 上限を経過秒で数え、最後の周を縮める | SECONDS などを使っているか見る | 上限を超えて待つ |
| 秒数に 10# を付けて読む | 08 と 030 を渡して動かす | 待ちが飛ぶ、または短くなる |
| 上限で失敗として終わる | 届かない宛先と短い上限で動かす | 失敗が成功として残る |
| やり直しの回が残りだけ待つ | 2 回目に待った秒数を見る | 上限の 2 倍まで待つ |
まとめ
- 到達待ちは、スクリプトの中で最初にネットワークを使う処理より前に置き、後で使う相手をすべて宛先に入れます。
- 上限は「開始からの経過秒」で数え、確認と休みを残り秒数で縮めます。筆者の計算では、これで最悪 40 分が 30 分に収まりました。
- 外から受け取った秒数は 10# を付けて読みます。08 は待ちを飛ばし、030 は 24 秒になります。
実行する場所を Mac 以外も含めて考えたい方は、手元の Mac・Cloud Run Jobs・GitHub Actions を止まり方と認証で比べた記事も参考になります。
自分の環境の定期実行の作りを相談したい方は、お問い合わせからご連絡ください。定期実行で集計する SQL の確認に不安がある場合は、BigQuery SQL レビューパックも用意しています。
参考にした一次情報
いずれも 2026-11-11 時点で内容を確認しました。
- GNU Bash Reference Manual: Shell Arithmetic(先頭が 0 の定数を 8 進数として解釈すること、基数#数の書き方)
- Apple Developer Documentation Archive: Scheduling Timed Jobs(launchd と cron の、スリープ中と電源オフの時の動き)
- curl man page(—connect-timeout が接続の段階だけを制限すること)