2026年7月20日

2026年7月20日

Pythonでシステム監視スクリプトを書く方法(psutilでCPU・メモリ・ディスク・プロセスを見張る)

Pythonでサーバーを監視するなら、psutil で CPU・メモリ・ディスク・プロセスの状態を取り出し、しきい値を「連続して超えたら通知」する形にして、systemd タイマーか cron で数分おきに動かすのが扱いやすい構成です。この記事では、WordPress を載せた Linux サーバーを例に、誤報の少ない監視スクリプトを一から組み立て、定期実行と動作確認までを説明します。

自作の監視スクリプトでできること・できないこと

自作スクリプトの強みは、見たい値と通知の条件を自分で決められることです。「ディスクが85%を超えた」「php-fpm が落ちている」といった、自分のサーバーで実際に困る事象だけに絞って見張れます。

一方で、サーバー自身の上で動くスクリプトは、サーバーが丸ごと止まったときやネットワークが切れたときには何も知らせてくれません。サイトの死活は外部から確かめる監視サービス(Uptime Kuma を別サーバーで動かす、外形監視サービスを使うなど)に任せ、この記事のスクリプトは「中で何が起きているか」を見る役割、と分担しておくのが現実的です。

準備:仮想環境に psutil を入れる

psutil はプロセスやシステムの情報を OS ごとの違いを吸収して取得できるライブラリです。OS 付属の Python に直接 pip で入れると、ディストリビューションのパッケージ管理とぶつかることがあるため、監視用の仮想環境を作って入れます。

sudo mkdir -p /opt/sysmon
sudo python3 -m venv /opt/sysmon/venv
sudo /opt/sysmon/venv/bin/pip install psutil
/opt/sysmon/venv/bin/python -c "import psutil; print(psutil.__version__)"

最後の行でバージョン番号が表示されれば準備完了です。Python 本体は、ディストリビューションが現在サポートしている 3 系を使ってください(2026年10月現在、サポート期間内の 3.11 以降を推奨します)。

取得する値と、その読み方

CPU使用率

psutil.cpu_percent(interval=1) は、1秒間待って、その間の CPU 使用率を返します。interval を省略すると「前回呼び出してからの値」になり、スクリプトの最初の1回目は意味のない 0.0 が返るので注意してください。一瞬の跳ね上がりは珍しくないため、1回の高い値だけで騒がない仕組みが後で効いてきます。

ロードアベレージ

os.getloadavg() は1分・5分・15分の平均負荷を返します。目安は CPU コア数(os.cpu_count())との比較で、コア数を継続的に上回るなら処理待ちが溜まっています。ディスク I/O 待ちでも上がるため、CPU 使用率が低いのにロードアベレージだけ高いときは I/O を疑います。

メモリ

psutil.virtual_memory() の percent は、利用可能なメモリ(available)から計算された使用率です。Linux は空きメモリをページキャッシュに使うため、free コマンドの「used」だけを見ると実際より逼迫して見えます。available が減り続けていないか、という見方が実態に近い判断になります。

ディスク容量と inode

psutil.disk_usage("/") で容量の使用率が取れます。見落としやすいのが inode で、小さなファイル(セッションファイルやキャッシュ)が大量にできると、容量に余裕があっても書き込めなくなります。psutil には inode の項目がないため、標準ライブラリの os.statvfs() で計算します。

プロセスの稼働

psutil.process_iter(["name"]) で動いているプロセスの名前を一覧できます。WordPress なら Web サーバー(nginx や Apache)、php-fpm、データベースが主な対象です。プロセス名は環境で異なり、php-fpm は「php-fpm8.3」のようにバージョン付きの名前になることがあり、MariaDB は 10.5 以降だと「mariadbd」、それ以前や MySQL では「mysqld」です。まず ps -eo comm | sort -u で自分のサーバーでの名前を確かめてから設定します。

監視スクリプト本体

以下を /opt/sysmon/monitor.py として保存します。しきい値の超過が3回続いたら通知し、下回ったら回復を通知します。通知先は Slack などの Incoming Webhook を想定し、URL は環境変数から読みます。

#!/usr/bin/env python3
"""サーバーの基本リソースを確認し、しきい値超えが続いたら通知する監視スクリプト(Linux用)"""
import json
import logging
import os
import sys
import urllib.request
from pathlib import Path

