RaspberryPi Pythonアプリケーションをシステムサービス化する

#### ラズパイ４のPythonアプリケーションをシステムサービス化するシェルスクリプトを作成する方法を紹介します。

以下、ラズパイ４は [Raspbery Pi 4 ModelB]、ラズパイゼロは [Raspberry Pi Zero WH] として話をすすめます。

ターゲットはラズパイですが、Linux PC (Ubuntuで確認) もそのまま使えます。

この記事では下記のような気象センサーのUDPパケットをモニターしCSVに出力するPythonアプリケーションをシステムサービス化します。

![udp_monitor_overview.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3583120/bf17ebcf-9167-2a45-a0ef-44728adcbe7d.jpeg)

### 参考書

**CentOS 7 システム管理ガイド systemd / NetworkManager Firewalld徹底攻略**  
【発行日】2015年11月 1日 第1版第1刷  
【ISBN】978-4-7980-4491-0 C3055  
【発行所】株式会社　秀和システム  
+ 発行日がだいぶ古くなっていますが、**掲載されている用例は最新のRaspberry pi OS でも問題なく動作します。**  
※説明もわかりやすく設定例も豊富なので本当に助かっています。
+ 実機にインストールする前に、開発PC(Ubuntu) で検証しています。  
※タイトルは「CentOS 7」ですが、**最近の Debian系は systemctlをサポートしています。**
 
![Books_CentOS7.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3583120/2885b9b0-795f-ea5e-dea2-737f865765a1.jpeg)


### 気象センサーのUDPパケット出力
+ ESP-WROOM-02 DIP化キット (ESP8266 Wi-Fi)
+ 10分間隔でUDPパケットのポート2222を内部ネットワークに向けてブロードキャストしている
+ データ形式: バイナリデータ (カンマ区切り) ```センサー名,外気温,室内気温,室内湿度,気圧```

