PHP で複数サイトの死活監視を自動化する(Bash スクリプトから移行)

はじめに

複数のクライアントサイトを保守していると、「サイトがダウンしていても気づくのが遅れた」という経験は一度はあるはずだ。理想はダウンを検知した瞬間に通知が来ること。

以前から Bash スクリプトで HTTP ステータスコードを定期チェックする死活監視を運用していた。しかし会社ごとに別スクリプトに分かれており、認証情報がコードに直書きされ、cron も2本走っている状態だった。

前回・前々回の記事で構築したディスク監視・SSL 証明書監視と同じサーバーに統合する機会に、全保守サイトを一括で監視する PHP システムに書き直した。今回はその実装を紹介する。


旧 Bash スクリプトの問題点

旧システムは monitor.sh という Bash スクリプトで、会社名を引数に渡して実行する形だった。

/bin/bash monitor.sh group-a    # グループA担当サイトをチェック
/bin/bash monitor.sh group-b    # グループB担当サイトをチェック

config_group-a.shconfig_group-b.sh の2ファイルに設定が分かれており、Chatwork の API トークンや通知先ルーム ID がそれぞれ直書きされていた。cron も2本で、毎時5分と毎時15分にずらして実行していた。

主な問題を整理すると以下のとおり。

問題点内容
設定の分散会社別に2ファイル・2 cron
認証情報の直書きAPI トークンがスクリプト内に記述
通知ロジックの複雑さ時間帯制限(08:00〜22:00)を Bash で実装
サマリーが毎時正常時でも毎時通知が届く

「時間帯制限」は「夜中に通知で起こされたくない」という要件だったが、cron スケジュールで制御するほうがシンプルだと判断し、PHP 版では廃止した。


設計方針

新システムの設計で決めたことは以下の3点。

  1. 全サイトを1つのスクリプトで管理。会社別の分割をやめ、config.php の配列に全 URL を列挙する
  2. 通知を2つのフェーズに分ける。毎時チェック(異常時のみアラート)と日次サマリー(常に送信)を別スクリプトに分離する
  3. 通知グループで送信先を制御。どの URL の通知を誰に送るかを notification_groups で設定し、スクリプト本体は変更不要にする

実装

ファイル構成

ディスク監視・SSL 証明書監視と同じ monitor/ ディレクトリ以下に配置し、共有ライブラリを使う構成にした。

manage/
├── lib/                      ← 共有ライブラリ(全モジュール共用)
│   ├── Env.php               # .env ローダー
│   └── Notifier.php          # 通知クラス(メール / Chatwork / Slack / Discord)
└── site_monitor/
    ├── .env                  # 認証情報(Git 管理外)
    ├── config.php            # 監視サイト・チェック設定・通知グループ
    ├── site_check.php        # 毎時実行:異常時のみアラート
    ├── site_report.php       # 日次実行:全サイトサマリー
    ├── deploy.sh             # デプロイスクリプト
    └── lib/
        ├── SiteChecker.php   # HTTP チェック・リトライ
        └── SiteCsvLogger.php # CSV 記録

SiteChecker — curl でチェック・リトライあり

HTTP ステータスコードを curl で取得するシンプルなクラス。OK とみなすのは 200301302 の3つ。

class SiteChecker
{
    private const OK_CODES = [200, 301, 302];

    public function __construct(
        private int $timeout       = 30,
        private int $retryCount    = 2,
        private int $retryInterval = 5
    ) {}

    public function check(string $url): array
    {
        $code = $this->fetch($url);
        if ($this->isOk($code)) return $this->result($url, $code, 'ok');

        for ($i = 0; $i < $this->retryCount; $i++) {
            sleep($this->retryInterval);
            $code = $this->fetch($url);
            if ($this->isOk($code)) return $this->result($url, $code, 'ok');
        }

        return $this->result($url, $code, $code === 0 ? 'error' : 'down');
    }

    private function fetch(string $url): int
    {
        $ch = curl_init($url);
        curl_setopt_array($ch, [
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_FOLLOWLOCATION => false,   // リダイレクト先はチェックしない
            CURLOPT_TIMEOUT        => $this->timeout,
            CURLOPT_USERAGENT      => 'SiteMonitor/1.0',
            CURLOPT_SSL_VERIFYPEER => false,   // SSL エラーは ssl_monitor に委任
            CURLOPT_SSL_VERIFYHOST => false,
        ]);
        curl_exec($ch);
        $code = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
        curl_close($ch);
        return $code;
    }
}

CURLOPT_FOLLOWLOCATION => false にしている点がポイントだ。リダイレクト設定の問題でループが発生したり、最終的な転送先のステータスコードと元の URL のステータスが混在したりするケースを避けるため、301302 はそのまま OK と判定している。