import psutil

THRESHOLDS = {
    "cpu": 90.0,     # CPU使用率(%)
    "memory": 90.0,  # メモリ使用率(%)
    "disk": 85.0,    # ディスク使用率(%)
    "inode": 90.0,   # inode使用率(%)
}
DISK_PATH = "/"
REQUIRED_PROCESSES = ["nginx", "php-fpm", "mariadbd"]  # 自分の環境の名前に合わせる
ALERT_AFTER = 3  # 連続何回の超過で通知するか
STATE_FILE = Path(os.environ.get("STATE_DIRECTORY", "/var/lib/sysmon")) / "state.json"
WEBHOOK_URL = os.environ.get("SYSMON_WEBHOOK_URL", "")

logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("sysmon")


def collect():
    st = os.statvfs(DISK_PATH)
    inode = (1 - st.f_ffree / st.f_files) * 100 if st.f_files else 0.0
    return {
        "cpu": psutil.cpu_percent(interval=1),
        "memory": psutil.virtual_memory().percent,
        "disk": psutil.disk_usage(DISK_PATH).percent,
        "inode": round(inode, 1),
        "load1": os.getloadavg()[0],
    }


def missing_processes():
    names = {p.info["name"] or "" for p in psutil.process_iter(["name"])}
    return [r for r in REQUIRED_PROCESSES if not any(r in n for n in names)]


def load_state():
    try:
        return json.loads(STATE_FILE.read_text())
    except (FileNotFoundError, json.JSONDecodeError):
        return {}


def save_state(state):
    STATE_FILE.parent.mkdir(parents=True, exist_ok=True)
    tmp = STATE_FILE.with_suffix(".tmp")
    tmp.write_text(json.dumps(state))
    tmp.replace(STATE_FILE)  # 書きかけのファイルを残さない


def notify(text):
    if not WEBHOOK_URL:
        log.warning("通知先が未設定のため送信しません: %s", text)
        return
    data = json.dumps({"text": text}).encode("utf-8")
    req = urllib.request.Request(WEBHOOK_URL, data=data,
                                 headers={"Content-Type": "application/json"})
    try:
        with urllib.request.urlopen(req, timeout=10) as res:
            res.read()
    except OSError as e:
        log.error("通知の送信に失敗しました: %s", e)


def main():
    metrics = collect()
    log.info("metrics %s", metrics)
    state = load_state()
    messages = []

    for key, limit in THRESHOLDS.items():
        count = state.get(key, 0)
        if metrics[key] >= limit:
            count += 1
            if count == ALERT_AFTER:
                messages.append(f"{key} が {metrics[key]:.1f}% (しきい値 {limit}%) を{ALERT_AFTER}回連続で超えています")
        else:
            if count >= ALERT_AFTER:
                messages.append(f"{key} が {metrics[key]:.1f}% に戻りました")
            count = 0
        state[key] = count

    missing = missing_processes()
    if missing != state.get("missing", []):
        if missing:
            messages.append("停止しているプロセス: " + ", ".join(missing))
        else:
            messages.append("監視対象のプロセスがすべて稼働中に戻りました")
    state["missing"] = missing

    save_state(state)
    host = os.uname().nodename
    for msg in messages:
        log.warning(msg)
        notify(f"[{host}] {msg}")
    return 0


if __name__ == "__main__":
    sys.exit(main())

process_iter() に取得する属性を渡すと、途中で消えたプロセスや権限のないプロセスで例外が出ないよう psutil 側が処理してくれます。自分で p.name() を呼ぶ書き方にすると、その例外処理を自分で書く必要があります。

誤報を減らす3つの工夫

監視で一番困るのは、通知が多すぎて誰も見なくなることです。上のスクリプトには次の工夫を入れています。

  • 連続回数で判定する:5分おきの実行で3回連続なら、約15分続いた異常だけが通知されます。バックアップやプラグイン更新での一時的な高負荷は拾いません。
  • 状態の変化だけを通知する:超過が続いている間は再通知せず、回復したときに1回だけ知らせます。プロセス停止も、停止した一覧が変わったときだけ送ります。
  • 状態ファイルを安全に書く:一時ファイルに書いてから replace() で置き換えるので、書き込み中に止まっても壊れた JSON が残りにくくなります。