気象センサーの詳細については下記 GitHubリポジトリでご覧になれます
[GitHub(pipito-yukio) ESP-WROOM-02 DIP化キットを使った気象センサーモジュール](https://pipito-yukio.github.io/home_weather_sensors/53_esp_wroom_02.html)

### インストーラースクリプトの実行環境

**OS: Debian GNU/Linux 11 (bullseye) 64bit**  
※今回紹介するPythonアプリは依存するライブラリないためラズパイゼロでも動作します
```bash
pi@raspi-4-dev:~ $ lsb_release -a
No LSB modules are available.
Distributor ID:	Debian
Description:	Debian GNU/Linux 11 (bullseye)
Release:	11
Codename:	bullseye
```
**OSインストールとアップデートが完了し追加のライブラリはインストールしていない事**  


### インストールするリソース一覧  

```
installer/
├── 1_inst_pythonapp.sh  # インストラー(シェルスクリプト)本体
├── bin
│     ├── pigpio                                   # Pythonアプリケーションパス
│     │     ├── UdpMonitorFromWeatherSensor.py     # UDPパケットモニターメイン
│     │     ├── conf
│     │     │     └── logconf_service_weather.json # ログ設定ファイル(JSON)
│     │     └── log                                # アプリケーションログモジュール
│     │         ├── __init__.py
│     │         └── logsetting.py
│     └── udp_monitor_from_weather_sensor.sh       # UDPパケットモニターシェルスクリプト
├── logs
│     └── pigpio                                   # UDPパケットモニターメインログ出力先
└── work
    ├── etc
    │     ├── default
    │     │     └── udp-weather-mon            # UDPパケットモニター用環境変数定義ファイル
    │     └── systemd
    │         └── system
    │             └── udp-weather-mon.service  # UDPパケットモニターサービスユニットファイル
    └── requirements.txt                       # pythonライブラリ一覧 ※今回は空
```

### 1. UDPパケットモニタ

#### 1-1. Pythonスクリプト  

**[ソースファイル] ```UdpMonitorFromWeatherSensor.py```**  
本当にシンプルですが出力するCSVは他の可視化アプリで利用可能です。  
(1) **システムサービスから起動される場合、環境変数はすべて文字列です。**  
　※1 ポート番号は数値ですがデフォルト値は文字列として定義します。
　※2 コマンドライン実行は数値でもOKですが、サービス起動の場合はエラーになります。 
(2) UDPパケットモニター中はファイルは開き放しです。  
　※ シグナルをモニタしシャットダウン時、ファイルとUDPソケットをクローズします。　　
(3) **UDPパケットモニターは無限ループでバケットを待ち受けています。**  

**シグナルのモニタについては下記 Qiita 投稿記事に詳しい説明があるのご覧ください。**  
[Raspberry Pi zero pythonでOSシャットダウン時にクリーンアップ処理を実行するには](https://qiita.com/pipito-yukio/items/19ee3fa8493c9cf2467a)

```python
import io
import logging
import os
import signal
import socket
from datetime import datetime
from typing import List, Optional, Tuple
from log import logsetting

"""
UDP packet Monitor from ESP Weather sensors With export CSV
[UDP port] 2222

For ubuntu permit 2222/udp
$ sudo firewall-cmd --add-port=2222/udp --permanent
success
"""

# args option default
# システムサービスで設定される環境変数は文字列なので、デフォルトが数値であっても文字列にする
WEATHER_UDP_PORT: str = os.environ.get("WEATHER_UDP_PORT", "2222")
CSV_OUTPUT_PATH: str =  os.environ.get("CSV_OUTPUT_PATH", "~/Documents/csv")
CSV_FILE: str = "udp_weather.csv"
CSV_HEADER: str = '"measurement_time","device_name","temp_out","temp_in","humid","pressure"\r\n'
BUFF_SIZE: int = 1024
isLogLevelDebug: bool = False


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


def cleanup():
    global csv_fp
    if csv_fp is not None:
        csv_fp.close()
    udp_client.close()


def loop(client: socket.socket, fp: io.TextIOWrapper):
    server_ip = ''
    data: bytes
    addr: str
    while True:
        data, addr = client.recvfrom(BUFF_SIZE)
        if server_ip != addr:
            server_ip = addr
            logger.info(f"server ip: {server_ip}")

        # from ESP output: device_name, temp_out, temp_in, humid, pressure
        line: str = data.decode("utf-8")
        record: List = line.split(",")
        # Insert weather DB with local time
        if isLogLevelDebug:
            logger.debug(line)
        # 到着時刻
        now_timestamp: datetime = datetime.now()
        s_timestamp: str = now_timestamp.strftime("%Y-%m-%d %H:%M:%S")
        line: str = f'"{s_timestamp}","{record[0]}",{record[1]},{record[2]},{record[3]},{record[4]}\r\n'
        fp.write(line)
        fp.flush()

if __name__ == '__main__':
    logger: logging.Logger = logsetting.create_logger("service_weather")
    isLogLevelDebug = logger.getEffectiveLevel() <= logging.DEBUG
    # サービス停止 | シャットダウンフック
    signal.signal(signal.SIGTERM, detect_signal)

    hostname: str = socket.gethostname()
    # Receive broadcast.
    # ポート番号は文字列なので整数に変換する
    broad_address: Tuple[str, int] = ("", int(WEATHER_UDP_PORT))
    logger.info(f"{hostname}: {broad_address}")
    # UDP client
    udp_client: socket.socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
    udp_client.bind(broad_address)

    # 出力先ディレクトリが存在しなければ作成する
    output_full_path: str = os.path.expanduser(CSV_OUTPUT_PATH)
    if not os.path.exists(output_full_path):
        os.makedirs(output_full_path)
    output_filepath: str = os.path.join(output_full_path, CSV_FILE)
    # CSVファイル TextIOオブジェクト定義
    csv_fp: Optional[io.TextIOWrapper] = None
    try:
        if os.path.exists(output_filepath):
            # 既存なら追記モード
            csv_fp = open(output_filepath, 'a', encoding="utf-8")
        else:
            # 新規作成なら書き込みモード
            csv_fp = open(output_filepath, 'w', encoding="utf-8")
            # ヘッダー追記
            csv_fp.write(CSV_HEADER)
            csv_fp.flush()

        # UDPモニターループ
        logger.info(f"type(csv_fp): {type(csv_fp)}")
        loop(udp_client, csv_fp)
    except KeyboardInterrupt:
        logger.info("KeyboardInterrupted!")
    finally:
        cleanup()
```

#### 1-2. シェルスクリプト  

**[ソースファイル] ```udp_monitor_from_weather_sensor.sh```**  
**Python仮想環境のpythonでpythonスクリプトファイルを実行します。**


```shell
#!/bin/bash

. ${HOME}/py_venv/raspi4_apps/bin/activate

python ${HOME}/bin/pigpio/UdpMonitorFromWeatherSensor.py

deactivate
```

### 2. アプリケーションサービスユニット定義

#### 2-1. UDPパケットモニター用環境設定ファイル

[ファイル] ```~/work/etc/default/udp-weather-mon```

pythonスクリプトで ```os.environ.get("WEATHER_UDP_PORT", "2222")``` のように参照します

```shell
WEATHER_UDP_PORT=2222
CSV_OUTPUT_PATH=~/datas/csv
```

#### 2-2. UDPパケットモニターサービスユニットファイル

[ファイル] ```~/work/etc/systemd/system/udp-weather-mon.service```  

**所有者 piユーザーで```udp_monitor_from_weather_sensor.sh```がメインプロセスで起動される**

```systemd
[Unit]
Description=UDPClient Weather data monitor service

[Service]
Type=simple
EnvironmentFile=/etc/default/udp-weather-mon
ExecStart=/home/pi/bin/udp_monitor_from_weather_sensor.sh
User=pi

[Install]
WantedBy=multi-user.target
```

### 3. インストーラーの作成   
[ファイル] ```1_inst_pythonapp.sh```

以下がUDPパケットモニターサービスユニットをシステムに設定するシェルスクリプトになります  
※エラー処理を入れています。処理結果がエラーなら処理を中止します。

(1) システムライブラリ python3-venvのインストール  
　※1 **root権限** (**sudo**) が必要になります  
　※2 最新のRaspberry Pi OS (Desktop) にはインストールされている場合があります  
(2) **Python仮想環境作成: ~/py_venv/raspi4_apps**   
　※1 通常は **pip** でアプリに必要なpythonライブラリをインストールします。
　※2 今回は標準の組み込み関数、パッケージしか使用しないのコメントアウトしています。

```shell
#!/bin/bash

# execute before export my_passwd=xxxxxx
date +"%Y-%m-%d %H:%M:%S >Script START"

# Install system libraries
# (1) python3-venv: make python virtual environment.
echo $my_passwd | { sudo --stdin apt-get -y update
   sudo apt-get -y install python3-venv
}
exit1=$?
echo "Install system libraries >> status=$exit1"
if [ $exit1 -ne 0 ]; then
   echo "Fail install system libraries!" 1>&2
   exit $exit1
fi

# Create Python virtual environment.
if [ ! -d "$HOME/py_venv" ]; then
   mkdir py_venv
fi

cd py_venv
python3 -m venv raspi4_apps
. raspi4_apps/bin/activate
# requirements.txt in xxxx libraries
# pip install -r ~/work/requirements.txt
exit1=$?
echo "Make python virtual environment raspi4_apps >> status=$exit1"
deactivate
if [ $exit1 -ne 0 ]; then
   echo "Fail make python virtual environment!" 1>&2
   exit $exit1
fi

cd ~/
```

(3) **ユーザーアプリケーションのサービスユニットをシステムに設定します**  
　※1 **root権限** (**sudo**) が必要になります  
　※2 環境設定ファイルとサービスユニットファイルをシステムディレクトリにコピー
　※3 **サービスユニットを有効にする**
 
```shell
# Enable my python application service
# UDP packet monitor servcie: udp-weather-mon.service
echo $my_passwd | { sudo --stdin cp ~/work/etc/default/udp-weather-mon /etc/default
  sudo cp ~/work/etc/systemd/system/udp-weather-mon.service /etc/systemd/system
  sudo systemctl enable udp-weather-mon.service
}
exit1=$?
```

(4) ユーザーアプリケーションシステムサービス化実行結果チェック  
　※A **正常終了ならリブート**: **root権限** (**sudo**) が必要
　※B エラーならメッセージを表示して終了

```shell
date +"%Y-%m-%d %H:%M:%S >Script END"
if [ $exit1 -ne 0 ]; then
   echo "Fail import all csv!" 1>&2
   exit $exit1
else
   echo "Done"
   echo "rebooting."
   echo $my_passwd |sudo --stdin reboot
fi
```

### 4. インストーラーアーカイブ作成と展開

#### 4-1. インストーラーアーカイブ作成

開発PC(Linux)で tar アーカイブを作成し、ラズパイ４ (試験機) にコピーします

```bash
$ tar czf ../python_udp_monitor.tar.gz 1_inst_pythonapp.sh bin/ logs/ work/
$ cd ..
$ scp python_udp_monitor.tar.gz pi@raspi-4-dev:~/
```

#### 4-2. インストーラーアーカイブ解凍

ラズパイ４(開発機)にログインしてインストール作業を行います

```bash
yukio@Dell-T7500:~$ ssh pi@raspi-4-dev
Linux raspi-4-dev 6.1.21-v8+ #1642 SMP PREEMPT Mon Apr  3 17:24:16 BST 2023 aarch64

The programs included with the Debian GNU/Linux system are free software;
the exact distribution terms for each program are described in the
individual files in /usr/share/doc/*/copyright.

Debian GNU/Linux comes with ABSOLUTELY NO WARRANTY, to the extent
permitted by applicable law.
Last login: Fri Nov 17 12:35:32 2023

pi@raspi-4-dev:~ $ ls -lrt --time-style long-iso
合計 36
drwxr-xr-x 2 pi pi 4096 2023-05-03 12:02 Bookshelf
drwxr-xr-x 2 pi pi 4096 2023-09-04 14:59 Desktop
drwxr-xr-x 2 pi pi 4096 2023-09-04 14:59 Videos
drwxr-xr-x 2 pi pi 4096 2023-09-04 14:59 Downloads
drwxr-xr-x 2 pi pi 4096 2023-09-04 14:59 Documents
drwxr-xr-x 2 pi pi 4096 2023-09-04 15:17 Templates
drwxr-xr-x 2 pi pi 4096 2023-09-04 15:17 Music
drwxr-xr-x 2 pi pi 4096 2023-09-04 15:17 Pictures
-rw-r--r-- 1 pi pi 3855 2023-11-18 14:52 python_udp_monitor.tar.gz
```

アーカイブ ```python_udp_monitor.tar.gz``` を解凍します  
※既存のディレクリは割愛しています

```bash
pi@raspi-4-dev:~ $ tar xzf python_udp_monitor.tar.gz 
pi@raspi-4-dev:~ $ ls -lrt --time-style long-iso
合計 52
#...既存のディレクリ省略...
drwxr-xr-x 3 pi pi 4096 2023-11-18 11:07 logs
drwxr-xr-x 3 pi pi 4096 2023-11-18 11:13 work
drwxr-xr-x 3 pi pi 4096 2023-11-18 11:41 bin
-rwxr-xr-x 1 pi pi 1363 2023-11-18 13:12 1_inst_pythonapp.sh
-rw-r--r-- 1 pi pi 3855 2023-11-18 14:52 python_udp_monitor.tar.gz
```

### 5. インストール実行

#### 5-1. root権限の環境変数を設定

このスクリプトはroot権限が必要な処理が有るので実行前に下記の環境変数を設定します。  
**[環境変数] ```my_passwd```**

```bash
pi@raspi-4-dev:~ $ export my_passwd=your_raspi_password
pi@raspi-4-dev:~ $ ./1_inst_pythonapp.sh 2>&1 | tee ~/work/1_inst_pythonapp.log
```

#### 5-2. インストール実行ログ

※システムアップデートとライブラリイントールログは割愛しています

```bash
2023-11-18 14:54:46 >Script START
#...システムアップデートとライブラリインストールログは省略...
python3-venv はすでに最新バージョン (3.9.2-3) です。
python3-venv は手動でインストールしたと設定されました。
以下のパッケージが自動でインストールされましたが、もう必要とされていません:
  libfuse2
これを削除するには 'sudo apt autoremove' を利用してください。
アップグレード: 0 個、新規インストール: 0 個、削除: 0 個、保留: 57 個。
Install system libraries >> status=0
Make python virtual environment raspi4_apps >> status=0
Created symlink /etc/systemd/system/multi-user.target.wants/udp-weather-mon.service → /etc/systemd/system/udp-weather-mon.service.
2023-11-18 14:55:35 >Script END
Done
rebooting.
Connection to raspi-4-dev closed by remote host.
Connection to raspi-4-dev closed.
```

### 6. アプリケーションサービス稼働確認

#### 6-1. アプリケーションサービスの稼働確認  

[システムコマンド] ```systemctl status udp-weather-mon.service```  
※ステータス確認は **sudo** 不要です

```bash
pi@raspi-4-dev:~ $ systemctl status udp-weather-mon.service
● udp-weather-mon.service - UDPClient Weather data monitor service
     Loaded: loaded (/etc/systemd/system/udp-weather-mon.service; enabled; vendor preset: enabled)
     Active: active (running) since Sat 2023-11-18 15:06:26 JST; 4s ago
   Main PID: 921 (udp_monitor_fro)
      Tasks: 2 (limit: 3933)
        CPU: 143ms
     CGroup: /system.slice/udp-weather-mon.service
             ├─921 /bin/bash /home/pi/bin/udp_monitor_from_weather_sensor.sh
             └─922 python /home/pi/bin/pigpio/UdpMonitorFromWeatherSensor.py

11月 18 15:06:26 raspi-4-dev systemd[1]: Started UDPClient Weather data monitor service.
```

piユーザ権限でメインプロセスで起動されていることがわかります

```bash
pi@raspi-4-dev:~ $ ps aux | grep udp_monitor_from_weather_sensor | grep -v grep
pi           921  0.0  0.0   4728  2852 ?        Ss   15:06   0:00 /bin/bash /home/pi/bin/udp_monitor_from_weather_sensor.sh
```

#### 6-2. Pythonアプリケーションのログ確認  

ポートをリッスンし、ファイルをオーブンしたログまで出力しています

```bash
pi@raspi-4-dev:~/logs/pigpio $ cat service_udpmon_weather_202311181506.log
2023-11-18 15:06:26 INFO UdpMonitorFromWeatherSensor.py(82)[<module>] raspi-4-dev: ('', 2222)
2023-11-18 15:06:26 INFO UdpMonitorFromWeatherSensor.py(106)[<module>] type(csv_fp): <class '_io.TextIOWrapper'>
```

#### 6-3. CSVファイルの確認

(1) サービスユニットの環境変数のディレクトリ ```~/datas/csv``` に移動  
(2) CSVファイルにモニターした結果が出力されているか確認します

```bash
pi@raspi-4-dev:~/datas/csv $ ls -l
合計 4
-rw-r--r-- 1 pi pi 74 11月 18 15:06 udp_weather.csv
# tail -f で出力をモニター
pi@raspi-4-dev:~/datas/csv $ tail -f udp_weather.csv 
"measurement_time","device_name","temp_out","temp_in","humid","pressure"
"2023-11-18 15:15:55","esp8266_1",11.3,18.1,60.6,987.6
"2023-11-18 15:25:40","esp8266_1",10.7,18.1,60.1,987.6
"2023-11-18 15:35:24","esp8266_1",10.4,18.1,59.2,987.5
"2023-11-18 15:45:08","esp8266_1",10.2,18.0,58.1,987.6
"2023-11-18 15:54:53","esp8266_1",10.0,18.0,57.8,987.8
"2023-11-18 16:04:37","esp8266_1",9.8,17.9,57.5,987.7
"2023-11-18 16:14:21","esp8266_1",9.6,17.9,57.1,987.8
```

### 7. 結論

アプリケーションサービスの設定はコマンドラインから直接設定可能ですが、アプリケーションのインストールにはシステムライブラリのインストール、pythonライブラリのインストールなども必要になります。

手作業ですべて設定していたら、インストールが途中で失敗すると再実行も大変です。

インストーラースクリプトを作成すると複数台へのインストールも容易になり、他のシステム構築用インストーラーのテンプレートにもなります。

**本記事で紹介したソースコードは下記GitHubリポジトリで公開しております。**
[GitHub(pipito-yukio) qiita-posts: python/udp_monitor](https://github.com/pipito-yukio/qiita-posts/tree/main/python/udp_monitor)

#### 補足

**[訂正] Ubuntuの場合 firewall-cmdを使うには firewalld をインストールする必要がありました。**

```bash
$ sudo apt-get install firewalld
```

最近のLinux系PCではプライベートネットワークのUDPバケットもファイアウォールで遮断されるようになっています。
私の開発PC (Ubuntu22.04) はデフォルトで遮断されています。多分RedHat系も同じと思います。   
※Ubuntu 18.04の時は遮断されていませんでした。

**下記コマンドで ポート 2222/udp を開放できます。コマンドはRedHat系も同じです。**
```bash
# 一時的に開放する場合
$ sudo firewall-cmd --add-port=2222/udp
success
# 常に開放する場合: --permanent をつけます
$ sudo firewall-cmd --add-port=2222/udp --permanent
success
```

**ラズパイは何も設定していませんがプライベートネットワークのUDPバケットはそのまま受信できます。 IOT機器なので当然ですよね。**