一時的なネットワーク障害でアラートが誤発砲しないよう、失敗時は5秒待って2回リトライする。3回連続で失敗した場合のみ異常と判断する。

ステータスの定義は以下。

status意味
okHTTP 200 / 301 / 302
downその他の HTTP コード(4xx / 5xx など)
errorcurl 接続失敗(タイムアウト・DNS 解決失敗など)

SiteCsvLogger — サイトごとに日別 CSV で保存

チェック結果を CSV ファイルに追記する。ファイル名はホスト名+パスのスラグと日付を組み合わせ、サイトごと・日付ごとにファイルを分割する。

data/
├── example-a_com_20260424.csv
├── example-b_jp_20260424.csv
└── example-b_jp_navi_20260424.csv   ← パスが含まれる URL はパスも含む

CSV の各行。

datetime,url,label,status_code,status
"2026-04-24 09:05:00",https://example-a.com/,サンプルサイトA,200,ok
"2026-04-24 09:05:00",https://example-b.jp/,サンプルサイトB,503,down

config.php — 監視対象と通知グループ

'sites' => [
    ['url' => 'https://example-a.com/',         'label' => 'サンプルサイトA'],
    ['url' => 'https://example-b.jp/',          'label' => 'サンプルサイトB'],
    ['url' => 'https://example-b.jp/navi/',     'label' => 'サンプルサイトB ナビ'],
    // ...
],

'checker' => [
    'timeout'        => 30,
    'retry_count'    => 2,
    'retry_interval' => 5,
],

通知グループ:送信先・チャンネルの分離

旧 Bash では会社ごとに Chatwork ルームが異なっていた。PHP 版でもこの構成を維持し、notification_groups で管理する。

さらに、日次サマリー用チャンネルchannels)と異常アラート用チャンネルalert_channels)を同じグループ内で分けた。旧 Bash では通常通知ルームとアラートルームが別だったためだ。

'notification_groups' => [
    [
        'label' => 'グループA',
        'urls'  => [
            'https://example-a.com/',
            'https://example-a.jp/',
        ],
        'channels' => [          // site_report.php(日次サマリー)で使用
            'chatwork' => ['enabled' => true, 'room_id' => Env::get('NOTIFY_A_CHATWORK_ROOM_ID')],
            'slack'    => ['enabled' => true, 'webhook_url' => Env::get('NOTIFY_A_SLACK_WEBHOOK_URL')],
            'email'    => ['enabled' => true, 'to' => 'summary@example.com', ...],
        ],
        'alert_channels' => [    // site_check.php(毎時アラート)で使用
            'chatwork' => ['enabled' => true, 'room_id' => Env::get('NOTIFY_A_ALERT_CHATWORK_ROOM_ID')],
            'slack'    => ['enabled' => true, 'webhook_url' => Env::get('NOTIFY_A_SLACK_WEBHOOK_URL')],
            'email'    => ['enabled' => true, 'to' => 'alert@example.com', ...],
        ],
    ],
    [
        'label' => 'グループB',
        'urls'  => [
            'https://example-b.com/',
            // ...
        ],
        'channels'       => [ /* グループB のサマリー通知先 */ ],
        'alert_channels' => [ /* グループB のアラート通知先 */ ],
    ],
],

グループを追加・変更しても config.php だけを修正すればよく、スクリプト本体には触れない。


site_check.php — 毎時チェック・異常時のみ通知

全サイトをチェックし、down または error のサイトが1件でもあれば alert_channels へ送信する。全サイト正常の場合はサイレント。

foreach ($config['notification_groups'] as $group) {
    $issues = [];
    foreach ($results as $url => $data) {
        if (!urlInGroup($url, $group['urls'])) continue;
        if (in_array($data['result']['status'], ['down', 'error'])) {
            $issues[$url] = $data;
        }
    }
    if (empty($issues)) continue;   // 正常ならスキップ

    // alert_channels がなければ channels にフォールバック
    $channels = $group['alert_channels'] ?? $group['channels'];
    (new Notifier($channels))->send('【サイト監視異常通知】異常が検知されました', buildAlertBody($issues));
}

異常時の通知フォーマットは旧 Bash に合わせた。

件名: 【サイト監視異常通知】異常が検知されました

【サイト監視異常通知】

日時: 2026-04-24 09:05:00

異常検知されたサイト:

https://example-b.jp/
  状態: ダウンしています
  ステータスコード: 503

site_report.php — 日次サマリー・常に送信

