Dockerに Poetryを使って必要なPythonのパッケージをインストールして 環境を構築する方法について。
Poetry+Dockerのベストプラクティス
PoetryのDiscussionsで以下のような議論があります。
Document docker poetry best practices · python-poetry · Discussion #1879
4年くらい前に始まってまだcloseしてないもの。
何がベストか、定まってないのでdocument化もされてませんが、 見ていくと参考になるものがあります。
この辺も参考にしつつ、
- パッケージ群の管理としてのpoetryを使用する場合
- poetryで管理している特定のプロジェクトのpyproject.tomlを使った環境構築
の2つの場合でDockerfileなどを用意する方法について見てみます。
パッケージ管理として使う
特定のパッケージ用のpyproject.tomlとかではなくて 実際に何かしらを実行したい環境をPoetryを使って作りたい、という場合、 以下のようなファイル構成にします。
1 2 3 4 | |
pyproject.toml
pyproject.tomlは以下のような感じで。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 | |
pandasは依存パッケージのあるただの例です。
requirements.txtと同じようなものですが、
poetryの場合はpoetry.lockを使って依存関係を含めたものを作ることが簡単に出来るので
細かくバージョン管理したい場合には便利です。
poetry.lock
1
| |
でpyproject.tomlに書かれた依存関係を含めたpoetry.lockを作ります。
requirements.txtでpandasだけ書いて、
一旦空の仮想環境を作ってからpip install -r requirements.txtでインストールして
pip freeze > requirements.txtで依存関係を含めたリストを作れば同じようなことになりますが、
大変なので。
Dockerfile
このpython環境をDockerで作るためにpyproject.tomlとpoetry.lockのあるディレクトリに以下のようなDockerfileを用意します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 | |
- 2段階ビルド
builderとruntimeの2段階ビルドを行います。
builderでpoetryを使ってパッケージをインストールし、
runtimeでその仮想環境だけを持ってきて使うことで
最終的なイメージを小さくするためです。
ただ、今回は最初のbuild段階でも特にコンパイルなどは行わないので
runtimeで使うのと同じ小さいイメージを使ってますし
正直このレベルだとわざわざ分けてイメージをクリーンにしなくても良いレベルではあります。
もしくはpoetryなどインストールしたものをアンインストールしてしまえば
runtimeでやるのと同じサイズを作ることが出来ます。
ただ、slimバージョンだけではライブラリが足りなかったり
build時にはapt-getなどで何かしらパッケージをインストールして行う必要がある場合には
それら不要なものすべてをクリーンにするのは難しいので
このように2段階ビルドを行うことで簡単にクリーンなイメージを作ることが出来ます。
また、runtime側にCOPYするファイルをちょっと変えるだけ、等の場合には
builderのキャッシュが残っていればbuilderステージをスキップできるので
ビルド時間を短縮することが出来ます。
- イメージについて
元のイメージは python - Official Image の最新のDebianのイメージ。
今回は特にコンパイルとかはしないので
最小限にするためにslimバージョンを使っています。
alpineもありますが、ライブラリの違いなどで一般的なLinuxに比べるとパフォーマンスが悪かったりするので 1 Python公式イメージの中から選ぶならDebian系のslimなものを選んでおくのが無難です2。
- PIPの環境設定
PIP_NO_CACHE_DIR=1はキャッシュを使わない設定。
ここではpipでは直接的にはpoetryをインストールしているだけですが、
Dockerfileの中では基本的にpipで何度も同じパッケージをインストールすることは無いのでキャッシュは意味ないので無効にしています。
ただbuild側での話なのでキャッシュを作っても最終的なイメージには影響ありません。
途中段階のイメージがちょっとだけ軽くなる程度。
PIP_DISABLE_PIP_VERSION_CHECK=1はpipのバージョンチェックを無効にする設定。
チェックしてwarningを出されても無視するだけなので。
pipのバージョンアップがセンシティブな場合にはきちんとチェックするなりpip install -U pipを最初に必ず実行するなりしますが、現状そこまで必要ないと思います。
- Poetryのインストール
ここではpip install poetryでbuilderのグローバル環境に入れています。
poetryのバージョンもきちんと管理したい場合は
1
| |
のようにバージョン指定を。
また、もしそのイメージのグローバルのPython環境を直接使うような場合はpoetryの依存パッケージで汚さないように
1
| |
と直接いれることも出来ますが、今回はベースイメージでかつ使うのは仮想環境だけなのでよりシンプルにできるpipでグローバルに入れてます。
- Poetryの仮想環境作り
最初の
builderの方でpoetryを使ってパッケージインストールを行い、
その仮想環境部分(.venv)だけをruntime側に持ってきてその中のbinにPATHを通しています。
これでクリーンな最終的なイメージを作ることが出来ます。
仮想環境の作り方としては
POETRY_VIRTUALENVS_IN_PROJECT=1でカレントディレクトリ以下に仮想環境を作るようにして、
今回はもともとプロジェクトを管理しているものではなくdependenciesにあるパッケージをインストールしたいだけなので
インストールは--no-rootを使って依存パッケージだけをインストールするようにしています。
srcやプロジェクト名のディレクトリなどのソースディレクトリがなければ
warningが出るだけで結果的には変わりませんが、
間違って入れられてしまうものがあったりするのを防ぐためにも付けておくべきです。
Poetryで管理しているプロジェクトの環境構築
Poetryで管理しているプロジェクトをそのままDockerで使いたい場合、
poetry installで作った仮想環境はそのまま使えません。
poetry installでは--no-rootを付けずに行った場合、
プロジェクトのルートディレクトリのsrcまたはプロジェクト名のディレクトリがプロジェクト名のパッケージとしてインストールされますが、
インストールされるのはソースコードのディレクトリへのパスを書いた.pthファイルだけなので(pip install -e <package path>の状態)、
それが入った仮想環境ディレクトリだけをコピーしてもコードが見つかりません。
Dockerイメージの中でPythonを起動したらそのプロジェクトがパッケージとして認識されているような状態にするため、以下のようにします。
ファイルの構成としては以下のような状態を想定します。
1 2 3 4 5 6 7 8 9 | |
src
srcディレクトリにはプロジェクトのソースコードが入っているとします。
1 2 3 | |
1 2 | |
1 2 3 4 5 6 7 8 9 | |
こんな感じで、Hello, World!を出力するプロジェクトを想定します。
mainの方はcliとして使うことを想定しています。
pyproject.toml
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 | |
先ほどとnameを変えてpandasを消してますが、
加えてtool.poetry.scriptsでhelloというコマンドをmyproject:mainとして登録しています。
これで環境が正しくインストールされていればhelloコマンドが使えるようになります。
poetry.lock
先ほど同様poetry lockで作ります。
Dockerfile
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 | |
- 2段階ビルド
runtime側は先ほどと全く同じです。
一方builder側はだいぶ変わっています。
- poetry-plugin-export
まず、poetry-plugin-exportをインストールして
poetry exportでrequirements.txtを作ります。
poetry exportはpoetryのサブコマンドで、
現在はpoetryをインストールすれば使えますが、
1 2 3 | |
といった警告が出るようになっています。
poetry-plugin-exportをインストールすれば依存関係でpoetryもインストールされます。
- コピーするファイル
今回はpyproject.tomlとpoetry.lockに加えてsrcディレクトリもをコピーします。
- poetry export
poetry exportでrequirements.txtを作りますが、
--without-hashesでハッシュを含めないようにしています。
次に./を追加していますが、
poetryでルートディレクトリでpip install .とすれば
プロジェクト自体も含めてpyproject.tomlに書かれた依存関係を含めたパッケージがインストールされます。
そのままだと各パッケージは^1.0.0など範囲を指定してあるだけのものだったりするものもあるのでpoetry.lockの内容を含めることで毎回同じ環境を作ることが出来ます。
./にはハッシュ情報を付けてない状態で
requirements.txtを使ったpip installでは
ハッシュを持ったものとそうでないものが混在するとエラーになるので
この方法だと--without-hashesは必須です。
- 仮想環境の作り方
今回はpoetryではなく、venvで仮想環境を作ります。
poetryで/appにIN_PROJECTな状態で作るのと同じ名前になるように
1
| |
と仮想環境ディレクトリを指定して作成し、 activateしてからrequirements.txtを使ってインストールします。
あとは出来た仮想環境ディレクトリだけをruntime側に持ってきて使います。
これでDockerの中でpythonを実行すればimport myprojectが出来ますし、
コマンドラインからhelloというコマンドを打てばHello, World!と出力される状態になります。
その他気になることなど
プロジェクトをそのまま使う場合、ハッシュも含めてrequirements.txtを作る
PyPIではパッケージの同じバージョンを再生成することは出来ないので ハッシュを含めずとも基本的にはバージョンの指定だけで全く同じ環境を作ることが出来ます(アーキテクチャなどが同じ環境であるならば)。
ただPyPI以外の場所からインストールすることがあったり、より明確にハッシュを含めたい場合、以下のようにすると良いかな、と。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 | |
ここでは最初の例のようにpoetryで仮想環境を作っています。
途中まではsrcをコピーすること以外は一緒で
最後にプロジェクトをbuildしてwheelファイルを作り、
それをpip installでインストールしています。
依存パッケージは既にpoetry install --no-rootでインストールしてあるので
--no-depsを付けてmyprojectに関するものだけをインストールしています。
最初のものとの互換性を考えるとこちらの方がわかりやすいのと 依存パッケージのハッシュもきちんと管理してるので良いかもしれません。
まあ、好きな方で良いかと。
Poetryの環境変数設定
上で使っているものはPOETRY_VIRTUALENVS_IN_PROJECTだけですが、
poetryを使った環境構築の例を見ると他にもいくつかよく使われているものがあります。
Poetry - Python dependency management and packaging made easy
ただ、なんとなく、コピペで、で使われているだけのものもあるみたいです3
ちょうどこれを書いている時こんな議論が出てきてました。
it is not a thing, this is cargo-culting
実際自分もPOETRY_NO_INTERACTIONをきちんと確認しようとしてドキュメントに無いな、と思いながら探してたらこれを見つけたわけですが、
実際、POETRY_NO_INTERACTIONは存在しないようです。
poetryコマンド自体には--no-interaction (-n)というオプションがありますが、これはグローバルオプションでどのサブコマンドに対しても使えるものになっています。
なので
1
| |
と、インタラクティブなものがない場合でも使えて当然同じ結果になります。
よくある例のDockerfileで使うのは基本的にpoetry installだけですが、
そもそもpoetry installでインタラクティブなものに出くわしたことが無いので意味があるかはわかりません。
(もしかしたら知らないだけであるかもしれませんが。。。)
あるもの例としてはpoetry initがあります。
なので、これをPOETRY_NO_INTERACTIONを使って試してみます。
以下のテストはpoetry 1.7.1で行っています。
何も指定しない場合は
1 2 3 4 5 | |
のようにインタラクティブな作業が始まります。
次に--no-interactionを付けると
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 | |
といった感じに適当な初期値を使った状態でpyproject.tomlが作られます。
最後にPOETRY_NO_INTERACTIONを使ってみます。
1 2 3 4 5 | |
と、POETRY_NO_INTERACTIONを使ってもインタラクティブな作業が始まります。
というわけで、 it is not a thing, this is cargo-culting.
PYTHONの環境変数設定
同様にPYTHONの環境変数で使われているものも不要だろうと思うものがあります。
- PYTHONDONTWRITEBYTECODE=1
これは設定すると__pycache__というディレクトリの中に作るpycといったバイトコードを書かないようになります。
バイトコードはモジュールがimportされた時に作られるもので、 次回以降のimport時にはそれを使うことで高速になります。
Document docker poetry best practices · python-poetry · Discussion #1879
これの中でも質問されてますが、buildの段階でこれは本当に必要なのか、と。
実際にはbuild時には関係ありません。
関係ない、というか、これを指定してあってもパッケージをインストールするとバイトコードは生成されます。
仮にこれをDockerfileの中で指定するとしたら runtime側で使うのであればまだわかります。
複数ファイルからなるスクリプトをコピーして入れておいて
メインのファイルから他のファイルをimportするようなことをする場合には
バイトコードが生成されるので、この場合
PYTHONDONTWRITEBYTECODE=1を使ってバイトコードを書かないようにする、ということは意味があるかもしれません。
基本的にdocker runしたらそれで終わりなのでそこで作られたバイトコードは2度と使われることはないので。
ただ、上のように作った環境でやりたいことは通常は何らかインストールされたコマンドを実行したり、 1つのpythonスクリプトを実行することだと思うのでその際には いずれにしろバイトコードは生成されません。
というわけで、PYTHONDONTWRITEBYTECODE=1はきちんと作用はしますが、
少なくともbuild過程で使うことは意味がないし、
runtimeで使う場合にも意味がある場合は限られています。
でも上の議論の中では
It still generates the .pyc files but it won't write them to disk. Python will generate them on the fly every time you start your application.
A few places where I have seen it:
https://sourcery.ai/blog/python-docker/
https://testdriven.io/blog/dockerizing-django-with-postgres-gunicorn-and-nginx/
で議論が終わってしまっていて、 確かに参考先でも同じように使ってますが、ですが、確かめてはない模様。
実際、
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 | |
という感じに生成されます。
一方、
1 2 | |
を作ってこの環境下でimportを試してみると
1 2 3 4 5 6 7 8 9 10 11 12 13 14 | |
といった感じでPYTHONDONTWRITEBYTECODE=1を使うとバイトコードの生成を制御することが出来ていることがわかります。
なのでおそらく最初は1段階のruntimeのみなDockerfileで使われている例があり、 それをそのままコピペして最初に書いただけ、が始まりで そのまま広く使われてだけだと思います。
ただあまりに多くの場所で当たり前のように使われていて、 上の議論でも質問があったにもかかわらず(しかも一回食い下がったのに)一蹴されて終わっているのでこれだけ試してみた今でもなにかあるんじゃないかと思ってしまいます。。。
- PYTHONUNBUFFERED=1
これも良くあるもので、Pythonの標準出力をバッファリングしないようにするものです。
ただこれもbuildの段階で使うことは意味がないし、 逆に必要であればきちんとruntimeで指定しなくてはいけません。
これを指定するのはdocker runしたときに、何らかの理由でコンテナが落ちてしまった際、バッファリングされた出力が処理されないまま消えてしまうことを防ぐためです。
ただ、現在(python 3.7以降)では通常のテキストレイヤー部はバッファリングされないようになっているので、 画像を出力するなどの特殊な場合を除いては必要ないです。
-u
Force the stdout and stderr streams to be unbuffered. This option has no effect on the stdin stream.See also PYTHONUNBUFFERED.
Changed in version 3.7: The text layer of the stdout and stderr streams now is unbuffered.
いずれにしろこれもbuildでは意味ないし、 おそらく昔作られたなにかの1段階ビルドのDockerfileのものをコピペして使われているだけだと思います。
特にこちらに関してはやりたいことは確実にruntime側での話なので ちょっと影響が出るレベルにもなってくるかと思います。 (それともなんらかbuildで設定したENVが引き継がれるとかある?少なくともいくつか試した環境のいずれでも引き継がれるようなことはありませんでしたが。)
PIPの環境変数設定
PIPの環境設定の
PIP_NO_CACHE_DIR=1はbuildステージで実際に効果はありますが、
これに関しては設定値がちょっと厄介です。
もともとこの値は0, false, off, noが設定されているとキャッシュが無効になるようになっていました。
ただ、NO_CACHE_DIRという名前がキャッシュディレクトリを作らないなので、
これをNOにするということは逆にキャッシュを作る、ということになります。
というわけで理由がわからない、という理由で、1, true, on, yesと指定するとキャッシュを無効化するようにしたいということでそれらを有効にしましたが、
後方互換性を保つために0などを設定してもキャッシュが無効になるようになっています。
より正確にはdistutils.util.strtoboolが解釈できる文字ならばキャッシュが無効になるようになっています。
(上記リストに加えてy, t, n, fでも可。)
なので
PIP_NO_CACHE_DIR=onPIP_NO_CACHE_DIR=off
は正反対のように見えますが、実際にはどちらもキャッシュを無効にします。
PIP_NO_CACHE_DIR=0PIP_NO_CACHE_DIR=1
も。
かといって、たまにある、何でも良いから変数が空文字以外でとして定義されていれば効果を発揮するというわけでもなく、
上位以外の値をいれるとstrtoboolが解釈出来ずにエラーになります。
onでもoffでも同じならそうだろう、経験ある人ほどそう予測しがちだと思いますが。
この変更はpipの19.0から入っています。
これより前は上のコードの変更を見るとcallbackを使わずにstore_falseになっています。
store_false, store_trueに設定されているオプションは環境変数やconfigでの設定では
値をstrtoboolで判定し、その結果がTrueならそれぞれFalse, True、Falseなら逆にTrue, Falseを
設定するようになっています。
pip/src/pip/_internal/cli/parser.py at 76554a48eec96273717fbf87eb6e4a12374ddf4d · pypa/pip
PIP_DISABLE_PIP_VERSION_CHECKの方はstore_trueのオプションになっていて、
1やonなどを設定するとpipのバージョンチェックを無効化し、0やoffなど、または何も設定しなければ有効になります。
pip/src/pip/_internal/cli/cmdoptions.py at 76554a48eec96273717fbf87eb6e4a12374ddf4d · pypa/pip
OSのパッケージ管理
今回使っているDebianのイメージでは
aptやapt-getコマンドでパッケージを管理するシステムになっています。
今回は特に追加のパッケージをインストールしていないのでapt-getは使っていませんが、
よく使うので一応追加で。
builderではgitを使い、runtimeではcurlが必要だとする場合、
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 | |
こんな感じになります。
builderの方は無理にクリーンにしなくても良いということでapt-get cleanとかも実行しません。
runtime側ではapt-get cleanに加えてrm -rf /var/lib/apt/lists/*も実行しています。
これはapt-get updateで/var/lib/apt/listsにキャッシュが作られるのでそれを削除しています。
また、aptコマンドが導入されて以降、コマンドラインではapt-getとかではなくapt(apt getなど)を使うことが推奨されていますが、
ユーザーは インタラクティブ 用途には apt(8) コマンドを使うことが推奨されますし、シェルスクリプト中ではapt-get(8) や apt-cache(8) コマンドを使うことが推奨されます。
とのことでDockerfileなどの中ではapt-getなどを使います。
-
performance - Why is the Alpine Docker image over 50% slower than the Ubuntu image? - Super User ↩
-
Pythonの公式イメージとしてはないですが、 Googleが作っている DistolessのPythonイメージを使うのも1つの手。
ただ、こちらはPythonのバージョンが固定です(今現在の
python3は3.11の模様。) ↩ -
自分でもDockerfileは結構何も考えずにコピペしてよく確認してないものもあると思うので。。。特に環境変数とか、影響が見た目上すぐわからないものは反映されてなくても気づくのは難しい。。。 ↩