サポートが期限切れになる前にSlackに通知を行うpythonスクリプトの作成

はじめに

最近、 https://endoflife.date という素晴らしいサイトを知り、それをどうにか活用できないかということで、期限切れになる前にSlackに通知を行うスクリプトを作成した。
Dockerを使用して、 cron で定期実行し、pythonスクリプトで通知という流れになる。

環境

Windows 10 Professional
Docker Desktop 4.17.0 (99724)

動作

まずは、どういう動作をするかを確認するために gif を用意した。

Slack通知

/image/tech/programming/python/eol-notify-python/slack_notify.gif

標準出力

/image/tech/programming/python/eol-notify-python/stdout.gif

準備

下記リポジトリにて作成した。
README.md に手順を記載しているので、その手順に従って構築する。
Repo: https://github.com/katsuobushiFPGA/eol-notify-python.git

また、 Slackに通知をする場合には Incoming webhooks のAppを登録しておく必要がある。
参考: https://api.slack.com/messaging/webhooks

手順

1. このリポジトリをcloneします。

git clone https://github.com/katsuobushiFPGA/eol-notify-python.git

2. envの設定

cp .env.example .env

.env に下記を設定する。

  • WEB_HOOK_URL : Slackの通知URL
  • NOTIFICATION_PRODUCTS : 製品を指定する
  • NOTIFICATION_PRODUCTS_VERSION : 製品とバージョンを指定する
  • NOTIFICATION_BEFORE_DEADLINE_DAYS : サポート期限 ○日前に通知を行う日を設定する

例:

# SlackのwebhookURL
WEB_HOOK_URL=https://XXXXXXX

# 各製品のすべての情報を通知
NOTIFICATION_PRODUCTS='php mysql Apache AlmaLinux'

# PHP 8.0, MySQL8.0, Apache2.4, AlmaLinux8 の製品とバージョンの組み合わせについてEOLを通知
NOTIFICATION_PRODUCTS_VERSION='php=8.0 mysql=8.0 Apache=2.4 AlmaLinux=8'

# 10日前、20日前、30日前、40日前、50日前に通知
NOTIFICATION_BEFORE_DEADLINE_DAYS='10 20 30 40 50'

3. crontab.txtの設定

デフォルトでは下記のようになっています。 引数に notify_version を入れることで、製品とバージョンの組み合わせの通知を行うことができます。

0 7 * * * echo "Current date is `date`" > /var/src/app/check.log 2>&1
0 7 * * * cd /var/src/app && /usr/local/bin/python app.py notify_version >> /var/src/app/cron.log 2>&1
  1. イメージをビルドし、立ち上げます。
docker compose up -d

解説 & 構成

python コンテナのみの構成となっている。
これは実際に python が入っているコンテナになる。

cron を動作させる必要があるため、 supervisord を入れている。
※入れなくても、 python スクリプトは常時起動するものではないので、 cron コンテナ+python の実行環境という位置づけでもよかった。
しかし、 拡張性や勉強の観点からsupervisordを使用した。

Dockerの解説

作成した Dockerfileの中身を見ていく。

FROM python:3.11.2
USER root

python3.11.2 を使用する。 (現時点での最新)

以降は コメントにセクションを記載しているのでその単位で見ていく。

