cronでバッチ処理を実行しているサーバをダウンタイムゼロで換装する手法

# 背景

突然の質問ですが、定期的に実行する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の記事や、そこのリファレンスとなっている各種ドキュメントを確認してください。

https://ja.wikipedia.org/wiki/Crontab

# 前提条件

本提案手法では、長時間かかるバッチがあったり、バッチの数が多いなど、常に何らかのバッチが実行されているような環境における、ダウンタイムゼロでのリプレイスを目指します。

しかし、毎分新しいジョブがキックされるようなサーバにおいては、本提案手法では厳密なダウンタイムゼロを達成できないのでお気をつけください。

# 環境情報

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する
* 時刻をチェックして、必要なジョブを実行する

```c
	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);
	}
```

https://github.com/cronie-crond/cronie/blob/40b7164227a17058afb4f3d837ebb3263943e2e6/src/cron.c#L354-L481

つまり、新しいバッチが実行されるのは、このチェックが走る（おおよそ)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` によって停止するとどうなるのでしょうか？
最後のお掃除部分のコードを見てみましょう。

```c

#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;
```

https://github.com/cronie-crond/cronie/blob/40b7164227a17058afb4f3d837ebb3263943e2e6/src/cron.c#L482-L495

気づきましたでしょうか。子や孫プロセスを`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` にも興味を持ってもらえたら幸いです。

# 参考

* https://rpmfind.net/linux/RPM/centos/7.5.1804/x86_64/Packages/cronie-1.4.11-19.el7.x86_64.html
* https://github.com/cronie-crond
* https://en.wikipedia.org/wiki/Cron
* https://ja.wikipedia.org/wiki/Crontab