systemd user で Python venv を呼ぶ書き方、4 通り試して残った 1 つ
結論を先に
[Service]
Type=oneshot
WorkingDirectory=/home/trader/myproject
ExecStart=/home/trader/myproject/.venv/bin/python3 tools/job.py
source .venv/bin/activate を systemd unit に書く必要はない。venv の python3 を直接指す。これだけ。
背景
Pi 5 (8GB) で個人プロジェクトを 4 つ並行で動かしていて、それぞれ requirements が違うので venv を分けている。
~/proj_a/.venv/
~/proj_b/.venv/
~/proj_c/.venv/
~/proj_d/.venv/
cron で動かしていた頃は wrapper script の頭で source .venv/bin/activate していた。systemd user timer に移行するとき、ExecStart= からどう venv を有効化するかで詰まったので記録しておく。
試した 4 つの書き方
1. ExecStart で bash -c "source ... && python3 ..."
[Service]
Type=oneshot
ExecStart=/bin/bash -c "source /home/trader/proj_a/.venv/bin/activate && python3 tools/job.py"
動く。が、WorkingDirectory= がないと相対パスでこける。あと bash の引用クォートが地味に間違いやすく、%H の expansion と組み合わさると沼。
実害として、ExecStart= 内のクォート (二重引用と &&) を取り違えて 30 分溶かした。source を文字列リテラルの中で見たくない、というのが本音。
2. ExecStartPre で source、ExecStart で python3
[Service]
Type=oneshot
ExecStartPre=/bin/bash -c "source /home/trader/proj_a/.venv/bin/activate"
ExecStart=/usr/bin/python3 tools/job.py
これは動かない。ExecStartPre は別シェルで実行されるので、そこで PATH を書き換えても ExecStart 側のプロセスには伝わらない。systemd を触り始めた頃に必ず一度はやる罠。
journalctl にエラーは出ない代わりに、システム側の /usr/bin/python3 が使われて ModuleNotFoundError が出る、という分かりにくい失敗をする。
3. EnvironmentFile= で PATH を上書き
[Service]
Type=oneshot
EnvironmentFile=/home/trader/proj_a/.venv-env
WorkingDirectory=/home/trader/proj_a
ExecStart=/usr/bin/env python3 tools/job.py
.venv-env の中身:
PATH=/home/trader/proj_a/.venv/bin:/usr/local/bin:/usr/bin:/bin
動く。が、PATH の手書きはメンテで事故る。venv の場所を引っ越したら全 unit の env file を書き直す必要があるし、/usr/local/bin を含めるか含めないかの判断が unit ごとに分散する。
4. venv の python を絶対パスで直接呼ぶ
[Service]
Type=oneshot
WorkingDirectory=/home/trader/proj_a
ExecStart=/home/trader/proj_a/.venv/bin/python3 tools/job.py
これが結論だった。理由は単純で:
-
source activateがやっているのは要するにPATHを書き換えてpython3が.venv/bin/python3を指すようにすること - それなら最初から
.venv/bin/python3を直接書けばいい - venv の python インタプリタを起動した時点で
sys.prefixが venv を指すので、import の解決は普通に動く -
which python3を呼ぶわけでもないのでPATHも不要
Python の venv モジュールの docs にもそれっぽいことが書いてある。要約すると「スクリプトを直接インタプリタで起動するなら activate は要らない」という話で、それはそう、という結論にしかならない。
venv が壊れているかを起動前に検出する
ついでに、venv 自体が壊れていたとき (pip キャッシュ破損、SD カードのビットエラー、シンボリックリンク切れなど) にも sane に落ちるよう ExecStartPre を入れている:
[Service]
Type=oneshot
WorkingDirectory=/home/trader/proj_a
ExecStartPre=/home/trader/proj_a/.venv/bin/python3 -c "import sys; sys.exit(0)"
ExecStart=/home/trader/proj_a/.venv/bin/python3 tools/job.py
ExecStartPre が失敗すれば ExecStart は実行されない。journalctl には Failed at step EXEC が残るので、後で _SYSTEMD_UNIT=... で絞れば一発で原因が分かる。
半年動かしていて 1 回だけ救われた。apt full-upgrade を走らせた直後に .venv/bin/python3 のシンボリックリンクが切れていたケースで、pip からの再構築 (python3 -m venv --upgrade .venv) で復旧した。
複数プロジェクトを並行運用するときに地味に効く
unit ファイル側で venv を絶対パス指定にしておくと、grep で「どの unit がどの venv に依存しているか」が即わかる。
$ grep -l 'proj_a/.venv' ~/.config/systemd/user/*.service
/home/trader/.config/systemd/user/proj_a-batch.service
/home/trader/.config/systemd/user/proj_a-report.service
逆に bash wrapper + source activate の構成だと「unit ファイル → wrapper script → 中で activate するパス」と 3 段階追わないと分からない。プロジェクト間でツールを引っ越したり venv を作り直したりするとき、unit ファイル単位で grep できる構造はそれだけで価値がある。
あと地味な利点として、systemd-analyze verify が unit ファイル単独で検証できる。wrapper script に処理を逃すと verify では検出できない問題 (シェル変数のタイポなど) が残る。
まとめ
-
ExecStart=/path/to/.venv/bin/python3 script.pyを直接書く -
source activateは対話シェル用。unit には要らない -
WorkingDirectory=を忘れずに - venv 壊れ検知の
ExecStartPreを 1 行入れておくと半年に 1 回助かる - 絶対パス指定にすると複数プロジェクト運用で
grepの効きがいい
長らく「activate しないと venv にならない」と思い込んでいて、unit ファイルが bash -c で無駄に膨らんでいた。Python の sys.path の解決を理解してから 1 行で済むようになった。
Discussion