
最近、SSL証明書の期限切れによるサービス停止が話題になっていますね!どうやら、Pythonの標準ライブラリ+pyOpenSSLを使うと、複数ドメインの証明書期限を自動チェック・メール通知できるようです!
そこで今回は、Linux/Mac環境で「Python SSL証明書監視スクリプト」の実装と定期実行までを行ってみました!1ファイルで完結する本番運用レベルのコードですので、記事を読んで実際に動かしてみてください!
この記事で分かること
- socket+sslモジュールでSSL証明書の有効期限を取得する方法
- 複数ドメインを一括チェックし30日・7日・期限切れの3段階で判定するロジック
- smtplibとGmail SMTPでメール通知、cronで毎日自動実行する設定方法
完成ツールの概要と動作イメージ
このツールは、指定した複数のドメインに対してSSL証明書の有効期限を自動チェックし、期限に応じてメールまたはSlackで通知する1ファイル完結スクリプトだ。
完成ツールでできること
- 複数ドメインのSSL証明書期限を一括取得
- 残り30日・7日・期限切れの3段階で警告レベルを自動判定
- smtplib(Gmail SMTP)またはSlack Webhookでアラートを自動送信
- cronによる毎日定時の自動実行
- 実行ログのファイル出力
ここで、openssl コマンドで手動確認すればいいのでは?という疑問が出てくると思います。
大きな差としては、「複数ドメインの一括監視と自動通知」が可能ということです。これにより、証明書の期限切れを人間が気づく前に検知してアラートを上げることが出来ます。
今話題の、「インフラ監視の自動化(Observability as Code)」というやつですね!
手動確認との比較
| 項目 | openssl コマンド手動確認 | 本ツール(Python自動化) |
|---|---|---|
| 監視頻度 | 気づいたとき | 毎日自動 |
| 複数ドメイン対応 | 1件ずつ手動 | リストで一括 |
| 通知 | なし | メール・Slack自動送信 |
「インフラ自動化って難しそう…」と感じるかもしれませんが、この記事を読めばゼロから本番レベルの監視ツール完成まで解説する。
完成時のディレクトリ構成:
ssl_monitor/
├── cert_monitor.py # メインスクリプト(1ファイル完結)
├── domains.yaml # 監視対象ドメインリスト(オプション)
└── cert_monitor.log # 実行ログ(自動生成)
Step1〜5:段階的な実装手順
Step1:1ドメインの証明書期限を取得する(最小コード)
標準ライブラリの socket と ssl だけで証明書の notAfter フィールドを取得できる。外部ライブラリ不要なため、どの環境でも即動作する。
# ファイル名: step1_get_cert.py
import ssl
import socket
from datetime import datetime
def get_cert_expiry(hostname: str, port: int = 443) -> datetime:
ctx = ssl.create_default_context()
with socket.create_connection((hostname, port), timeout=10) as sock:
with ctx.wrap_socket(sock, server_hostname=hostname) as ssock:
cert = ssock.getpeercert()
expiry_str = cert["notAfter"] # 例: 'Sep 15 12:00:00 2025 GMT'
return datetime.strptime(expiry_str, "%b %d %H:%M:%S %Y %Z")
print(get_cert_expiry("example.com"))
実行コマンドと出力例:
$ python step1_get_cert.py
2025-09-15 12:00:00
getpeercert() が返す辞書の notAfter キーをパースするだけで期限日時が datetime オブジェクトとして得られる。
Step2:残り日数を計算して警告判定する
期限の絶対値より「あと何日か」が運用上重要なので、datetime.utcnow() との差分を計算して3段階に振り分ける設計にした。
# ファイル名: step2_check_days.py
from datetime import datetime, timezone
def classify_cert(expiry: datetime) -> tuple:
now = datetime.now(timezone.utc).replace(tzinfo=None)
days_left = (expiry - now).days
if days_left < 0:
status = "EXPIRED"
elif days_left <= 7:
status = "CRITICAL"
elif days_left <= 30:
status = "WARNING"
else:
status = "OK"
return days_left, status
# 使用例
expiry = datetime(2025, 7, 5, 12, 0, 0)
days, status = classify_cert(expiry)
print(f"残り {days} 日 → {status}")
出力例:
残り 6 日 → CRITICAL
Step3:複数ドメインを一括チェックするクラスに拡張
ドメインが増えるたびにコードを変えないよう、ドメインリストを外から渡せる CertificateMonitor クラスに分離することで保守性を高めた。
# ファイル名: step3_monitor_class.py
import ssl, socket
from datetime import datetime, timezone
from dataclasses import dataclass
@dataclass
class CertResult:
domain: str
days_left: int
status: str
expiry: datetime
error: str = ""
class CertificateMonitor:
def __init__(self, domains, port=443, timeout=10):
self.domains = domains
self.port = port
self.timeout = timeout
def check_one(self, hostname) -> CertResult:
try:
ctx = ssl.create_default_context()
with socket.create_connection((hostname, self.port), timeout=self.timeout) as sock:
with ctx.wrap_socket(sock, server_hostname=hostname) as ssock:
cert = ssock.getpeercert()
expiry = datetime.strptime(cert["notAfter"], "%b %d %H:%M:%S %Y %Z")
now = datetime.now(timezone.utc).replace(tzinfo=None)
days_left = (expiry - now).days
if days_left < 0:
status = "EXPIRED"
elif days_left <= 7:
status = "CRITICAL"
elif days_left <= 30:
status = "WARNING"
else:
status = "OK"
return CertResult(hostname, days_left, status, expiry)
except Exception as e:
return CertResult(hostname, -1, "ERROR", datetime.min, str(e))
def check_all(self):
return [self.check_one(d) for d in self.domains]
domains = ["example.com", "google.com", "github.com"]
monitor = CertificateMonitor(domains)
for result in monitor.check_all():
print(f"[{result.status:8}] {result.domain} — 残り {result.days_left} 日 (期限: {result.expiry:%Y-%m-%d})")
domains.yaml の例:
# ファイル名: domains.yaml
domains:
- example.com
- google.com
- github.com
出力例:
$ python step3_monitor_class.py
[OK ] example.com — 残り 82 日 (期限: 2025-09-15)
[OK ] google.com — 残り 74 日 (期限: 2025-09-07)
[WARNING ] github.com — 残り 22 日 (期限: 2025-07-22)
Step4:期限切れ前にメール通知する
認証情報をコードに直書きすると漏洩リスクがあるため、os.environ で環境変数から読み取る設計にした。Gmail は2段階認証+アプリパスワードが必須だ。
# ファイル名: step4_email_notify.py
import smtplib, os
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
def send_email_alert(results, to_addr: str):
alert_results = [r for r in results if r.status != "OK"]
if not alert_results:
return
smtp_host = "smtp.gmail.com"
smtp_port = 587
from_addr = os.environ["GMAIL_FROM"]
password = os.environ["GMAIL_APP_PW"]
body_lines = ["SSL証明書 期限アラート\n"]
for r in alert_results:
if r.error:
body_lines.append(f"[{r.status}] {r.domain} — 接続エラー: {r.error}")
else:
body_lines.append(f"[{r.status}] {r.domain} — 残り {r.days_left} 日 (期限: {r.expiry:%Y-%m-%d})")
msg = MIMEMultipart()
msg["Subject"] = f"[SSL監視] {len(alert_results)}件のアラート"
msg["From"] = from_addr
msg["To"] = to_addr
msg.attach(MIMEText("\n".join(body_lines), "plain", "utf-8"))
with smtplib.SMTP(smtp_host, smtp_port) as server:
server.starttls()
server.login(from_addr, password)
server.send_message(msg)
print(f"メール送信完了 → {to_addr}")
環境変数の設定方法(.bashrc または .zshrc に追記):
export GMAIL_FROM="[email protected]"
export GMAIL_APP_PW="xxxx xxxx xxxx xxxx"
⚠️ セキュリティ注意点:アプリパスワードはソースコードや Git には絶対にコミットしない。本番では python-dotenv や Vault、OS のキーチェーンを使うことを推奨する。
Step5:cronで毎日自動実行する
スクリプトを毎朝9時に自動実行し、実行ログをファイルに追記することで、通知漏れの原因調査ができる設計にした。
# crontab -e で以下を追加する
# 毎日 09:00 に実行しログを追記
0 9 * * * /usr/bin/python3 /home/user/ssl_monitor/cert_monitor.py >> /home/user/ssl_monitor/cert_monitor.log 2>&1
スクリプト側でのログ出力設定(cert_monitor.py 先頭に追加):
import logging
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(message)s",
handlers=[logging.StreamHandler()],
)
logger = logging.getLogger(__name__)
ログファイルの出力例:
2025-06-29 09:00:01 [INFO] [OK ] google.com — 残り 74 日
2025-06-29 09:00:02 [INFO] [WARNING ] github.com — 残り 22 日
2025-06-29 09:00:02 [INFO] メール送信完了 → [email protected]
発展:Slack Webhook通知への差し替え
smtplib の send_email_alert 関数を以下の send_slack_alert に差し替えるだけで、Slack への通知に切り替えられる。
Webhook URLは必ず環境変数で管理する。コードに直書きすると Slack チャンネルが外部から不正利用される可能性がある。
# slack_notify.py
import requests, os
def send_slack_alert(results):
alert_results = [r for r in results if r.status != "OK"]
if not alert_results:
return
webhook_url = os.environ["SLACK_WEBHOOK_URL"]
emoji_map = {"EXPIRED": "🔴", "CRITICAL": "🟠", "WARNING": "🟡", "ERROR": "⚫"}
lines = ["*SSL証明書 期限アラート*"]
for r in alert_results:
emoji = emoji_map.get(r.status, "⚪")
if r.error:
lines.append(f"{emoji} `{r.domain}` — 接続エラー: `{r.error}`")
else:
lines.append(f"{emoji} `{r.domain}` — 残り *{r.days_left}日* (期限: {r.expiry:%Y-%m-%d})")
payload = {"text": "\n".join(lines)}
response = requests.post(webhook_url, json=payload, timeout=10)
response.raise_for_status()
print("Slack通知完了")
Slack Webhook URLの取得手順:
- Slack の「Your Apps」→「Incoming Webhooks」→「Add New Webhook to Workspace」で発行
export SLACK_WEBHOOK_URL="https://hooks.slack.com/services/XXX/YYY/ZZZ"を環境変数に設定
完成版スクリプト全文
Step1〜5と発展のSlack通知を1ファイルに統合した完全版だ。実行時に引数で通知先(email / slack / both)を切り替えられる。
# ファイル名: cert_monitor.py
# 使い方: python cert_monitor.py [--notify email|slack|both]
# 依存: requests (Slack通知を使う場合のみ pip install requests)
# PyYAML (domains.yaml を使う場合のみ pip install pyyaml)
import ssl
import socket
import smtplib
import logging
import os
import argparse
from datetime import datetime, timezone
from dataclasses import dataclass
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(message)s",
handlers=[logging.StreamHandler()],
)
logger = logging.getLogger(__name__)
DEFAULT_DOMAINS = [
"example.com",
"google.com",
"github.com",
]
THRESHOLD_CRITICAL = 7
THRESHOLD_WARNING = 30
@dataclass
class CertResult:
domain: str
days_left: int
status: str
expiry: datetime
error: str = ""
class CertificateMonitor:
def __init__(self, domains, port=443, timeout=10):
self.domains = domains
self.port = port
self.timeout = timeout
def check_one(self, hostname) -> CertResult:
try:
ctx = ssl.create_default_context()
with socket.create_connection((hostname, self.port), timeout=self.timeout) as sock:
with ctx.wrap_socket(sock, server_hostname=hostname) as ssock:
cert = ssock.getpeercert()
expiry = datetime.strptime(cert["notAfter"], "%b %d %H:%M:%S %Y %Z")
now = datetime.now(timezone.utc).replace(tzinfo=None)
days_left = (expiry - now).days
if days_left < 0:
status = "EXPIRED"
elif days_left <= THRESHOLD_CRITICAL:
status = "CRITICAL"
elif days_left <= THRESHOLD_WARNING:
status = "WARNING"
else:
status = "OK"
logger.info("[%-8s] %s — 残り %d 日 (期限: %s)", status, hostname, days_left, expiry.strftime("%Y-%m-%d"))
return CertResult(hostname, days_left, status, expiry)
except Exception as e:
logger.error("[ERROR ] %s — %s", hostname, e)
return CertResult(hostname, -1, "ERROR", datetime.min, str(e))
def check_all(self):
return [self.check_one(d) for d in self.domains]
def send_email_alert(results, to_addr: str) -> None:
alert_results = [r for r in results if r.status != "OK"]
if not alert_results:
logger.info("アラート対象なし。メール送信スキップ。")
return
from_addr = os.environ["GMAIL_FROM"]
password = os.environ["GMAIL_APP_PW"]
body_lines = ["SSL証明書 期限アラート\n"]
for r in alert_results:
if r.error:
body_lines.append(f"[{r.status}] {r.domain} — 接続エラー: {r.error}")
else:
body_lines.append(f"[{r.status}] {r.domain} — 残り {r.days_left} 日 (期限: {r.expiry:%Y-%m-%d})")
msg = MIMEMultipart()
msg["Subject"] = f"[SSL監視] {len(alert_results)}件のアラート"
msg["From"] = from_addr
msg["To"] = to_addr
msg.attach(MIMEText("\n".join(body_lines), "plain", "utf-8"))
with smtplib.SMTP("smtp.gmail.com", 587) as server:
server.starttls()
server.login(from_addr, password)
server.send_message(msg)
logger.info("メール送信完了 → %s", to_addr)
def send_slack_alert(results) -> None:
try:
import requests
except ImportError:
logger.error("requests がインストールされていない: pip install requests")
return
alert_results = [r for r in results if r.status != "OK"]
if not alert_results:
logger.info("アラート対象なし。Slack送信スキップ。")
return
webhook_url = os.environ["SLACK_WEBHOOK_URL"]
emoji_map = {"EXPIRED": "🔴", "CRITICAL": "🟠", "WARNING": "🟡", "ERROR": "⚫"}
lines = ["*SSL証明書 期限アラート*"]
for r in alert_results:
emoji = emoji_map.get(r.status, "⚪")
if r.error:
lines.append(f"{emoji} `{r.domain}` — 接続エラー: `{r.error}`")
else:
lines.append(f"{emoji} `{r.domain}` — 残り *{r.days_left}日* (期限: {r.expiry:%Y-%m-%d})")
response = requests.post(webhook_url, json={"text": "\n".join(lines)}, timeout=10)
response.raise_for_status()
logger.info("Slack通知完了")
def load_domains():
yaml_path = os.path.join(os.path.dirname(__file__), "domains.yaml")
if os.path.exists(yaml_path):
try:
import yaml
with open(yaml_path) as f:
return yaml.safe_load(f)["domains"]
except Exception as e:
logger.warning("domains.yaml の読み込み失敗: %s。デフォルトリストを使用。", e)
return DEFAULT_DOMAINS
def main():
parser = argparse.ArgumentParser(description="SSL証明書有効期限監視ツール")
parser.add_argument("--notify", choices=["email", "slack", "both", "none"],
default="none", help="通知方法を選択")
parser.add_argument("--to", default="[email protected]",
help="メール通知先アドレス")
args = parser.parse_args()
domains = load_domains()
monitor = CertificateMonitor(domains)
results = monitor.check_all()
if args.notify in ("email", "both"):
send_email_alert(results, args.to)
if args.notify in ("slack", "both"):
send_slack_alert(results)
if __name__ == "__main__":
main()
動作確認コマンドと期待される出力:
# Slack通知・YAML使用時のみライブラリを追加インストールする
pip install requests pyyaml
# 通知なしで動作確認
python cert_monitor.py
# メールとSlack両方に通知する場合
export GMAIL_FROM="[email protected]"
export GMAIL_APP_PW="xxxx xxxx xxxx xxxx"
export SLACK_WEBHOOK_URL="https://hooks.slack.com/services/XXX/YYY/ZZZ"
python cert_monitor.py --notify both --to [email protected]
2025-06-29 09:00:01 [INFO] [OK ] example.com — 残り 82 日 (期限: 2025-09-15)
2025-06-29 09:00:02 [INFO] [OK ] google.com — 残り 74 日 (期限: 2025-09-07)
2025-06-29 09:00:02 [INFO] [WARNING ] github.com — 残り 22 日 (期限: 2025-07-21)
2025-06-29 09:00:03 [INFO] メール送信完了 → [email protected]
2025-06-29 09:00:03 [INFO] Slack通知完了
本番運用に耐えるSSL監視ツールの全体像
今回は、Python標準ライブラリのみでSSL証明書の有効期限を自動監視・通知するツール構築に取り組んだ。
openssl コマンドでの手動確認も便利だが、「cert_monitor.py」は更に便利で、複数ドメインの一括監視・3段階アラート・メール/Slack通知・cron定期実行がすべて1ファイルで完結するため、これを使わないのはもったいない!と感じた。
関連記事:Python OpenSSL完全ガイド 関連記事:Python SSL証明書エラー解決