「定時実行」と「定期実行」の実装ガイド
Intro
「毎時何時に実行」や「何時間ごとに実行」といった、タスクスケジューリングを実装する機会は少なくない。
タスクを実装し cron に登録すれば、動くものを実装するのは難しくない。
しかし、ひとたびタスクが思うように動かなかった途端、隠れていた要件が牙を剥く。
一度でも痛い目を見た人は、それを経験的に疑い「まず色々と前提を確かめよう」という気持ちになり、例外処理を事前に固めることができる。
今回は、実装方法そのものというよりも、「実装する前に確認すべき例外事項」を個人の経験を元に解説していく。
定時実行
「定時実行」とは、例えば「毎日 00:10 になったら、前日分のログをローテーションする」や「毎日 9:00 になったら一斉にメールを送る」といったものだ。バッチ処理などと呼ばれることも多い。
なお、00:10 のように微妙にずらすのは、00:00 には他のタスクも動いており、一度に負荷が上がるのを避けるための慣習だ。ずらした先が、他と被らないようにだけはしておきたい。
実装も、「普通にスクリプトを書いて、00:10 に実行するだけだろう」と思いがちだ。
ここでは少し短くして、毎時 10 分にタスクを実行する場合を考えてみよう。イメージとしてはこうだ。
初回実行
まず最初に、意外と見落としがちなのは、「スケジュールを開始した瞬間に実行するかどうか」だ。スケジュールの実行は、大抵中途半端なタイミングで行われ、その前にも処理すべきデータがある。
例えば、「毎時 10 分に実行するスケジュールを、23:50 に開始したらどうするか」を考えてみよう。筆者の経験上、ここには大体 3 パターンある。
1: 毎時 10 分と言っているのだから、次の最初の 10 分、つまり 00:10 から開始する。そこまでのデータもそこで処理する。
2: 開始時に 23:50 までのデータを処理し、次は 00:10 にそこまでの処理を行い、以降は直近 1 時間のデータを処理する。
3: 最初に 23:10 より前のものを一旦処理しておき、00:10 には直近 1 時間のデータを処理する。
メールの一括送信のようなものは、次の定時から始めれば良い。しかし、何かを集計するようなタスクでは、集計結果のグラフがそこだけ異常値になるが、その異常値の影響をなるべく小さくなる方に倒したいことがほとんどだ。
タスクによっては、「スクリプトを開始した瞬間から、次の定時までが n 分以内なら〜」といった絶妙な条件分岐を入れ、集計の影響が以降の集計に影響しないように調整することもある。
この初回実行は、スクリプトを最初にデプロイして実行した瞬間だけでなく、何かの弾みでスケジューラを再起動した際にも関わってくる。
「定時実行の初回は例外処理」と捉え、実装も「初回だけ動く前処理」と「繰り返す本処理」を分けて実装しておくと良い。
タスク遅延
初回をなんとか乗り切り、無事に定時タスクが走るようになったとしよう。
ところで、「00:10 に走ったタスクが 01:10 までに終わる」という保証はあるだろうか?
「このタスクが何分で終わるか」を意識して実装するのは重要だ。「さすがに 1 時間もかからないだろう」という根拠のない自信を元にデプロイしがちだが、想定していないことが起こるのが例外であり、例外処理を考えていなければ、例外が起こったときに困ることになる。
例えば、移動するファイルが普段数 MB だったのが、その日だけ何かの弾みで TB ほど膨れ上がり、コピーにいつもより時間がかかった。サーバの再起動に重いアップデートが挟まって時間がかかった、ネットワークが極端に遅くアップロードが全く進まない。AWS や Cloudflare が落ちた、といったことが起こり得る。
様々な例外があるため、「どんな例外が起こり得るか」を個別に洗い出して例外処理(つまり try-catch など)を実装する手前で、運用として「次のタスクまでに前タスクが終わらなかったら」を必ず考えておくべきだ。
取り得る選択肢はいくつかある。
1: 終わっていないタスクを中断して次を始める
「実行したこと自体に意味がある」タイプのタスクはこれでよいだろう。特に、中断時のリカバリが定義できる要件であれば実現しやすい。
ただし、そこまでのデータを処理するような場合は、前のタスクが失敗していた場合、残っているデータをどう処理するかなどを考慮する必要がある。
2: 終わっていないタスクと並行して次を始める
「完遂に意味がある」タイプのタスクなら 2 を実装しがちだが、非常に注意が必要だ。
この場合、終わっていない理由が解決しない限り、無限にタスクプロセスが増え続ける可能性がある。そして、気づいたらリソースを食い潰してサーバが落ちる。そこまで終わっていなかった数週間分のタスクが全て中途半端に飛び、復旧で地獄を見る、ということになりかねない。
3: 人間に助けを求める
要するにアラートを出してスケジューラ自体を止めるという方針だ。
筆者の意見としては「タスクスケジュールの中での例外処理は、そのプログラムの中で解決しようと頑張らない方が良い」と考えている。
これは一般的な「例外処理」の考え方でもあるが、タスクとスケジューラの責務は、その実行環境の前提が整った上で履行される。前提が整っていない状態での実行を保証することは原理的にできない。それはタスクやスケジューラの「バグ」ではないからだ。
なんとか落ちないように例外処理とリトライで固めようとしても、解決対象の問題がそのプログラムによって自己解決できることなどほとんどない。解決したように見えて解決に失敗していた場合、気づくのが遅れ、気づいたときには手遅れになる場合すらある。
なんとかしようとすればするほど、被害を大きくするだけなことがほとんどだ。
人間に気づかせて状況を確認させ、手動で環境を整備させ、前提を整えて再開する、というのが一番安心できる堅牢な実装だ。
したがって、基本は 3 で、アラートやシグナルやメールや Slack などでいち早く人間に知らせる。そして、人間が来るまでの短期間を凌ぐ間、1 か 2 のどちらかを選ぶように実装しておくのが落としどころだろう。
いつも 5 分で終わってたタスクも、失敗すれば長引く。長引くときは 1 日でも 1 週間でも長引く。「タスクが時間内に終わるとは限らない」ということは忘れないでほしい。
リトライ
タスクが失敗した場合、一番やりがちなのは「リトライ」だ。これは、大抵の場合問題を深刻にする。
サーバやネットワークが一時的にちょっと不調だった場合などは、数回のリトライで上手くいくこともある。しかし、先程のように、環境に問題がある場合、何度繰り返そうと環境が直っていなければタスクは完遂できない。あたりまえだ。
その環境を直すプログラムを例外処理に含むのも限界がある。アクセストークンを再発行したり、ストレージに空き容量を増やしたり、抜けた LAN ケーブルを差し込んだりするのは、タスクやスケジューラの責務ではないからだ。
にもかかわらず、単にループを回してリトライし、リソースを食いつぶしてサーバを落としたり、相手にスパム判定されてブロックを食らったりといったよりひどい結果をもたらすことは往々にしてある。
基本的に、「リトライして解決できる問題は非常に少ない」と思った方が良い。モバイルアプリのように、しょっちゅう WiFi と SIM を行き来してネットワークの切り替えが走るような場合は別だが、スケジューラを動かす環境でそういうことは少ない。
そのうえでリトライするのであれば、絶対に考えるべきことが 2 つある。
-
リトライ間隔をどうするか
- 一定間隔で繰り返す
- Exponential Backoff (2, 4, 8, 16 と倍々で間隔を増やす)
-
リトライ上限をどうするか
- 回数上限
- 時間上限
筆者としては「2 回リトライしてダメなら諦めて人間を呼ぶ」くらいがちょうど良いと考えている。
例えば、失敗直後に一回リトライ、しばらくしてからもう一回リトライ、それでもダメならアラートで人間を呼び出して終了、といった実装だ。
タスクにとって重要な責務は、無駄に悪あがきせず「何が原因で失敗したのか」をなるべく高い解像度で吐き出してから落ちることだ。
スケジューラにとって重要な責務は、失敗したことを隠さず、なるべく即座に、なるべく盛大に、失敗した事実を人間に伝えることだ。
タスク欠損
定時実行の仕様を噛み砕くと、「定時に実行する」の前提として「その前までも同じように実行されている」が暗黙に隠れている場合がある。
例えば、「毎時 **:10 にログを集計してレポートを出す」は、「その前のログが処理されてレポートが無事生成されている」ことが前提となっている。
1 つ前のレポート生成が失敗し、そのタスクがリトライを諦めてレポートが欠損していたら。そもそも、ちょうどメンテ中でサーバが落ちている間に、前回のタスクの時間が来て、実行されていなかったら。
次のレポートが二回分集計してしまうと困る。そうなっていなくても、前日のレポートがないと次のレポートに貼るリンクがなくてビルドが失敗する。などといった要件から、一個失敗したら、それ以降全て失敗するということがあり得るのだ。
対策としては 2 つある。
-
前後のタスクを極力依存させない
- 前のタスクへのリンクは別でやる
- 前のタスクとの差分を取るなどは別でやる
- 整合性の検査はそのタスクとは別でやる
-
タスクが扱うデータを、そのタスクの担当範囲時間に絞る
- 「今残っているデータを全部処理対象にする」ではなく「自分の担当時間分だけ処理対象にする」といった実装をする
「タスクは独立させる」「整合性検証は別でやる」は、あくまで理想ではある。
そういった整合性が厳密に求められるなら、結局「一回でも失敗したら、それ以降余計なことをせず全部止めて、人を呼ぶ」方が全体としては被害が少ないこともある。
とにかく「前のタスクが成功しているとは限らない」は実装時に意識すべきだ。
タスク重複
重複とは、「これからタスクの中で作ろうとしていたファイルが、なぜか既にある」というような状態だ。
「なぜそんなことが起こるのか」と思うかもしれないが、リトライや、タスク実行中の再起動などで、例えば途中まで作ったファイルがある状態でもう一度タスクが走ってしまう、といった状況は普通にあり得る。
このとき問題になるのが、例えば作るファイルの命名規則が被る場合、重複するため「上書き」「追記」「スキップ」の 3 択を迫られることだ。
もし、ファイルを「連番」で作っていると、そもそもタスクが被ったことに気づかないままファイルを作り足してしまう可能性がある。すると、1 時間に 1 個しか作られないと思っていたファイルが、なぜか 1 時間に 10 個作られていたりすることがあるわけだ。
「定時実行」の場合は「その時間に一回だけ実行する」という要件が裏に隠れていることが多い。大抵は「この時間のタスクが実行されたか。それが成功だったか失敗だったか。」といった情報を保存し、タスク開始時に検証できなければならない。
しかし、そこまで考えて実装していないタスクは往々にしてある。実行回数や成否くらいはスケジューラ側が管理してくれる場合もあるが、スケジューラは通常タスクが何をしたかまで知らない。
「タスクは冪等に設計すべき」といった話になるが、「ファイルが増えすぎるから集計して削除したい」のように、そもそもタスク自体が副作用を持つ場合は、冪等にするのが難しかったりもする。うっかり上書きして消してしまうと、より大きな問題になる要件だ。
その場合は「集計」と「削除」のタスクを分け、毎日集計をするがファイルを残し、ファイルの削除は週に一回行う、といった二段のタスクにするといったことも考えられる。しばらく残しておくことで、最悪なんとか復旧するといったバッファだ。残しておいたらなんとかなる、というのも、かなり願望混じりではあるため、重要なタスクなら副作用部分は人間が確認の上でタスクをトリガーするといったことも考えられる。
とにかく、「一度だけ確実に実行する、は難しい」ということは覚えておくべきだ。
もちろん、冪等に実装するコストをかけて「保証」できるなら良いが、大抵複雑度を増すだけになったりする。発生頻度が少ないなら、「すでにファイルがあったら中断して人を呼ぶ」くらいの方が良い場合もある。
定期実行
定期実行、つまり「何分に一回実行」というタイプの実装は、定時実行よりも数段難しい。そんなに違うのかと思うかもしれないが、定期実行と定時実行の違いは、「絶対的な軸」がないということだ。
ところが、会話の中では思った以上に一緒くたに使われるので、そこに注意が必要である。
例えば「10 分ごとに実行」を考えてみよう。
実は、一番見落としがちなのが「実行間隔」である。「10 分ごとと言っているのだから 10 分ごとだろう」と思うかもしれない。おそらく、今想像している 10 分ごととは、こういうものではないだろうか。
では、それがざっくりこんな実装だったとしよう。
この場合、タイムラインはこうだ。
わかりにくいかもしれないが、微妙に後ろにずれている。
「タスクが完了してから 10 分」で実装しているため、タスク実行にかかる時間を忘れているからだ。
これはおそらく、多くの人が思う「10 分ごとに実行」とは異なる。
多くの人が思う「10 分ごとに定期実行」は「開始時を基点とした 10 分間隔の定時実行」であることが多い。
たとえ一瞬で終わりそうなタスクであっても、時間がかかるものだと想定して実装しなければならない。
スケジューラとタスクを、一緒に実装してはならない理由でもある。
定期実行でも、注意点は同じだ。例えば、前のタスクが 10 分以上終わらなかったらどうするか。
もし、前のタスク終了から 10 分で続けていくと、どんどんズレが大きくなっていく。コレがしたい要件は、経験上見たことはない。あるんだろうか?
前述を踏まえて「前の実行から 10 分経ったら次を始める」という実装だと、タスクが並列で走ってしまう。しかし、大抵は「前の実行から 10 分経ったら、前のタスクは終わっているので、次のタスクを始める」という暗黙の前提がある。
つまり、スケジューラは前のタスクの実行が完了しているかも確認する必要があるのだ。そのうえで、前のタスクを止めて次を走らせるか、フォークして次のタスクも定時で走らせるかを決めることになる。
しかし、大抵は前の実行に問題があり、それを放置して次に進むほうがリスクなことも多いため、スケジューラ自体に「タスクが 3 分以上かかっていたら、全てをアラート」といった、スレッショルドを設定する方が良い。それをしていれば、後続をどうするかを考えるのも含め、人間に判断を委ねられて、実装をシンプルにできる。
「定期実行」のリトライも注意が必要だ。特に「定期実行」として実装されたものは、初回実行を行うものが多い。したがって、例えばうっかり消えたファイルを探してリトライをし続けると、リトライした回数だけ実行し続けることになる。
したがって、リトライは必ず間隔を空けるべきであり、回数を制限して終了するようにしなければならない。
この点も、先述の話と同様だ。
定時実行と違って、定期実行ではタスクを開始した瞬間に初回を実行するのが普通だろう。
一見、それで良さそうに見える。
しかし、ここにも落とし穴がある。
「定期実行は定時実行と違って軸がない」が問題になるのは、スケジューラが再起動したときだ。
先ほどの実装で、実行を開始してから 15 分後に再起動が走ったら、どうなるだろうか。
おそらくこうなる。
「定期実行は、前回開始地点からの経過時間」と普通は考えられるため、実は初回実行は「前回最後にいつ実行したか」を踏まえて行わなければならない。
ただ繰り返すだけと思われがちな「定期実行」も、実際には様々な状態を保存しておかないと実装は難しいのだ。
定期実行の初回実行はスクリプトの開始時、と思われがちだが、意外と「最初の実行はこのタイミングにしたい」というように、開始時刻を指定したくなることがある。
例えば、今は 00:12 だが、最初の実行は 00:30 にして、そこから 10 分ごとにしたいといった場合だ。しかし、そのために人間が 18 分間待機するのは不便なので、定期実行は大抵開始タイミングを指定できる実装にしたくなるものだ。意外と面倒である。
実行間隔
loop do
task()
sleep(10 * 60)
end
タスク遅延
リトライ
初回実行
再起動
開始時刻指定
実装 Tips
タスクにスケジューリングを実装しない
最も避けるべきは、なんちゃってスケジューラの自前実装だ。
こういうやつだ。
loop do
task()
sleep(10 * 60)
end
なぜか、「スケジューラを使うまでもない」などの意味不明な理由で、こういうものを常駐させたがる人がいる。「定期実行」や「定時実行」で痛い目を見たことがない、経験不足からくる失態だ。
タスクのテストもしづらく、スケジューラもどこまで考慮できているか怪しい。スケジューラなんて大したことがない、と思っている人がまともに実装できていることなんかありえないので、こういう実装を見たら、まずタスクを剥がして、スケジューラを導入しよう。
「定期実行」は「定時実行」に置き換える
慣れている人には分かりきっていることだが、「大抵の定期実行は定時実行に置き換えて実装した方が良い」。
cron は最初からそうなっているため、以下のように書いておけば 10 分間隔の定時実行になっている。
*/10 * * * * command
また、それなりのスケジューラは基本的に cron 互換のスケジュールに対応しており、定時実行で設定ができる。
ところが cron はシンプルすぎて、ここまで書いたような細かい処理をカバーしきれない。
今ならスケジューラにも Systemd を用いる方が良い。
Systemd
タスクを常駐デーモンにするにも、単発タスクにするにも、どちらにせよ systemd で管理するべきだ。
ログや再起動などの管理だけでなく、Timer 機能を使うことで cron よりもリッチなスケジューラ機能を、標準で使うことができる。
タスクを実装して cron に登録するのと同じように、タスクを Service にし、Type=oneshot で登録する。
スケジュールは、Timer に cron と同じように実装すれば良い。
次の 10 分から 10 分ごとの定時実行はこうなる。
[Service]
Type=oneshot
[Timer]
OnCalendar=*-*-* *:00/10:00
また、この定時実行時に systemd が落ちており、実行されなかったタスクがあった場合、Persistent=true を付けておけば、補完して実行することもできる。
定期実行の方もサポートすることができ、実行時間もオプションで細かく選ぶことができる。
[Timer]
OnBootSec=10min # システム起動から
OnStartupSec=10min # systemd 起動から
OnUnitActiveSec=10min # 対象 service の前回起動から
OnUnitInactiveSec=10min # 対象 service の前回完了から
他にも、発火時刻の許容誤差を指定したり、ランダムな遅延を加えたりと、さまざまなオプションがあるため、細かい設定が systemd だけで可能だ。
タスクは単発実行されるコマンドとして実装されがちだが、それが必ずしも良いかというと、ケースによる。
実際、ここまで述べてきたように、タスクの実行には前提となる条件が多く、それらを状態としてどこかに保持しなければならない。単発タスクなら結果を毎回ファイルに書き出し、ファイルの有無である程度を察することもできるが、ファイルの数が増えるほど状態の復元やチェックが面倒になる。例外処理も増える。
そのため、比較的重要なタスクはデーモンにして常駐させるのも 1 つの手だ。
そうすれば、インメモリに状態を持っても良い。前回実行について覚えておくこともできるし、再起動した場合にメモリ上の状態が無いことで「初回実行」を判別もできるからだ。
すでに Systemd を使っているなら、そのままいつも通りデーモンとして登録する。
では、デーモンにどう実行タイミングを教えるかと言うと、いくつか方法がある。
大きな違いは通知する相手を知る必要があるかどうかだ。ファイルを決め、それを 今だとシグナルなどを扱うより、普通に HTTP を立ててしまったほうが、慣れている人も多い上に、簡単な UI を提供することもできて、良いようにも思う。
そこまでいくと話が変わってくるが、「単なる定時/定期実行だと思っていた」ものが、色々と気にし出すと大きくなっていくというのは、よくあることだ。
タスクをデーモンにする
[Service]
Type=simple
ExecStart=/usr/local/bin/taskd
kill -USR1 $(cat /var/run/taskd.pid)
touch /var/run/taskd-trigger
echo "run" > /var/run/taskd.fifo
socat - UNIX-CONNECT:/var/run/taskd.sock <<< 'run'
echo "run" | nc localhost 9090
curl -s -X POST http://taskd/run
touch したことをイベントでハンドルする方法は、そのファイルを見ているのが誰かを気にしないで良い。他の方法は、明確にデーモンめがけて指示を出す。
呼び出されないために
安易に「人間を呼べ」などというと、「そんなことでいちいち呼ぶな」という話になる。
そのとおりだ。「人間を呼ばないと解決しない」問題は人間を呼ぶしかないが、「人間を呼ばないとならない状態」になることを推奨するものではない。
つまり、スケジューラの監視が重要である以上に、そのスケジューラが動いてる「環境そのものの監視」の方が実は重要なのだ。環境が整っていれば、雑に登録したタスクもそれなりに上手く動く。
一方、環境がしっかり整っていれば、その上で動くタスクの実装、特に Access Token の更新などはきっちり管理した上でデプロイするといった、最小限の考慮は前提となる。
そうした考慮をサボり、かといってアラートを上げるのも遠慮して、無駄な処理を無限に繰り返して大事になってから発覚するというのが、最も最悪のパターンだ。
本番に影響しないようにと、誰もまともに監視していない別リージョンのインスタンスに閉じ込めて放置した結果、リトライでファイルを無限に生み、リソースを完全に食いつぶした状態でやっと気づき、SSH で入ってみるとファイルが溜まりすぎて ls すら返ってこないといった悲惨な状態で発掘されたりする。
大したことがない要件だから、と甘く見られているものほど、雑な環境で雑に実装され、雑に走り続けた結果最悪の事態に陥りがちだ。どんな簡単なタスクも、ちゃんと実装し、ちゃんとレビューし、ちゃんとした環境に、ちゃんとしたスケジューラで走らせ、ちゃんと監視していればいいのだが、そうならないことが多い分野でもある。
まとめ
これだけ考えても、実行したいタスクによって「隠れた前提条件」は次々に出てくるだろう。一度グラフを書き、そのグラフのバーを極端に伸ばしたり、ずらしたり、消したりしながら、どこまで炙り出せるかで失敗したときの被害はかなり変わる。
また、6 ヶ月を超えるような定期実行は、カレンダーに登録して人間が行うのが一番安心だ。