定期実行のネットワーク待ち:復帰前に起動して失敗した構築ログ

定期実行のネットワーク待ちを、スリープ明けに失敗した実例から説明します。待ちを最初の通信の前に置く順番、宛先 2 つで 30 分の上限を分け合う数え方、秒数の 08 や 030 を bash が 8 進数と読む落とし穴を、待ち時間の計算つきで扱います。

17分で読めます Data
点線で途切れた電波の弧から砂時計を通って矢印が伸び、宛先を表す 2 つの丸に印が付いてから歯車へ進む、つながるまで待ってから動く定期実行を表す抽象図
この記事は AI が下書きを補助し、運営者が内容を確認して公開しています。

手元の 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
スリープ中起きた時に実行する実行しない。次の予定時刻まで待つ
電源オフ実行しない。次の予定時刻まで待つ実行しない。次の予定時刻まで待つ
launchd はスリープ明けに取り戻して動くので、起動直後にネットワークが無い前提でスクリプトを書く必要があります。

原因:到達を待つ処理が、最初に通信する処理より後にあった

最初に入れた待ちは、次の順番で動いていました。

直す前: git fetch → 依存パッケージの準備 → API への到達待ち → 本処理(AI の呼び出し)

git fetch(ギット フェッチ:GitHub などにある最新の変更を手元に取り寄せる命令)も、ネットワークを使います。到達待ちはその後ろにあったので、ネットワークが戻る前の回は、待ちにたどり着く前に git fetch で失敗していました。

直す前は git fetch で失敗して到達待ちまで進めず、直した後は到達待ちで API と GitHub の 2 つに届くのを確かめてから 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 つの上限を分け合うので、宛先を増やしても待つ時間の上限は変わりません。

起動から 1,800 秒までの時間軸に、API の確認 10 秒と 30 秒の休みが繰り返され、API に届いた周は GitHub も確かめて git fetch へ進み、1,800 秒で打ち切る様子を並べた時系列図

図 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
}

上限 30 分の到達待ちの最悪の待ち時間を、数え方を直す前の 40 分、直した後の 30 分、動き直した回が待った分を引かない場合の 60 分で比べた横棒グラフ

図 3 の値を表にすると次のとおりです(筆者が設定値から計算)。3 つ目の行は、後の「つまずいた点 2」で説明します。

場合最悪の待ち(秒)分
数え方を直す前(sleep だけ数える)2,40040
数え方を直した後(経過秒で数える)1,80030
動き直した回が待った分を引かない場合3,60060
上限は「休んだ秒数の合計」ではなく「開始からの経過秒」で数えると、確認にかかった時間も含めて上限を守れます。

上限まで届かなければ失敗として終わり、完了の印を作りません。筆者は、届かない宛先と上限 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 がありません。

上限の秒数に 30・08・030・10#030 と書いた時に、bash がそれぞれ 10 進数の 30、計算の失敗、8 進数の 24、10 進数の 30 と読むことを、上限と起きることで比べた表

図 4 の内容を表にすると次のとおりです。

書いた値bash の読み方上限起きること
3010 進数の 3030 秒意図どおり
088 進数として読もうとして失敗計算できない待ちの塊が丸ごと飛ばされ、終了コード 0 のまま次へ進む
0308 進数の 30(3 × 8 = 24)24 秒意図の 8 割で打ち切る
10#03010 進数として読む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 時点で内容を確認しました。