🐍

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 行で済むようになった。

GitHubで編集を提案

Discussion