背景
突然の質問ですが、定期的に実行するJobをどのような形で管理していますでしょうか?
サーバレスが流行している2018年においても、昔ながらの cron
を利用されている方もいらっしゃるのではないでしょうか?
この記事では、 cron
でバッチ処理を実行しているサーバを、ダウンタイムゼロでリプレイスする方法を提案したいと思います。また、どうして、それが可能なのかを、cron
のソースコードをちら見しつつ解説したいと思います。(時間の都合上、CentOS7
での cronie
のみを取扱います。)
実運用を行う上では、きちんとしたジョブ管理システムを設計すべきではありますが、本記事では泥臭い手法を紹介します。
TL; DR
- 新サーバを
crond
が停止した状態で用意 - 最後のバッチがキックされたタイミングで、旧サーバの
crond
を止め、新サーバのcrond
を起動 - 最後のバッチの完了を見届け、旧サーバを完全停止
cronってなんでしたっけ?
cron
とは、UNIX系OSで利用される時刻ベースのジョブスケジューラであり、定期実行されるタスクをスケジュールするのに適しています。システムの管理にも用いられますし、実サービスのタスク実行にも用いられます。
例えば、crontab -e
により、以下のようなジョブを登録すると、毎時0分に集計バッチが実行されます。
0 * * * * /bin/bash -l -c '/home/vagrant/bin/execute_hourly_aggregation'
もっと詳しく知りたい方は、以下のWikipediaの記事や、そこのリファレンスとなっている各種ドキュメントを確認してください。
前提条件
本提案手法では、長時間かかるバッチがあったり、バッチの数が多いなど、常に何らかのバッチが実行されているような環境における、ダウンタイムゼロでのリプレイスを目指します。
しかし、毎分新しいジョブがキックされるようなサーバにおいては、本提案手法では厳密なダウンタイムゼロを達成できないのでお気をつけください。
環境情報
Vagrantで雑に立てた、以下の環境で説明します。解説対象のソースコードとのバージョンは少し異なりますが、大きな変更は入っていないので、仕組みの部分を理解していただければと思います。
[vagrant@localhost ~]$ cat /etc/redhat-release
CentOS Linux release 7.5.1804 (Core)
[vagrant@localhost ~]$ yum list installed | grep cron
Failed to set locale, defaulting to C
cronie.x86_64 1.4.11-19.el7 @anaconda
cronie-anacron.x86_64 1.4.11-19.el7 @anaconda
crontabs.noarch 1.11-6.20121102git.el7 @anaconda
手順
1. crondの停止した新サーバの準備
まずは、新のサーバを準備をしましょう。
ファイル構成としては、旧サーバと同じファイル構成で問題ありませんが、以下の2つを満たすように作ってください。
- 旧サーバのcrondは動作していること
- 新サーバのcrontab は旧サーバと同一の設定であること
- 新サーバのcrond は停止していること
例えば、CentOS7系なら、 service
コマンドを利用して止めちゃいましょう。
[vagrant@localhost ~]$ sudo service crond stop
Redirecting to /bin/systemctl stop crond.service
[vagrant@localhost ~]$ service crond status
Redirecting to /bin/systemctl status crond.service
● crond.service - Command Scheduler
Loaded: loaded (/usr/lib/systemd/system/crond.service; enabled; vendor preset: enabled)
Active: inactive (dead) since Tue 2018-09-11 17:44:33 UTC; 11s ago
Process: 3406 ExecStart=/usr/sbin/crond -n $CRONDARGS (code=exited, status=0/SUCCESS)
Main PID: 3406 (code=exited, status=0/SUCCESS)
2. ジョブがキックされなくなる時を待つ
cronのジョブがキックされなくなる時間を見計らってください。
ここで重要なのは、ジョブが完了するタイミングではなく、ジョブがキックされなくなるタイミングです。
ps auxf
などで crond
以下のプロセスを見つつ、旧サーバでの最後のバッチ処理がキックされたことを確認しましょう。
root 3492 0.0 0.3 26096 1704 ? Ss 17:45 0:00 /usr/sbin/crond -n
root 3921 0.0 0.4 82144 2488 ? S 18:05 0:00 \_ /usr/sbin/CROND -n
vagrant 3924 0.0 0.3 12992 1568 ? Ss 18:05 0:00 | \_ /bin/bash -l -c echo "start at $(date)" && sleep 120 && echo "end at $(date)"
vagrant 3937 0.0 0.0 7764 352 ? S 18:05 0:00 | | \_ sleep 120
3. 旧サーバでcrond
を停止し、新サーバでcrond
を起動する
いよいよ、リプレイス作業に移ります。
まずは、旧サーバでcrond
を停止し、新サーバでcrond
を起動しましょう。
この時点で、旧サーバで、新たなバッチ処理がキックされることはなくなります。
4. 旧サーバでバッチ処理が完了するのを見届ける
crond
は停止しましたが、 crond
がキックしたジョブは残っています。バッチ処理が完了するのを待ってあげましょう。このとき、crond
によりキックされたジョブは、大本の親を失い /usr/sbin/CROND
以下にぶら下がった状態になっています。
root 4199 0.0 0.4 82144 2488 ? S 18:18 0:00 /usr/sbin/CROND -n
vagrant 4201 0.0 0.3 12992 1564 ? Ss 18:18 0:00 \_ /bin/bash -l -c echo "start at $(date)" && sleep 120 && echo "end at $(date)"
vagrant 4214 0.0 0.0 7764 352 ? S 18:18 0:00 | \_ sleep 120
5. 旧サーバを完全に停止する
最後のバッチ処理が完了したのを見届けてから、丁重に停止してあげましょう。
種明かし
どうして、このようなことが可能なのでしょうか?
少し、cron
のコードを読みつつ、実行時のプロセスの状態を再確認してみましょう。
まず、cron
は SIGINT
や SIGTERM
のシグナルを受け取るまで、以下の処理をwhile
ループしています。
- 1分 sleepする
- 時刻をチェックして、必要なジョブを実行する
while (!got_sigintterm) {
int timeDiff;
enum timejump wakeupKind;
/* ... wait for the time (in minutes) to change ... */
do {
cron_sleep(timeRunning + 1, &database);
set_time(FALSE);
} while (!got_sigintterm && clockTime == timeRunning);
if (got_sigintterm)
break;
timeRunning = clockTime;
// ~ 中略 ~
handle_signals(&database);
}
つまり、新しいバッチが実行されるのは、このチェックが走る(おおよそ)1分ごとということがわかります。
新しいバッチは、 find_jobs()
によって発見され、 job_runqueue()
, do_command()
, child_process()
を経由して、もともとの crond
の孫プロセスとして実行されます。
root 3492 0.0 0.3 26096 1704 ? Ss 17:45 0:00 /usr/sbin/crond -n
root 3921 0.0 0.4 82144 2488 ? S 18:05 0:00 \_ /usr/sbin/CROND -n
vagrant 3924 0.0 0.3 12992 1568 ? Ss 18:05 0:00 | \_ /bin/bash -l -c echo "start at $(date)" && sleep 120 && echo "end at $(date)"
vagrant 3937 0.0 0.0 7764 352 ? S 18:05 0:00 | | \_ sleep 120
では、ここで SIGTERM
によって停止するとどうなるのでしょうか?
最後のお掃除部分のコードを見てみましょう。
# if defined WITH_INOTIFY
if (inotify_enabled && (EnableClustering != 1))
set_cron_unwatched(fd);
if (fd >= 0 && close(fd) < 0)
log_it("CRON", pid, "INFO", "Inotify close failed", errno);
# endif
log_it("CRON", pid, "INFO", "Shutting down", 0);
(void) unlink(_PATH_CRON_PID);
return 0;
気づきましたでしょうか。子や孫プロセスをwait
することも、kill
することもせずに、子に孫の処理を託して、親はひっそりと死んでいるのです。
root 4199 0.0 0.4 82144 2488 ? S 18:18 0:00 /usr/sbin/CROND -n
vagrant 4201 0.0 0.3 12992 1564 ? Ss 18:18 0:00 \_ /bin/bash -l -c echo "start at $(date)" && sleep 120 && echo "end at $(date)"
vagrant 4214 0.0 0.0 7764 352 ? S 18:18 0:00 | \_ sleep 120
その結果、新規ジョブはキックされない一方で、すでにキック済みのジョブは最後まで完了させることが可能になっているのです。(ちょっぴり切ないですね)
まとめ
crond
はとてもシンプルに実装されているので、本提案手法のような雑な方法でも、ダウンタイムゼロでのリプレイスが可能になっています。もちろん、実運用においては、きちんとしたシステム化をすべき領域ではありますが、身近にある cron
にも興味を持ってもらえたら幸いです。