毎朝9時に全サイトのチェックを実行し、結果を channels へ送信する。問題の有無に関わらず常に送信するため、「問題がないこと」の確認にもなる。ステータスの深刻度順(down → error → ok)でソートし、問題のあるサイトが先頭に並ぶ。

件名: 【サイト監視サマリー】全サイトの状態レポート

【サイト監視結果サマリー】

日時: 2026-04-24 09:05:00

監視結果:

https://example-b.jp/
  状態: ダウンしています
  ステータスコード: 503


EXAMPLE-A
EXAMPLE-A is a dynamic web application crafted with Next.js, a React-based framework, designed to provide users with eff...
状態: 正常に稼働中 ステータスコード: 200
https://example-a.jp/
状態: 正常に稼働中 ステータスコード: 301

異常があった場合は件名に *** 異常あり *** が付く。


旧 Bash との比較

項目旧 Bash新 PHP
スクリプト数会社別 2 スクリプト全サイト共通 1 セット
cron 数2本(毎時5分・毎時15分)2本(毎時5分・毎朝9時)
認証情報の管理コード直書き.env ファイル
サマリー送信毎時(時間帯制限あり)日次(cron スケジュールで制御)
通知チャンネルメール・Chatwork・Slackメール・Chatwork・Slack(同等)
通知グループ会社ごとに別スクリプトnotification_groups で一元管理
サイト追加2ファイルを修正config.php の1配列に追記

cron が2本のままなのは変わらないが、役割が「毎時チェック(アラート)」と「日次サマリー」に整理されたことで、通知が届いたときの意味が明確になった。


デプロイと cron 登録

デプロイは rsync ベースの deploy.sh で行う。.envdata/・ログは転送対象から除外され、サーバー側のファイルが保持される。

# 転送内容の確認
./deploy.sh

# 本番デプロイ
./deploy.sh --deploy

デプロイ後、リモートで PHP の構文チェックが自動実行される。

cron は XServer API で登録する。

# 毎時5分:死活チェック(異常時のみ通知)
curl -s -X POST \
  -H "Authorization: Bearer xs_xxxxxxxxxx" \
  -H "Content-Type: application/json" \
  -d '{
    "minute": "5", "hour": "*", "day": "*", "month": "*", "weekday": "*",
    "command": "/opt/php-8.3/bin/php $HOME/shell/monitor/site_monitor/site_check.php",
    "comment": "サイト死活監視:毎時チェック(毎時5分)"
  }' \
  "https://api.xserver.ne.jp/v1/server/your-server.xsrv.jp/cron"

# 毎朝9時:日次サマリー(常に送信)
curl -s -X POST \
  -H "Authorization: Bearer xs_xxxxxxxxxx" \
  -H "Content-Type: application/json" \
  -d '{
    "minute": "0", "hour": "9", "day": "*", "month": "*", "weekday": "*",
    "command": "/opt/php-8.3/bin/php $HOME/shell/monitor/site_monitor/site_report.php",
    "comment": "サイト死活監視:日次レポート(毎朝9時)"
  }' \
  "https://api.xserver.ne.jp/v1/server/your-server.xsrv.jp/cron"

旧 Bash の cron を削除する際も同じ API の DELETE エンドポイントを使えばよい。cron ID は GET で一覧取得できる。


まとめ

今回構築したシステムの要点を整理する。

項目採用した方法
HTTP チェック方式curl(CURLOPT_FOLLOWLOCATION => false
OK 判定HTTP 200 / 301 / 302
障害の誤検知対策5秒待ちリトライ × 2回
SSL エラーの扱いSiteChecker では無視(ssl_monitor に委任)
ログ形式サイトごと・日付ごとの CSV ファイル
通知フェーズ毎時アラート(異常時のみ)+日次サマリー(常時)
通知グループchannels(サマリー用)と alert_channels(アラート用)を分離
共有ライブラリEnv.php / Notifier.php をディスク監視・SSL 監視と共用
cron 管理XServer API 経由で登録・削除

ディスク監視・SSL 証明書監視と合わせて、3つの監視モジュールが /home/shell/monitor/ 以下に統合された。共有ライブラリを更新するとすべてのモジュールに反映されるため、通知ロジックの改善も1か所で済む。

shell/monitor/
├── lib/               ← Env.php / Notifier.php(3モジュール共用)
├── xserver_monitor/   ← ディスク使用量監視
├── ssl_monitor/       ← SSL 証明書監視
└── site_monitor/      ← サイト死活監視(今回)

サイトを追加する際は config.phpsites 配列に1行追記してデプロイするだけ。特定のグループにも通知したい場合は notification_groupsurls に追加する。運用コストを最小限に保ちながらカバレッジを広げていける構成になった。

コメント

タイトルとURLをコピーしました