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=on
PIP_NO_CACHE_DIR=off
は正反対のように見えますが、実際にはどちらもキャッシュを無効にします。
PIP_NO_CACHE_DIR=0
PIP_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は結構何も考えずにコピペしてよく確認してないものもあると思うので。。。特に環境変数とか、影響が見た目上すぐわからないものは反映されてなくても気づくのは難しい。。。 ↩