データエンジニアの@masaki925 です。
私が所属するデータ組織では、スタディサプリ本体に対して検索やレコメンドなどのデータプロダクトをマイクロサービスとして提供しており、その多くはPython がメイン言語です。
またデータ基盤としてBigQuery をはじめとしたGCP サービスを多く活用しています。
現在、私は2020年頃から新規プロジェクトに参画しており、そこでもPython とGCP をベースとした開発環境を構築しました。
私が開発環境に求めることは「TDD がしやすいこと」です。
これは平たく言うと、テストを書いて、実装して、CI/CD して、というサイクルを効率的に回すことです。当たり前のことを当たり前にやりたい、ただそれだけです。
今回、以下の環境でそれをやろうとしたらいくつか罠があったので記しておきます。
$ docker compose run poetry-docker-build-and-run python -V Python 3.9.9 $ docker compose run poetry-docker-build-and-run poetry -V Poetry version 1.1.12 $ docker -v Docker version 20.10.10, build b485636 $ docker run gcr.io/cloud-builders/docker -v Docker version 19.03.9, build 9d988398e7 Cloud Build (2021-12-01 時点) Cloud Run (2021-12-01 時点)
TL;DR
今回構築した環境のエッセンスを盛り込んだサンプルプロジェクトを置いておきました。
やりたいこと
全体的に普通だと思いますが、ところどころ罠があるので、ピックアップ(★ 印) して後述していきます。
Python パッケージ管理
- => Poetry の利用
- 他の主要ツールとの比較はこちらの記事が大変参考になります: vaaaaaanquish.hatenablog.com
開発環境のコンテナ化
- => Docker, Docker Compose の利用
- 最近はCodespaces などが盛り上がりを見せていますが、今回は検討していません (当時考えもしなかった)
live reload, testing
- 要はコードの変更を即座に確認できること
- server をreload することなく変更を確認
- container をrebuild することなくテストを実施
- => Docker volume mount の利用
- ★ 1
- Poetry のvirtualenvs.in-project はtrue にしない
- => そもそもvirtualenvs.create をfalse にする
- Poetry のvirtualenvs.in-project はtrue にしない
- 要はコードの変更を即座に確認できること
CI/CD
- push したらCI が走る
- テストが通ったらマージ可能
- マージしたらデプロイされる
- => Cloud Build の利用
無限PR 環境 (★ 2)
ビルドの効率化
- 高速化、ディスク容量節約のため
- => Docker multi-stage builds, build cache の利用
- ★ 3
- Cloud Build が関係ないstage をskip してくれない
Dockerfile+ のinclude 文法 を使うこれも使えない今回は不要になった
- =>
Dockerfile, cloudbuild.yaml を分けるBuildKit
を有効にする
- Cloud Build が関係ないstage をskip してくれない
Poetry のvirtualenvs.in-project はtrue にしない
わかってしまえば単純な話ですが、筆者は無駄にハマってしましました。
というのも、「poetry docker best practice」で検索すると上位にヒットするpython-poetry-docker-example
というリポジトリがあり、
そこでは POETRY_VIRTUALENVS_IN_PROJECT=true
が入っています。
このオプションを使うと、poetry install が勝手にカレントディレクトリに .venv
ディレクトリを作成し、virtualenv として利用します。
またPoetry はデフォルトで.venv
が存在した場合、それをvirtualenv として利用します。
この結果、volume mount した状態だと、ホスト側とコンテナ側で.venv
が共有されてしまい、意図しない挙動(インストールしたものがされていなかったり、逆だったり) をします。
さらに混乱するのは、venv のpath を指定できないのかという議論があり、作者がその機能を拒否しているため、work around なども提案されています。
ですが、そもそもコンテナ内でvirtualenv を使わなければ不要な議論なので、さっさとvirtualenv.create false してしまいましょう。
無限PR 環境
要件は下記です。
- Pull Requst が作られたときに、専用のサービス(Cloud Run instance) がデプロイされる
- サービスの名前に
pr-(PR 番号)
が付与されている) - PR がマージされたら、 main 環境 (dev やprod ) にデプロイされる
これらはスタディサプリがメインで利用しているインフラ基盤(AWS) では既にSRE チームによって実現されており、今まで特に気にする必要がありませんでした。
今回メイン基盤ではないGCP のみで完結する構成を取った結果、この便利機能が使えなくなってしまい、やっぱりあったほうがいいよね、となった次第です。
実現方法として、Cloud Build のTrigger を2つ使い分けています。
Terraform のリソースgoogle_cloudbuild_trigger のgithub.pull_request
と github.push
にあたる設定を使い分けることで、PR が出されたときとマージされたときを区別しています。
またcloudbuil.yaml のdeploy コマンドで指定する名前を ${_APP_NAME}-${_ENV}${_PR_NUMBER}
として、「pull リクエスト トリガーに使用する GitHub 固有のデフォルトの置換」を利用してPR 番号を取得しています。
これによって無事PR 環境を取り戻すことができました。
しかし残課題として、マージ後に残ったPR 環境がそのまま放置されてしまうという問題があります。
ナイーブには定期的にお掃除バッチを流すなどありますが、on-demand に認証情報(例えばGCP -> GitHub) を持たずに済ませる方法が無いかなと模索しています。
(前述のマージ時のgithub.push
トリガーでは_PR_NUMBER
が取れないため同じ方法ではできない)
よい方法があればぜひ教えていただけるとうれしいです。
Cloud Build が関係ないstage をskip してくれない
Docker のmulti-stage builds の話です。
例えば
FROM alpine as base FROM base as step1 RUN echo step1 FROM base as step2 RUN echo step2
があったとき、 local での実行と、Cloud Build による実行では挙動が異なります。
local だと(最終成果物として必要な) step2 のみ実行される
$ d build -t aaa . [+] Building 2.0s (6/6) FINISHED => [internal] load build definition from Dockerfile 0.0s => => transferring dockerfile: 36B 0.0s => [internal] load .dockerignore 0.0s => => transferring context: 2B 0.0s => [internal] load metadata for docker.io/library/alpine:latest 1.9s => [base 1/1] FROM docker.io/library/alpine@sha256:21a3deaa0d32a8057914f36584b5288d2e5ecc984380bc0118285c70fa8c9300 0.0s => CACHED [step2 1/1] RUN echo step2 0.0s => exporting to image 0.0s => => exporting layers 0.0s => => writing image sha256:b62cafe42d4621e2b4bfb8282b741fb23a5303471273f9655be85e4ad8fc579e 0.0s => => naming to docker.io/library/aaa 0.0s
Cloud Build だと両方実行される
# cloudbuild.yaml steps: - name: 'gcr.io/cloud-builders/docker' args: ['build', '-t', 'aaa', '.']
$ gcloud builds submit --config cloudbuild.yaml (... snip) BUILD Already have image (with digest): gcr.io/cloud-builders/docker Sending build context to Docker daemon 34.3kB Step 1/5 : FROM alpine as base latest: Pulling from library/alpine 59bf1c3509f3: Pulling fs layer 59bf1c3509f3: Verifying Checksum 59bf1c3509f3: Download complete 59bf1c3509f3: Pull complete Digest: sha256:21a3deaa0d32a8057914f36584b5288d2e5ecc984380bc0118285c70fa8c9300 Status: Downloaded newer image for alpine:latest ---> c059bfaa849c Step 2/5 : FROM base as step1 ---> c059bfaa849c Step 3/5 : RUN echo step1 ---> Running in 89c951160e32 step1 Removing intermediate container 89c951160e32 ---> aa33d769764c Step 4/5 : FROM base as step2 ---> c059bfaa849c Step 5/5 : RUN echo step2 ---> Running in 011188bcff62 step2 Removing intermediate container 011188bcff62 ---> b46331031f63 Successfully built b46331031f63 Successfully tagged aaa:latest PUSH DONE
これにより、例えば「PR 環境ではテストを実行してからデプロイしたいが、マージ後は直接main 環境にデプロイする場合」に、両環境で同じDockerfile を使っていると、デプロイ時に余計なstage が実行されることになり、デプロイ時間が長くなってしまいます。
(以下、編集あり。2021-12-08)
このため、Dockerfile, cloudbuild.yaml をそれぞれPR 環境用とmain 環境用で使い分けています。 => 後述の通り、不要になりました。
この原因については、
おそらくCloud Build サービスというよりはstep で指定しているCloud Builder のdocker イメージによる制約な気がしますが、詳細はわかっていないため、なにかご存知の方がいたら教えていただけるとうれしいです。
親切な方に教えていただきました。ありがとうございます。
この記事に書かれている `Cloud Build が関係ないstage をskip してくれない` はBuildKitが有効になっているかどうかが問題だと思います。 https://t.co/lmJSobd5OU ここにある解決策の通り環境変数にDOCKER_BUILDKIT=1を追加すると解決すると思います
— おりさの (@orisano) 2021年12月7日
試しに環境変数を追加してみたところ、意図通りskip されました。
# cloudbuild.yaml steps: - name: 'gcr.io/cloud-builders/docker' args: ['build', '-f', 'Dockerfile.ext', '-t', 'gcr.io/$PROJECT_ID/aaa', '.'] + env: + - "DOCKER_BUILDKIT=1"
$ gcloud builds submit --config cloudbuild.yaml (...snip) BUILD Already have image (with digest): gcr.io/cloud-builders/docker #1 [internal] load build definition from Dockerfile #1 transferring dockerfile: 129B done #1 DONE 0.1s #2 [internal] load .dockerignore #2 transferring context: 2B done #2 DONE 0.0s #3 [internal] load metadata for docker.io/library/alpine:latest #3 DONE 0.3s #4 [base 1/1] FROM docker.io/library/alpine@sha256:21a3deaa0d32a8057914f365... #4 DONE 0.0s #5 [step2 1/1] RUN echo step2 #5 0.258 step2 #5 DONE 0.3s #6 exporting to image #6 exporting layers #6 exporting layers 0.0s done #6 writing image sha256:296579c9f857070089c03611667b890aa787eb5c71b03e2bb8aa137b8473e54e done #6 naming to gcr.io/iwa-lab/aaa done #6 DONE 0.1s PUSH DONE
よく見ると出力形式も変わっており、builder がBuildKit に切り替わっていることがわかります。
また、Dockerfile+ のinclude 文法 もBuildKit を有効にすることで使えるようになりました。(今回の用途ではDockerfile を分ける必要がなくなりましたが、原因がわかってよかった。)
「BuildKit... お前だったのか...」
ということで、Cloud Build でmulti-sage のskip を利用したい場合はBuildKit を有効にしておきましょう。
(編集ここまで。2021-12-08)
おまけ
- Poetry のproject name に
-
を使うと、module 名は_
に変換されるため、module not found
の原因になったりする - Poetry install コマンドはデフォルトでroot project をmodule としてinstall してくれるが、Dockerfile のcache の調整でCOPY src をする前にpoetry install とかしてたりするとmodule はinstall されない (当たり前だけど)
まとめ
このようにいくつかの罠に足を取られながらも、筆者の考える「TDD しやすい」Python 開発環境を整えることができました。
このおかげもあり(もちろんメンバーの能力によるところもありますが)、プロジェクトに新しく参画したメンバーの立ち上がりもよく、スムーズに環境構築を済ませ、good first issue を爆速でこなしてもらったりしています。
これからも当たり前のことを当たり前にこなしつつ、サービスの改善に努めていきたいと思います。
私たちは、データの力で未来の教育・学びを創り出していきたいという方を絶賛募集中です。ご興味ある方はぜひ以下の採用ページからご応募ください。