しきい値は最初はゆるめに置き、1〜2週間ログを見て、平常時の値から余裕を持たせて調整するのが無理のない進め方です。

定期実行:systemd タイマーで動かす

systemd が使えるサーバーなら、cron より systemd タイマーのほうが実行ログを journalctl でまとめて追え、前回の実行がまだ終わっていないときに重ねて起動しない点でも扱いやすいです。まず root で動かさないよう専用ユーザーを作ります。

sudo useradd --system --no-create-home --shell /usr/sbin/nologin sysmon
sudo install -m 600 -o root -g root /dev/null /etc/sysmon.env
echo 'SYSMON_WEBHOOK_URL=your-webhook-url' | sudo tee /etc/sysmon.env > /dev/null

/etc/systemd/system/sysmon.service:

[Unit]
Description=Simple system monitor

[Service]
Type=oneshot
User=sysmon
EnvironmentFile=/etc/sysmon.env
StateDirectory=sysmon
ExecStart=/opt/sysmon/venv/bin/python /opt/sysmon/monitor.py

/etc/systemd/system/sysmon.timer:

[Unit]
Description=Run sysmon every 5 minutes

[Timer]
OnCalendar=*:0/5
AccuracySec=30s

[Install]
WantedBy=timers.target
sudo systemctl daemon-reload
sudo systemctl enable --now sysmon.timer
systemctl list-timers sysmon.timer
journalctl -u sysmon.service -n 20

StateDirectory=sysmon を書くと、systemd が /var/lib/sysmon を sysmon ユーザーの持ち物として作り、環境変数 STATE_DIRECTORY にその場所を渡します。スクリプトはこの変数を読むので、ディレクトリの権限で悩まずに済みます。

cron を使う場合は、cron の実行環境では環境変数がほぼ空になる点に注意してください。crontab 内で SYSMON_WEBHOOK_URL=... を定義するか、ラッパーのシェルスクリプトで読み込んでから実行します。状態ファイルの置き場所も、実行ユーザーが書き込めるディレクトリを用意しておきます。

動作確認のしかた

本番のしきい値のまま負荷をかけるより、確認用にしきい値を一時的に下げるほうが安全です。たとえば "disk": 1.0 にして手動で3回実行し、3回目に通知が届くか、元の値に戻して実行したら回復通知が届くかを見ます。

sudo systemctl start sysmon.service
journalctl -u sysmon.service -n 5

プロセス監視は、REQUIRED_PROCESSES に存在しない名前(例: "dummy-process")を一時的に足して、停止通知が出るかで確かめられます。本番の php-fpm やデータベースを止めて試すのは、サイトが止まるので避けてください。確認が終わったら設定を戻し、状態ファイル(/var/lib/sysmon/state.json)を削除してカウントをリセットしておきます。

つまずきやすい点

  • 通知が届かない:ログに「通知の送信に失敗しました」が出ていないか確認します。サーバーから外向きの HTTPS が制限されている環境もあります。
  • cpu が常に低い/高い:仮想サーバーでは、ほかの利用者の影響(steal)で体感と値がずれることがあります。psutil.cpu_times_percent(interval=1).steal を記録すると原因の切り分けに役立ちます。
  • プロセスが「停止」と誤判定される:名前の不一致がほとんどです。ps -eo comm で出る名前を部分一致で書いてください。
  • ログが増え続ける:journald の保存容量設定、または logrotate で上限を決めておきます。

まとめ

  • psutil と標準ライブラリで、CPU・ロードアベレージ・メモリ・ディスク容量と inode・プロセスの稼働を取得できる
  • 通知は「連続回数での判定」と「状態が変わったときだけ」に絞ると、誤報が減って監視が続けやすい
  • 定期実行は systemd タイマーが扱いやすく、専用ユーザーで動かし、Webhook URL は権限を絞った環境ファイルに置く
  • サーバー自身が止まった場合は検知できないので、外部からの死活監視と組み合わせる
  • 確認はしきい値を一時的に下げて行い、本番のサービスを止めて試さない

お気軽にご相談ください

お見積りへ お問い合わせへ
▲