スタディサプリ Product Team Blog

株式会社リクルートが開発するスタディサプリのプロダクトチームのブログです

Poetry, Docker, Build, and Run

データエンジニアの@masaki925 です。

私が所属するデータ組織では、スタディサプリ本体に対して検索やレコメンドなどのデータプロダクトをマイクロサービスとして提供しており、その多くはPython がメイン言語です。

またデータ基盤としてBigQuery をはじめとしたGCP サービスを多く活用しています。

現在、私は2020年頃から新規プロジェクトに参画しており、そこでもPythonGCP をベースとした開発環境を構築しました。

私が開発環境に求めることは「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

github.com

今回構築した環境のエッセンスを盛り込んだサンプルプロジェクトを置いておきました。

やりたいこと

全体的に普通だと思いますが、ところどころ罠があるので、ピックアップ(★ 印) して後述していきます。

  • Python パッケージ管理

  • 開発環境のコンテナ化

    • => Docker, Docker Compose の利用
    • 最近はCodespaces などが盛り上がりを見せていますが、今回は検討していません (当時考えもしなかった)
  • live reload, testing

    • 要はコードの変更を即座に確認できること
      • server をreload することなく変更を確認
      • container をrebuild することなくテストを実施
    • => Docker volume mount の利用
    • ★ 1
  • CI/CD

    • push したらCI が走る
    • テストが通ったらマージ可能
    • マージしたらデプロイされる
    • => Cloud Build の利用
  • 無限PR 環境 (★ 2)

    • PR (= Pull Request) を作成した時点で挙動確認するためにデプロイされる環境
    • (これは社内事情によるものだが) 既存の社内インフラ基盤(AWS) では備えている機能だったが、GCP で単純にCloud Run を利用するだけでは使えなくなってしまった
    • => Cloud Build Trigger の利用 (使い分け)
  • ビルドの効率化

    • 高速化、ディスク容量節約のため
    • => Docker multi-stage builds, build cache の利用
    • ★ 3
      • Cloud Build が関係ないstage をskip してくれない
      • => Dockerfile, cloudbuild.yaml を分ける BuildKit を有効にする

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_triggergithub.pull_requestgithub.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 イメージによる制約な気がしますが、詳細はわかっていないため、なにかご存知の方がいたら教えていただけるとうれしいです。

親切な方に教えていただきました。ありがとうございます。

試しに環境変数を追加してみたところ、意図通り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... お前だったのか...」

docs.docker.com

ということで、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 を爆速でこなしてもらったりしています。

爆速

これからも当たり前のことを当たり前にこなしつつ、サービスの改善に努めていきたいと思います。

私たちは、データの力で未来の教育・学びを創り出していきたいという方を絶賛募集中です。ご興味ある方はぜひ以下の採用ページからご応募ください。

テクノロジー職 | 株式会社リクルート 中途採用サイト