Raspberry Pi zero pythonでOSシャットダウン時にクリーンアップ処理を実行するには

### Rasberry Pi(zero) においてPyhonアプリケーションでシャットダウンを検知し、リソースのクリーンアップを実行する方法について解説します。

パソコンの場合シャットダウンすると電源がOFFになりますが、ラズパイゼロではOSをシャットダウンしますが電源までは切ってはくれません。

電源が切れていないので赤枠でくくった7桁LEDはすべて点灯したままです。自ら電源OFFスイッチを押して消灯せますが。  
※LEDを点灯させているドライバーモジュールは電源を切っても数値を記憶しているので、初期化せずに再点灯すると前の値が表示されることになります。

この記事が説明しているシステムは下記の通りで Raspberry Pi (zero)です

![RaspiZero_pythn_signal_overview.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3583120/04270432-1c72-aef7-2e07-fd2745ec34af.jpeg)

上記システムの概要は下記GitHubリポジトリでご覧になれます
[GitHub(pipito-yukio) ラズベリーパイによる家庭用気象データ監視システム](https://github.com/pipito-yukio/home_weather_sensors "home_weather_sensors")

### 参考URL

+ [**Stack Exchange [ask Ubuntu]** How to detect Linux machine power off state using Python](https://askubuntu.com/questions/1035246/how-to-detect-linux-machine-power-off-state-using-python)  

回答のソースは以下のとおりです。質問の内容については上記サイトをご覧ください。
※この回答をそのまま参考にしています。

```python
import signal

signal.signal(signal.SIGINT, sigterm_handler)
signal.signal(signal.SIGTERM, sigterm_handler)
signal.signal(signal.SIGKILL, sigterm_handler)
```

+ [python 3.7: 18.8. signal --- 非同期イベントにハンドラを設定する](https://docs.python.org/ja/3.7/library/signal.html)  ※液晶モジュールのライブラリが 3.7 までしか対応していないので古いバーションのURLを紹介しています。バージョンによってフックできるシグナルが異なります。


### システムサービスとシャットダウン検知の実装

#### ラズパイゼロで稼働させているシステムサービス

+ UDPパケット(気象センサーモジュール)受信とDB登録サービス  
**※DB登録処理ではデータ登録ごとにDB接続をクローズしているのでクリーンアップは対象外**
+ 電源ボタン押下時にシャットダウン(poweroff)を実行するサービス
+ Flask Webアプリケーションサービス ※観測データのCSVダウンロード

#### UDPパケットモニターサービスのクリーンアップ

下記のクリーンアップ処理になります

+ LEDを点灯させているドライバーモジュールのクリーンアップ  
  ※ドライバーモジュールのメモリクリアと消灯
+ pigpio サービスのクローズ (**pigpid**)
+ UDPソケットのクローズ

```python
def cleanup():
    """ GPIO cleanup, and UDP client close. """
    if cb_brightness is not None:
        cb_brightness.cancel()
    if led_driver is not None:
        # Check i2c connect.
        if not has_led_i2c_error:
            led_driver.cleanup()
    if time_led_driver is not None:
        if not has_led_i2c_error:
            time_led_driver.cleanup()
    pi.stop()
    udp_client.close()
```


#### シグナルハンドラの定義

+ シグナルが **SIGTERM(下記)** ならリソースのクリーンアップ実行
    + **シャットダウン時 (poweroff / shutdown)**
    + **サービス停止時 (sudo systemctl stop xxxxxx.service)**

```python
def detect_signal(signum, frame):
    """
    Detect shutdown, and execute cleanup.
    :param signum: Signal number
    :param frame: frame
    """
    logger.info("signum: {}, frame: {}".format(signum, frame))
    if signum == signal.SIGTERM:
        # signal shutdown
        cleanup()
        # Current process terminate
        exit(0)
```


#### シャットダウンを検知するシグナルの設定

+ メイン処理の最初で検知するシグナルを設定します

```python
if __name__ == '__main__':
    pi = pigpio.pi()
    if not pi.connected:
        logger.warning("pigpiod not stated!")
        exit(1)

    # ...ログ設定などは割愛...
    signal.signal(signal.SIGTERM, detect_signal)
```

### 補足事項 (重要)

#### 参考にした実装で SIGINT と SIGKILL をトラップしなかった理由

+ **シグナル SIGINT(signum=2)**  
  **UDPモニターはシステムサービスなので契機(CTRL+C)がないため定義せず**
  ※ キーボードからCTRL+Cでプログラムを終わらせるとトラップされます。  

参考までに開発PC(Ubuntu)で実行したコンソール出力を示します  

```bash
(py37_pigpio) $ python LocalUdpMonitorFromWeatherSensor.py 
create_logger(app_name:local_weather)
logFile:/home/yukio/logs/pigpio/local_weather_202311160840.log
INFO Namespace(exec_raspi=False, system_service=False)
INFO enable_insert_db: False
INFO Dell-T7500: ('', 2222)
# CTRL+C
^CINFO signum: 2, frame: <frame at 0x56235f3fe720, file 'LocalUdpMonitorFromWeatherSensor.py', line 146, code loop>
```

+ (2) **シグナル SIGKILL (signum=9)**  
**実行時エラーになるため**  
※ **Ubuntu 22.04のPython は 3.10.xですが、ラズパイゼロは python 3.7.x のため開発PCにはソースコードからビルドした python3.7.xの仮想環境から実行しています。**

参考までに開発PC(Ubuntu)で実行したときのエラーを示します。 
※さすがにラズパイゼロの本番機では実行できないので、開発PCでの実行になります。
```bash
(py37_pigpio) $ python LocalUdpMonitorFromWeatherSensor.py 
create_logger(app_name:local_weather)
logFile:/home/yukio/logs/pigpio/local_weather_202311160838.log
INFO Namespace(exec_raspi=False, system_service=False)
INFO enable_insert_db: False
Traceback (most recent call last):
  File "LocalUdpMonitorFromWeatherSensor.py", line 217, in <module>
    signal.signal(signal.SIGKILL, detect_signal)
  File "/opt/python3.7.16/lib/python3.7/signal.py", line 47, in signal
    handler = _signal.signal(_enum_to_int(signalnum), _enum_to_int(handler))
OSError: [Errno 22] Invalid argument
```

+ **シグナル SIGTERM (signum=15)**  
 (1) **systemctlでサービスを停止したとき**
 (2) **シャットダウン時 (killコマンドでプロセスを終了させたときも同様)**

(1) ボタン開始サービスのステータスログを示します

```bash
pi@raspi-zero:~/logs/pigpio $ systemctl status button_start.service 
● button_start.service - Button start Specific application subprocess.
   Loaded: loaded (/etc/systemd/system/button_start.service; enabled; vendor preset: enabled)
   Active: active (running) since Sat 2023-10-07 12:29:26 JST; 1 months 7 days ago
 Main PID: 311 (buttons_startup)
    Tasks: 3 (limit: 877)
   CGroup: /system.slice/button_start.service
           ├─311 /bin/bash /home/pi/bin/buttons_startup.sh
           └─323 python /home/pi/bin/pigpio/ButtonStartServices.py

Warning: Journal has been rotated since unit was started. Log output is incomplete or unavailable.
```

ボタン開始サービスを停止前のログは以下のとおりです

```
pi@raspi-zero:~/logs/pigpio $ tail -f service_buttonstart_202310071229.log
2023-10-22 23:13:30 INFO ButtonStartServices.py(61)[change_actions] pin: 12, level: 1, tick: 2995727718
2023-10-22 23:13:30 INFO ButtonStartServices.py(78)[change_actions] Subprocess kill_display_process.sh thread start
2023-10-22 23:13:30 INFO ButtonStartServices.py(52)[subproc_start] Subprocess kill_display_process.sh start.
2023-10-22 23:13:31 INFO ButtonStartServices.py(54)[subproc_start] Subprocess kill_display_process.sh terminated: CompletedProcess(args=['/home/pi/bin/kill_display_process.sh'], returncode=0)
```

**ラズパイゼロで稼働中のボタンサービスを停止(stop)します**

```bash
pi@raspi-zero:~/logs/pigpio $ sudo systemctl stop button_start.service 
```

**サービス停止(stop)により、シグナルSIGTERM(15) がトラップされたことを示しています**

```
2023-11-14 14:09:50 INFO ButtonStartServices.py(35)[detect_signal] signum: 15, frame: <frame at 0xb685c7d8, file '/home/pi/bin/pigpio/ButtonStartServices.py', line 122, code <module>>
```

(2) 参考までに killコマンドでプロセスを終了させたときのログは以下のとおりです

※ 開発PC(Ubuntu)です
```bash
(py37_pigpio) $ python LocalUdpMonitorFromWeatherSensor.py 
create_logger(app_name:local_weather)
logFile:/home/yukio/logs/pigpio/local_weather_202311160850.log
INFO Namespace(exec_raspi=False, system_service=False)
INFO enable_insert_db: False
INFO Dell-T7500: ('', 2222)
```

プロセスを調べ killコマンドで終了  ※該当しないプロセスは割愛しました。

```bash
$ ps -aux | grep python
#...割愛...
yukio  27319  0.8  0.0  27184 15168 pts/0  S+ 08:50  0:00 python LocalUdpMonitorFromWeatherSensor.py
# kill コマンドでプロセスを終了させる
$ kill 27319
```

pythonアプリ側のログ出力

```bash
INFO signum: 15, frame: <frame at 0x563873a52720, file 'LocalUdpMonitorFromWeatherSensor.py', line 146, code loop>
```

#### シグナルSIGTERMを検知したらUDPソケットのクリーンアップ必要か

+ シャットダウンなOS自体が終了なので行儀は悪いが問題はないと思います。
+ サービスの停止ではUDPソケットのクローズは重要です。サービスを開始したときには新たなファイルディスクリプタ生成されるので、古いファイルディスクリプタは開いた状態で残ったままになります。

#### サービスを停止するシチュエーションはあるか？

運用中でも下記の理由によりサービスを停止する場合があります

+ システムサービスから起動されているPythonスクリプト、又は参照するモジュールにバグがあった場合  
※1 ソース入替え前に該当するソースに紐づくサービスを停止させます  
※2-1 停止後にソースを入れ替える  
※2-2 **参照しているモジュールのバグならモジュールのキャッシュ(\_\_pycache\_\_)を削除する**
※3 該当サービスを開始する
+ メンテナンスでSQLiteデータベースの古いデータをすべて削除する場合  
※1 SQLite3データベースはファイルベースなので参照しないような古いデータを整理する必要があります。
※2 UDPパケットモニタサービスを停止し、パケット到着によるデータ登録を防ぎます

#### 一般的なシステム(Linux)に応用する

考えられるのは下記のようなクリーンアップなどです

+ キャッシュメモリの内容をファイルなどに保存する
+ データベース接続を開いたままならクローズ

#### コード例で示したソースコードは下記GitHubにて公開しています

[GitHub(pipito-yukio) home_weather_sensors/raspi_zero/bin/pigpio](https://github.com/pipito-yukio/home_weather_sensors/tree/master/raspi_zero/bin/pigpio "home_weather_sensors/raspi_zero")

関係するソースは以下の通りです。

```
pigpio/
├── UDPClientFromWeatherSensor.py      # 記事で紹介したメインスクリプト
└── lib
    ├── ht16k33.py                     # 7セグLEDドライバーモジュールのベースクラス
    ├── led4digit7seg.py               # 7セグLED出力モジュール
    └── timeled7seg.py                 # 時刻表示用LEDドライバーモジュール
```