# Language
RUN apt-get update \
&& apt-get -y install locales \
&& localedef -f UTF-8 -i ja_JP ja_JP.UTF-8 \
&&  apt-get clean \
&&  rm -rf /var/lib/apt/lists/*
ENV LANG ja_JP.UTF-8
ENV LANGUAGE ja_JP:ja
ENV LC_ALL ja_JP.UTF-8
ENV TERM xterm

こちらは、言語を日本語にするためにパッケージを入れている。

# Locale
RUN apt-get install -y --no-install-recommends tzdata && \
    ln -sf /usr/share/zoneinfo/Asia/Tokyo /etc/localtime && \
    apt-get clean && \
    rm -rf /var/lib/apt/lists/*
ENV TZ Asia/Tokyo

お次は、タイムゾーンの設定、これを実施しておくことでJSTで実行できる。

# supervisor
RUN apt-get update \
&&  apt-get install -y supervisor \
&&  apt-get clean \
&&  rm -rf /var/lib/apt/lists/*
COPY supervisor/supervisord.conf /etc/supervisor/

supervisord はフォアグラウンドのプロセスを管理してくれて、1コンテナに複数のプロセスが稼働できるようになる。

# cron
RUN apt-get update \
&&  apt-get install -y cron \
&&  apt-get clean \
&&  rm -rf /var/lib/apt/lists/*
COPY --chown=root:root ./crontab/crontab.txt /etc/cron.d/cron
RUN chmod 0644 /etc/cron.d/*
RUN crontab /etc/cron.d/cron

定期実行してくれる cron
crontab.txt に内容を入力しておいて、これを crontab コマンドでインストールする。
root にインストールしておかないと実行権限の問題などで実行できない場合があるので注意。
crontab.txt に書いてある内容は一度は手動で実行して動作を試した方が良い。

# python library
RUN apt-get update \
&&  apt-get install -y vim less \
&&  apt-get clean \
&&  rm -rf /var/lib/apt/lists/*
RUN pip install --upgrade pip
RUN pip install --upgrade setuptools
RUN pip install slack_bolt
RUN pip install python-dotenv
RUN pip install requests
RUN pip install pandas
RUN pip install tabulate

これは python のライブラリ郡. 多分 requirements.txt とかに書いたほうが良かったなあと思いつつ直していない。

# CMD [ "tail", "-f", "/dev/null"]
CMD ["/usr/bin/supervisord", "-c", "/etc/supervisor/supervisord.conf"]

最後はエントリーポイント。
このプロセスが死んだらコンテナも落ちるやつ。
python 単体で cronsupervisordを入れてなかったときは、 tail -f /dev/null で フォアグラウンドの tail プロセスを起動していたが、その必要もなくなったのでコメントアウトした。
最近、こういう裏技的なやつを知ったので覚えておきたい…。

Pythonスクリプトの解説

構成は下記のような感じ。
ほとんど書いていないので調べながら作った。

app.py    - バッチの本体
config.py - 環境変数をインポートするためのファイル

まずは、 config.py から見る。

from dotenv import load_dotenv
load_dotenv()

# 環境変数を参照
import os
WEB_HOOK_URL = os.getenv('WEB_HOOK_URL')

NOTIFICATION_PRODUCTS = []
if len(os.getenv('NOTIFICATION_PRODUCTS')) > 0:
    NOTIFICATION_PRODUCTS = os.getenv('NOTIFICATION_PRODUCTS').split()

NOTIFICATION_PRODUCTS_VERSION = {}
if len(os.getenv('NOTIFICATION_PRODUCTS_VERSION')) > 0 :
    npvstr = os.getenv('NOTIFICATION_PRODUCTS_VERSION')
    NOTIFICATION_PRODUCTS_VERSION = dict(item.split('=') for item in npvstr.split())

NOTIFICATION_BEFORE_DEADLINE_DAYS = []
if len(os.getenv('NOTIFICATION_BEFORE_DEADLINE_DAYS')) > 0 :
    NOTIFICATION_BEFORE_DEADLINE_DAYS = list(map(int, os.getenv('NOTIFICATION_BEFORE_DEADLINE_DAYS').split())) 
    NOTIFICATION_BEFORE_DEADLINE_DAYS.sort()

こんな感じ、 .env の中身にある定数を pythonで使いやすい形に変える処理をしている。
このファイルをインポートすることで、これらの定数を使えるようにする。

app.py

#!/usr/bin/env python3
import config
import requests
import pandas
from http import HTTPStatus
import json
import sys
from datetime import datetime, timedelta

WEB_HOOK_URL = config.WEB_HOOK_URL
EOL_API_BASE_URL  = 'https://endoflife.date/api/'

NOTIFICATION_PRODUCTS = config.NOTIFICATION_PRODUCTS
NOTIFICATION_PRODUCTS_VERSION = config.NOTIFICATION_PRODUCTS_VERSION
NOTIFICATION_BEFORE_DEADLINE_DAYS = config.NOTIFICATION_BEFORE_DEADLINE_DAYS

def send_slack(text):
    headers = {
        'Accept': 'application/json',
    }
    response = requests.post(WEB_HOOK_URL, headers=headers, data= json.dumps({
        'text': text
    }))
    print(response.text)

def fetch_end_of_life_date(product, version = None):
    url = EOL_API_BASE_URL + product
    if version != None :
        url = url + '/' + version + '.json'
    else:
        url = url + '.json'
    response = requests.get(url)
    if response.status_code == HTTPStatus.OK:
        return response.text
    else:
        return None

def json_to_markdown_table(data, v):
    data = json.loads(data)
    if v != None:
      df = pandas.DataFrame(data, index=[0]).rename(columns={'support':'Support', 'eol':'EOL', 'latest':'Latest', 'latestReleaseDate':'latestReleaseDate', 'releaseDate': 'releaseDate', 'lts': 'LTS'})
    else:
      df = pandas.DataFrame(data).rename(columns={'support':'Support', 'eol':'EOL', 'latest':'Latest', 'latestReleaseDate':'latestReleaseDate', 'releaseDate': 'releaseDate', 'lts': 'LTS'})
    return df.to_markdown(index=False,tablefmt='fancy_grid')

def notify_product():
    for product in NOTIFICATION_PRODUCTS:
      res = fetch_end_of_life_date(product)
      if res != None:
        slack_text = product + '   \n'
        slack_text += json_to_markdown_table(res, None)
        print(slack_text)

def notify_product_version():
    for product,version in NOTIFICATION_PRODUCTS_VERSION.items():
      res = fetch_end_of_life_date(product, version)
      if res != None:
        slack_text = product + '   \n'
        slack_text += json_to_markdown_table(res, version)
        print(slack_text)

def notify_product_version_deadline_for_slack():
   for product,version in NOTIFICATION_PRODUCTS_VERSION.items():
       res = fetch_end_of_life_date(product, version)
       if res != None:
         product_json = json.loads(res)

         slack_notify = None
         eol = product_json["eol"]
         try:
           deadline_date = datetime.strptime(eol, '%Y-%m-%d')
         except:
            continue
         today = datetime.now()
         for day in NOTIFICATION_BEFORE_DEADLINE_DAYS:
            notify_date = deadline_date - timedelta(days=day)
            print('notify_date', notify_date)
            if today >= notify_date:
               slack_notify = {
                  'product': product,
                  'version': version,
                  'day': day,
                  'support_term': (deadline_date - datetime.now()).days
                }
               break
         if slack_notify != None and deadline_date >= today:
           slack_text = create_notify_message(product_json, slack_notify)
           send_slack(slack_text)

def create_notify_message(product_json, slack_notify):
    slack_text = '*****' + slack_notify["product"] +  '*****\n'
    slack_text += '【期限切れ】 ' + str(slack_notify["day"]) + ' 日前の通知です。\n'
    slack_text += '残りサポート期間:' + str(slack_notify["support_term"])  + '日です。\n'
    slack_text += slack_notify["product"] + slack_notify["version"] + 'は EOLが' + product_json["eol"] + 'です。\n'
    return slack_text

if __name__ == "__main__":
    if len(sys.argv) < 2:
      print("Error: argument is required.")
      sys.exit(1)
    exec_action = sys.argv[1]
    if exec_action == 'notify_version':
       notify_product_version_deadline_for_slack()
       notify_product_version()
    elif exec_action == 'notify_all':
       notify_product()

結構グチャッているのだが、簡単に説明すると、

def send_slack(text) - Slackに通知を送る関数引数に 送りたいテキストを渡す
def fetch_end_of_life_date(product, version = None) - endoflife.dateのAPIをコールする
def json_to_markdown_table(data, v) - endoflife.dateのjsonをマークダウン形式にする
def notify_product() - envに設定した製品を出力する
def notify_product_version() - envに設定した製品とバージョンの組み合わせについて出力する
def notify_product_version_deadline_for_slack()  - envに設定した製品とバージョンの組み合わせについてSlackに通知する
def create_notify_message(product_json, slack_notify) - 通知用のSlackテキストを構築する

以上である。

トラブルシューティング

プロセスが終了したよっていうログが出る

cron を実行できるところまでは確認できたが、どうやら ジョブが実行できていないようだった。
docker-compose logs -f でログを確認してみると、下記のようなログが 1分おき(cronのジョブの実行時間) に流れていた。

INFO reaped unknown pid 11 (exit status 0)

結論から話すと、 crontab.txt の改行コードが CR+LF だったことが問題だった。
それにしたってもっとわかりやすいログが欲しかった・・・。

試行錯誤していたときは、いつの間にか直っていたのでどこが悪いんだろうと思いながら、git の履歴から巻き戻して一つずつ修正を適用したりしていたのだが…。
改行コードのエラーは、bad interpreterみたいなの出ませんでしたっけ・・・!?
これで3時間使いました。。。

参考

おわりに

python スクリプトを書くのは久々だったけど便利なライブラリ多めで助かった・・・。
あとは、 cronDocker に入れるかどうかは悩んだ。
Windows だと入れないとどうにも定期実行するのは手間がかかるが、 Linux であればホストOSに cron が基本的に入っているからそれを使えば良いと考えていた。
まあでも、 Docker 環境を除去するときにホストOSでも削除しないといけない部分が出ると嫌だなあと思い、ホストOSの環境をできるだけ汚さないようにするのがいいのかなと思った。
Slack 通知も実は初めてで、 Slack_bot みたいなのもあるらしいけど、そっちではなく curl とかでリクエストしたら通知が入る簡単なやつにした。
次は bot の方を使ってみても良いなぁ。
趣味プロは時間かけても大丈夫で安心する。

Hugo で構築されています。
テーマ StackJimmy によって設計されています。