皆様、いかがお過ごしであろうか? ハロウィンは仮装するまでもなくゾンビと化していた野地である。俺のCPUが火を噴いている。
さて、普段はPHPとJavaScriptを用いたプログラミングを主な仕事にしている自分だが、最近の仕事でデータベースをかなり短い周期、具体的に言えば10秒に一回程度監視する必要があるシステムを組む要件が発生した。
普通、Webサイトのバックエンドはユーザーからのアクセスをトリガーにして動作するし、定期的な処理が必要であればcronの出番なのだが、ここまで短い周期での常時監視にcronは適していない。
なのでインフラに関してはLinuxっていう種類のOSで動いているらしい、ぐらいの知識しかなかった筆者が頑張ってPHPをデーモン化することになったのだが、何とか様になったので今回はその知見を記事にしたいかと思う。
PHPをデーモンとして動かすとかwww という声が聞こえてきそうだが、生暖かい目で見て頂けると幸いである。
- まずは動かすための要件を確認する
- PHPで常時監視を実現するために無限ループを使用する
- CLI起動以外のアクセスを弾く
- 処理が終了してしまった場合自力で再起動できるようにプロセスをフォークする
- サンプルphpファイル
- Linuxへデーモンとして認識させるために.serviceファイルと.shファイルを作成する
- まとめ
まずは動かすための要件を確認する
仕事で使ったサーバーで動いていたのはCent OS の7系だったハズだが、この記事を書くに辺り使用した自宅サーバーは
Ubuntu Server 18.04.1 LTS
なので、Cent OS 等で開発する際は適宜読み替えて欲しい。
また、実際に仕事で使用したコードは PHP 7 系 + CodeIgniterでの開発ということでクラスのメソッドに型がビシバシ付いていたが、今回紹介するコードは多分 PHP 5 系でも動くハズである。
PHP 側のコードではPCNTL関数を多用するため、要件を満たしているか確認の上作業を進めて欲しい。
PHPで常時監視を実現するために無限ループを使用する
普通、プログラムは起動とともに逐次実行され、分岐やループを経ていつかは終了するものである。Web系の開発をしている場合、だいたいの処理は1秒以内に終わるようなものが大半だろう。
では常時監視、という処理はどのように実現するのだろうか。
答えは無限ループである。直感的にはヤバイ匂いしかしないが、少なくともPHPの場合は無限ループだった。
ただし、そのまま無限ループさせると可能な限り早い速度でループ処理が繰り返されるので、処理の最後でsleep
関数などを用いて処理を停止させるのが基本的な方針となる。
<?php while(TRUE) { //ここでなにか定期的に行う処理 var_dump('処理実行'); //10秒停止する sleep(10); }
単純な話、これがやりたいだけなのだが、ユーザーからのhttpアクセスに対して処理結果を出力するのがメインのPHPではその他に工夫しなくてはならない点がいくつかある。
CLI起動以外のアクセスを弾く
まず、PHPにはブラウザからのアクセスに対して実行制限時間があるという問題を回避しなくてはならない。
実際何分何秒まで許容するのかは設定によるのだが、ブラウザから何時間もかかるような処理へ自由にアクセスされてはたまったものではないので、普通に運用するWebシステムであれば間違いなく設定されているだろう。
しかし、常時監視システムは文字取り常時処理しっぱなしとなるので、ブラウザから起動するとその実行制限時間に引っかかってしまう。
なので、常時監視システムはCLI(コマンドライン)からの起動することになる。
これは処理を実現させるだけなら特にコードは変わらないのだが、多くの場合、CLIから起動すべき処理はブラウザから起動させたくはないだろう。
便利なことに、PHPにはそれをアクセスごとに判定できる定数が既に用意されている。
<?php //CLI以外の起動を防ぐ if (PHP_SAPI !== 'cli') { die(); }
以上のコードを冒頭に置いておくだけで、このif文以下のブロックは実行されなくなる。
処理が終了してしまった場合自力で再起動できるようにプロセスをフォークする
もう一つ工夫すべき点は、なんらかの要因で処理が途中で終了してしまった場合に同じ処理を再起動させる仕組みである。
理想的にはずっと無限ループ内の処理が繰り返されるハズだが、なんせ扱っているのはPHP。どんな要因で処理が止まるかは分からないし、全ての障害を取り除くのは困難だ。
厳密には後ほど解説する Service の設定で処理が落ちても即再起動できるように作ることも可能だが、PHP単体でも再起動できるようにコードを工夫するべきだろう。
さて、それを実現するためにはまず、同じコードでプロセスをフォークする必要がある。
名前通り食器であるフォークを想像してほしいのだが、フォークを使えば通常一本道の処理を親の処理から子の処理へと分岐させ同時並行で処理させる事ができる。
まず、処理が先述の無限ループに至る前に親(実行中のコード)から子へと処理を分岐させ、無限ループ部分は子に実行させる。
親は子とずっと並走しているのだが、実際に処理を行っている子が異常終了したら親はまた新たな子を生成し、同じく無限ループ部分を実行させるのだ。
こうすることでPHPのみで、異常終了が発生しても即再起動することができる。
<?php //pidファイルへのパス define('PID_FILE_PATH', '/run/watchTest.pid'); //テキストファイルへのパス define('TEXT_FILE_PATH', dirname(__FILE__).'/watchTest.txt'); /** * $pathで示された.pidが示すプロセスが現在実行中かどうか判定する * このメソッドの副作用として、ファイルは存在するがプロセスが実行中でない場合は * ファイルを削除するので注意 * @param string $path * @return bool */ function is_process($path = PID_FILE_PATH) { //そもそも.pidファイルが見つからない場合はFALSEを返す if ( ! file_exists($path)) { return FALSE; } //pidが指し示すプロセスが実行中か確認する($statusにintが入ってくる) $pid = trim(file_get_contents($path)); system("ps {$pid} ", $status); //既存のプロセスが無ければファイルを削除しつつFALSEを返す if ($status) { unlink($path); return FALSE; } //既存のプロセスが実行中と判定 return TRUE; } /** * 現在のプロセスIDを$pathに上書き保存する * $pathで示されるファイルが無ければ新規作成する * 成功可否がboolで返る * @param string $path * @return bool */ function set_pid_file($path = PID_FILE_PATH) { //現在のプロセスIDを取得する $processID = getmypid(); //$pathに上書き(無ければ新規作成)する $fp = fopen($path, 'w'); $result = fwrite($fp, $processID, strlen($processID)); fclose($fp); //fwriteの返り値によって成功/失敗を判定して返す return $result === FALSE ? FALSE : TRUE ; } /** * 処理を実行する * 親プロセスの場合は子を実際に起動させる * 子プロセスはタスクを実行する */ function watch() { //シグナルディスパッチを発動 pcntl_signal_dispatch(); //子プロセスを生成 $pid = pcntl_fork(); //失敗時はプロセスを終了させる if ($pid < 0) { die('プロセス起動に失敗しました'); } //子プロセスの場合 elseif ($pid === 0) { //既にこのメソッドが実行中の場合はここで処理終了 if (is_process()) { die('既存プロセス実行中により終了'); } //pidファイルの生成 if ( ! set_pid_file()) { //.pidファイル作成の失敗 die('.pidファイル作成失敗により終了'); } //ここで無限ループを呼び出す while(TRUE) { //何か処理 } } //親プロセスの場合 else { //ゾンビプロセスから守る pcntl_wait($status); //シグナルによる停止(CLIからのkillコマンド等)でなかった場合は再起動する if ( ! pcntl_wifsignaled($status) && ! is_process()) { watch(); exit(); } } //シグナルディスパッチを発動 pcntl_signal_dispatch(); } //処理実行 watch();
サンプルphpファイル
上記の手順で用意したサンプルのphpファイルが下記である。
<?php //CLI以外の起動を防ぐ if (PHP_SAPI !== 'cli') { die(); } //pidファイルへのパス define('PID_FILE_PATH', '/run/watchTest.pid'); //テキストファイルへのパス define('TEXT_FILE_PATH', dirname(__FILE__).'/watchTest.txt'); /** * $pathで示された.pidが示すプロセスが現在実行中かどうか判定する * このメソッドの副作用として、ファイルは存在するがプロセスが実行中でない場合は * ファイルを削除するので注意 * @param string $path * @return bool */ function is_process($path = PID_FILE_PATH) { //そもそも.pidファイルが見つからない場合はFALSEを返す if ( ! file_exists($path)) { return FALSE; } //pidが指し示すプロセスが実行中か確認する($statusにintが入ってくる) $pid = trim(file_get_contents($path)); system("ps {$pid} ", $status); //既存のプロセスが無ければファイルを削除しつつFALSEを返す if ($status) { unlink($path); return FALSE; } //既存のプロセスが実行中と判定 return TRUE; } /** * 現在のプロセスIDを$pathに上書き保存する * $pathで示されるファイルが無ければ新規作成する * 成功可否がboolで返る * @param string $path * @return bool */ function set_pid_file($path = PID_FILE_PATH) { //現在のプロセスIDを取得する $processID = getmypid(); //$pathに上書き(無ければ新規作成)する $fp = fopen($path, 'w'); $result = fwrite($fp, $processID, strlen($processID)); fclose($fp); //fwriteの返り値によって成功/失敗を判定して返す return $result === FALSE ? FALSE : TRUE ; } /** * $pathで指定されたファイルに$textを追記する * $textの先頭に日付、末端に改行を勝手に入れる * @param string $path * @param string $text * @return bool */ function append_text($text, $path = TEXT_FILE_PATH) { //ファイルが無い場合はFALSEを返す if ( ! file_exists($path) && ! touch($path)) { return FALSE; } //追記モードで書き込む return file_put_contents($path, '['.date('Y-m-d H:i:s').'] -> '.$text."\r\n", FILE_APPEND) !== FALSE; } /** * 無限ループでタスクを実行する * $interval秒に一回実行される * @param $interval */ function task($interval = 1) { //変な$intervalを防ぐ $interval = ! is_int($interval) || $interval < 1 ? 1 : $interval; //無限ループでタスク実行 while (TRUE) { append_text('タスク実行中'); //$interval秒停止 sleep($interval); } } /** * 処理を実行する * 親プロセスの場合は子を実際に起動させる * 子プロセスはタスクを実行する */ function watch() { //シグナルディスパッチを発動 pcntl_signal_dispatch(); //子プロセスを生成 $pid = pcntl_fork(); //失敗時はプロセスを終了させる if ($pid < 0) { append_text('プロセス起動に失敗しました'); } //子プロセスの場合 elseif ($pid === 0) { //既にこのメソッドが実行中の場合はここで処理終了 if (is_process()) { append_text('既存プロセス実行中により終了'); exit(); } //pidファイルの生成 if ( ! set_pid_file()) { //.pidファイル作成の失敗 append_text('.pidファイル作成失敗により終了'); exit(); } //タスク実行 task(3); } //親プロセスの場合 else { //ゾンビプロセスから守る pcntl_wait($status); //シグナルによる停止(stopWatch())でなかった場合は再起動する if ( ! pcntl_wifsignaled($status) && ! is_process()) { watch(); exit(); } } //シグナルディスパッチを発動 pcntl_signal_dispatch(); } //処理実行 watch();
このファイルが/var/www/html/watchTest/index.php
にある場合、CLIから
sudo nohup php /var/www/html/watchTest/index.php &
とコマンドを実行すれば/var/www/html/watchTest/watchTest.txt
が勝手に生成され、中にメッセージが蓄積されていく。
sudo
付きで実行すれば問題ないかと思うが、ディレクトリやファイルに実行権限が無い場合はchmod
コマンドで実行権限を操作しよう。
nohup
付きでバックグラウンド実行するため、実際に処理が動いているか確認する際は
ps auxf | grep php
を実行すれば階層構造で動いているindex.phpの雄姿が確認できる。処理を止める場合はそこに表示されているプロセス番号を控え、
sudo kill プロセス番号
で処理を停止することが可能だ。
Linuxへデーモンとして認識させるために.serviceファイルと.shファイルを作成する
上記の手順で用意したphpファイルはそのままphp
コマンドで常駐システムとして運用できるが、Ubuntuを再起動したときに自動スタートさせたり、コマンドからステータスを見る場合は、やはり正規のデーモンとして登録した方が良いだろう。
デーモンとして登録すれば、たとえ親プロセスが落ちたとしても自動で再起動させることもできる。
ところで、今更だが、デーモンとはなんなのだろうか。悪魔ではない。原義は守護神である。
Linux的な意味では常駐している(=ずっと起動しっぱなし)システムのことをそう呼び、デーモンとして登録するとその処理の起動・終了・状態監視などをLinuxがお世話してくれるという仕組みである。
実際に処理をするのはコンパイルされたJavaの実体であったりPythonであったりするわけだが、今回は邪道ながらそれがPHPに置き換わったわけだ。
さて、デーモンの登録方法には、System V init だ UpStart だ色々な方法があるが、今回は Systemd と呼ばれる新しめの方法を用いることにする。
この Systemd を扱うためには.serviceという拡張子の設定ファイルを/etc/systemd/system
に置く必要がある。
今回の設定ファイルはこんな感じだ。
[Unit]
Description=watchTest
[Service]
Type=forking
PIDFile=/run/watchTest.pid
ExecStart=/opt/watchTest.sh
Restart=always
[Install]
WantedBy=multi-user.target
詳しい説明はこちらのサイト様が詳しいが、特に着目すべきは[Service]
の部分で、
Type=forking
: フロントではなくプロセスを切り出してバックで実行するPIDFile=/run/watchTest.pid
: バックへ切り出されたプロセスをLinuxが追えるようにPIDファイル(プロセス番号が書かれたテキストファイル)のパスを設定ExecStart=/opt/watchTest.sh
: このデーモンが起動されたときに実行されたときに実行するコマンドを指定した.shファイルRestart=always
: プロセスが終了したときにいかなる時も再起動を試みる
という各設定部分である。ここはデーモンをどのように動作させるかによって大分変ってくるので適宜変更して欲しい。
今回のケースではこのファイルを/etc/systemd/system/watchTest.service
として保存し、更にExecStart=/opt/watchTest.sh
とあるように、/opt/watchTest.sh
へ以下の内容の.shファイルを保存しよう。
#!/bin/bash
cd /var/www/html/watchTest
nohup php index.php &
これにより、このデーモン起動時にこのコマンドが実行されるようになった。
いざデーモンを起動する際は、
sudo systemctl daemon-reload
を行ってから
sudo service watchTest start
と入力してみよう。
前章で php コマンドを打った時と同じく、処理が実行されるだろう。
まとめ
記事を書いておいてなんだが、邪道の極みたるPHPのデーモン化がLinuxを勉強するきっかけとなった筆者の知識もまた邪道な気がしてならない。
しかし今のところ仕事で作ったデーモンは今日も元気に監視を続けてくれているし、なによりPHPで作った資産を上手く使いまわせたので、コストは勉強時間以外あまり掛からなかったのは良かった点である。
また、今のところ興味の中心はフロントエンド~バックエンド間にあるが、邪道であれど今回の仕事でインフラ領域もまた楽しそうだと思ったのも事実だ。
普段は全然別の分野で活躍している人も、余っている古いマシンがあればLinuxをインストールして自宅で遊んでみると意外な所で役立つ日がくるかもしれない。