はじめに
最近、 https://endoflife.date という素晴らしいサイトを知り、それをどうにか活用できないかということで、期限切れになる前にSlackに通知を行うスクリプトを作成した。
Dockerを使用して、 cron
で定期実行し、pythonスクリプトで通知という流れになる。
環境
Windows 10 Professional
Docker Desktop 4.17.0 (99724)
動作
まずは、どういう動作をするかを確認するために gif
を用意した。
Slack通知
標準出力
準備
下記リポジトリにて作成した。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の通知URLNOTIFICATION_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
- イメージをビルドし、立ち上げます。
docker compose up -d
解説 & 構成
python
コンテナのみの構成となっている。
これは実際に python
が入っているコンテナになる。
cron
を動作させる必要があるため、 supervisord
を入れている。
※入れなくても、 python
スクリプトは常時起動するものではないので、 cron
コンテナ+python
の実行環境という位置づけでもよかった。
しかし、 拡張性や勉強の観点からsupervisord
を使用した。
Dockerの解説
作成した Dockerfile
の中身を見ていく。
FROM python:3.11.2
USER root
python
は 3.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
単体で cron
やsupervisord
を入れてなかったときは、 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時間使いました。。。
参考
- Sending messages using Incoming Webhooks - Slack
https://api.slack.com/messaging/webhooks - endoflife.date API
https://endoflife.date/docs/api - DockerでPython実行環境を作ってみる
https://qiita.com/jhorikawa_err/items/fb9c03c0982c29c5b6d5#dockerfile - 【OS 14種類】Docker imageごとのタイムゾーン設定方法まとめ
https://zenn.dev/k8shiro/articles/docker-image-timezone - Dockerコンテナのタイムゾーンを変更する
https://blog.hikaru.run/2022/07/24/change-timezone-in-docker-container.html - Dockerコンテナ内でcronが動かない時の対処
https://qiita.com/c3drive/items/d7249a18daa09bf7689b - start cron service with supervisor - ask Ubuntu
https://askubuntu.com/questions/907388/start-cron-service-with-supervisor - Run container background tasks with cron
https://github.com/binxio/blog-cron-supervisor-docker - crontabで、改行コードがCR+LFだと動かない模様
https://qiita.com/hiro0236/items/6bca0e9c20f6d9b579dd - cronが起動しないときの確認ポイントまとめ
https://www.koikikukan.com/archives/2012/10/30-015555.php - Dockerでsupervisorを使う時によくハマる点まとめ
https://techracho.bpsinc.jp/morimorihoge/2017_06_05/40936 - Running a Cronjob Inside Docker: A Beginner’s Guide
https://tecadmin.net/running-a-cronjob-inside-docker/
おわりに
python
スクリプトを書くのは久々だったけど便利なライブラリ多めで助かった・・・。
あとは、 cron
を Docker
に入れるかどうかは悩んだ。Windows
だと入れないとどうにも定期実行するのは手間がかかるが、 Linux
であればホストOSに cron
が基本的に入っているからそれを使えば良いと考えていた。
まあでも、 Docker
環境を除去するときにホストOSでも削除しないといけない部分が出ると嫌だなあと思い、ホストOSの環境をできるだけ汚さないようにするのがいいのかなと思った。Slack
通知も実は初めてで、 Slack_bot
みたいなのもあるらしいけど、そっちではなく curl
とかでリクエストしたら通知が入る簡単なやつにした。
次は bot
の方を使ってみても良いなぁ。
趣味プロは時間かけても大丈夫で安心する。