最近、CloudRunとCloudBuildをよく使うので、
Django+CloudSQLも簡単とできるだろ〜と思ったら、
大ハマリしたときの備忘録。
CloudSQL(MySQL)だとライブラリが対応してなくてハマるっぽい。。(´・ω・`)
基本的な流れは公式ドキュメントを参照。
・Cloud Run 環境で Django を実行する | Python | Google Cloud
公式ドキュメントの構成
公式ドキュメントの内容を読みすすめると以下の感じの構成になっている。
- DBはCloud SQL for PostgreSQL
- 静的ファイルは、Cloud Storageに配置
- DBの設定値などは、Secret Managerで管理
- Cloud Buildによる自動化は、 イメージの作成まで
やりたい構成としては、こんな感じ
- DBはCloud SQL for MySQL
- 静的ファイルは、whitenoiseでDjangoから配信
- DBの設定値などは、Secret Managerで管理
- Cloud Buildによる自動化は、 デプロイまで
やったこと
公式ドキュメントの流れに沿ってはまったところ追記していく。
「始める前に」
設定済みのため、スキップ。
APIの有効化やCloud SDKのインストールなど、未実施のものがあれば適宜実施。
「環境の準備をする」
これも設定済みのためスキップ。
requirements.txtがなければ、用意しておく。
「バッキング サービスの準備」
「Cloud SQL for PostgreSQL インスタンスを設定する」
これも設定済みのためスキップ。
「Cloud Storage バケットを設定する」
今回は使わないので、スキップ
「Secret Manager にシークレット値を保存する」
手順通り進めていけばOK。
MySQLだとこんとこんな感じ。
DATABASE_URL=mysql://<USER>:<PASSWORD>@//cloudsql/<PROJECT_ID>:<REGION>:<INSTANCE_NAME>/<DATABASE_NAME> GS_BUCKET_NAME=<BUCKET_NAME> SECRET_KEY=(a random string, length 50)
ポイントとしては、
- シークレットマネージャーを使う前に、有効化が必要
- 名前を使ってDjango内から参照するので、わかりやすい名前をつける
- 以下の2ユーザを追加が必要
<PROJECTNUM-compute>@developer.gserviceaccount.com<PROJECTNUM>@cloudbuild.gserviceaccount.com- ロールは「Secret Manager のシークレット アクセサー」
「Cloud Build に Cloud SQL へのアクセス権を付与する」
手順通りに進めればOK
「Cloud Run にアプリをデプロイする」
ビルドとデプロイのコマンドが記載されている。
初回実行の場合は、こんな感じ。
# Cloud BuildでDockerイメージをビルド & Container Repositoryにアップロード
gcloud builds submit --config cloudmigrate.yaml \
--substitutions _INSTANCE_NAME=<INSTANCE_NAME>,_REGION=<REGION>
# Container RepositoryにアップロードしたイメージをCloud Runにデプロイ
gcloud run deploy polls-service \
--platform managed \
--region <REGION> \
--image gcr.io/<PROJECT_ID>/<SERVICE_NAME> \
--add-cloudsql-instances <PROJECT_ID>:<REGION>:<INSTANCE_NAME> \
--allow-unauthenticated
2回目移行の更新の場合は、デプロイコマンドの引数が減る。
# Cloud BuildでDockerイメージをビルド & Container Repositoryにアップロード
gcloud builds submit --config cloudmigrate.yaml \
--substitutions _INSTANCE_NAME=<INSTANCE_NAME>,_REGION=<REGION>
# Container RepositoryにアップロードしたイメージをCloud Runにデプロイ
gcloud run deploy polls-service \
--platform managed \
--region <REGION> \
--image gcr.io/<PROJECT_ID>/<SERVICE_NAME> \
ドキュメント以外で設定したところ
Cloud Run用のsettings.pyの変更
ドキュメントだとサンプルアプリを使っているので省略されているけど、
シークレットマネージャなどを使うようsettings.pyの変更が必要。
まずは必要なパッケージをインストール
pip install google-cloud-secret-manager mysqlclient mysqlclient
次に、サンプルを見ながら、settings.pyを変更
・python-docs-samples/settings.py at master · GoogleCloudPlatform/python-docs-samples
以下は、主な変更点のみ。
import io import os import environ import google.auth from google.cloud import secretmanager # [START cloudrun_django_secret_config] env = environ.Env(DEBUG=(bool, False)) env_file = os.path.join(BASE_DIR, ".env") # Attempt to load the Project ID into the environment, safely failing on error. try: _, os.environ["GOOGLE_CLOUD_PROJECT"] = google.auth.default() except google.auth.exceptions.DefaultCredentialsError: pass if os.path.isfile(env_file): # Use a local secret file, if provided env.read_env(env_file) elif os.environ.get("GOOGLE_CLOUD_PROJECT", None): # Pull secrets from Secret Manager project_id = os.environ.get("GOOGLE_CLOUD_PROJECT") client = secretmanager.SecretManagerServiceClient() settings_name = os.environ.get("SETTINGS_NAME", "django_settings") name = f"projects/{project_id}/secrets/{settings_name}/versions/latest" payload = client.access_secret_version(name=name).payload.data.decode("UTF-8") env.read_env(io.StringIO(payload)) else: raise Exception("No local .env or GOOGLE_CLOUD_PROJECT detected. No secrets found.") # [END cloudrun_django_secret_config] SECRET_KEY = env("SECRET_KEY") # SECURITY WARNING: don't run with debug turned on in production! # Change this to "False" when you are ready for production DEBUG = env("DEBUG") # [START cloudrun_django_database_config] # Use django-environ to parse the connection string DATABASES = {"default": env.db()} # If the flag as been set, configure to use proxy if os.getenv("USE_CLOUD_SQL_AUTH_PROXY", None): DATABASES["default"]["HOST"] = "127.0.0.1" DATABASES["default"]["PORT"] = 5432 # ** work around for CloudRun+Django-environ # https://github.com/googlecodelabs/feedback/issues/964#issuecomment-844554396 if "/" in DATABASES["default"]["NAME"]: DATABASES["default"]["HOST"], DATABASES["default"]["NAME"] = DATABASES["default"]["NAME"].rsplit('/', 1) # [END cloudrun_django_database_config]
ポイントとしては、
- 環境変数は、Django-environで扱う
- ローカルの
.envが優先され、なければシークレットマネジャーの値を使う SECRET_KEYとDEBUGは、envの値を利用する- 環境変数に
USE_CLOUD_SQL_AUTH_PROXYが設定されているとローカルのCloud SQL Authプロキシが利用される - MySQLの場合は、別途追記が必要
MySQLについては、以下のDjango-environのissueがあがっていて、
・Incorrect parsing of DATABASES_URL for Google Cloud MySQL · Issue #294 · joke2k/django-environ
ワークアラウンドが紹介されていた。
・[cloud-run-django]: Codelab does not work with MySQL Cloud · Issue #964 · googlecodelabs/feedback
また、ModuleNotFoundError: No module named 'google.cloud'やModuleNotFoundError: No module named 'google'のエラーが出るときがあるけど、
アップグレードすると解決する。
pip install --upgrade google-api-python-client pip install --upgrade google-cloud-secret-manager
・python 2.7 - ImportError: No module named google.cloud - Stack Overflow
・python - ImportError: No module named 'google' - Stack Overflow
staticファイル用のsettings.pyの変更
staticディレクトリにある静的ファイルをDjangoから配信したいけど、
そのままではダメなので、HerokuのドキュメントにもあるWhiteNoiseを利用する。
まずはインストール
pip install whitenoise
次にsettings.pyの変更。以下は該当部分のみ。
MIDDLEWARE = [
'django.middleware.security.SecurityMiddleware',
// SecurityMiddlewareの次に追加する
'whitenoise.middleware.WhiteNoiseMiddleware',
# ...
]
STATICFILES_STORAGE = 'whitenoise.storage.CompressedStaticFilesStorage'
# or
# django.contrib.staticfiles.storage.ManifestStaticFilesStorageを使う場合
STATICFILES_STORAGE = 'whitenoise.storage.CompressedManifestStaticFilesStorage'
Cloud Buildでビルド&デプロイ
Cloud BuildでCDするためのyamlとDockerfileを用意。
サンプルを見つつ、以下の感じに。
・python-docs-samples/cloudmigrate.yaml at master · GoogleCloudPlatform/python-docs-samples
サンプルからの変更点は
- デプロイまで実施するように
id: deployを追加 - GCRのホスト変更できるように
_GCR_HOSTNAMEを追加 - settings.pyを切り替えられるよう
_SETTINGS_MODULEを追加し、 ビルド時に引数に渡すよう変更
steps: - id: "build image" name: "gcr.io/cloud-builders/docker" args: [ "build", "-t", "${_GCR_HOSTNAME}/${PROJECT_ID}/${_SERVICE_NAME}", ".", "--build-arg", "VERSION=${_TAG_NAME}", "--build-arg", "SETTINGS_MODULE=${_SETTINGS_MODULE}", "--build-arg", "SETTINGS_NAME=${_SECRET_SETTINGS_NAME}", ] - id: "push image" name: "gcr.io/cloud-builders/docker" args: ["push", "${_GCR_HOSTNAME}/${PROJECT_ID}/${_SERVICE_NAME}"] - id: "apply migrations" name: "gcr.io/google-appengine/exec-wrapper" args: [ "-i", "${_GCR_HOSTNAME}/$PROJECT_ID/${_SERVICE_NAME}", "-s", "${PROJECT_ID}:${_REGION}:${_INSTANCE_NAME}", "-e", "SETTINGS_NAME=${_SECRET_SETTINGS_NAME}", "--", "python", "manage.py", "migrate", "--settings=${_SETTINGS_MODULE}", ] - id: "collect static" name: "gcr.io/google-appengine/exec-wrapper" args: [ "-i", "${_GCR_HOSTNAME}/$PROJECT_ID/${_SERVICE_NAME}", "-s", "${PROJECT_ID}:${_REGION}:${_INSTANCE_NAME}", "-e", "SETTINGS_NAME=${_SECRET_SETTINGS_NAME}", "--", "python", "manage.py", "collectstatic", "--settings=${_SETTINGS_MODULE}", "--verbosity", "2", "--no-input", ] - id: "deploy" name: "gcr.io/google.com/cloudsdktool/cloud-sdk" entrypoint: gcloud args: [ "run", "deploy", "${_SERVICE_NAME}", "--platform=$_PLATFORM", "--region=${_REGION}", "--image=${_GCR_HOSTNAME}/$PROJECT_ID/${_SERVICE_NAME}", "--add-cloudsql-instances=${PROJECT_ID}:${_REGION}:${_INSTANCE_NAME}", "--labels=managed-by=gcp-cloud-build-deploy-cloud-run,commit-sha=$COMMIT_SHA,gcb-build-id=$BUILD_ID,gcb-trigger-id=$_TRIGGER_ID,$_LABELS", "--quiet", ] options: substitutionOption: ALLOW_LOOSE substitutions: _REGION: asia-northeast1 _GCR_HOSTNAME: asia.gcr.io _PLATFORM: managed _SERVICE_NAME: my-app-prod _SECRET_SETTINGS_NAME: my-app-prod _SETTINGS_MODULE: app.settings.production _INSTANCE_NAME: my-app-prod _TAG_NAME: "$TAG_NAME" tags: - gcp-cloud-build-deploy-cloud-run - gcp-cloud-build-deploy-cloud-run-managed images: - "${_GCR_HOSTNAME}/${PROJECT_ID}/${_SERVICE_NAME}" # [END cloudrun_django_cloudmigrate]
Dockerfileはこんな感じ。参考にしたサンプルは以下。
・python-docs-samples/Dockerfile at master · GoogleCloudPlatform/python-docs-samples
サンプルからの変更点は以下。
SETTINGS_MODULEとSETTINGS_NAMEを受け取るように変更SETTINGS_MODULEとSETTINGS_NAMEをgunicornの環境変数に設定- MySQLを利用できるように
apt-get installを追加(Issue#964)
FROM python:3.9-slim ARG VERSION ARG SETTINGS_MODULE ARG SETTINGS_NAME ENV APP_HOME /app WORKDIR $APP_HOME # Removes output stream buffering, allowing for more efficient logging ENV PYTHONUNBUFFERED 1 # Additional EVN ENV VERSION $VERSION ENV SETTINGS_MODULE $SETTINGS_MODULE ENV SETTINGS_NAME $SETTINGS_NAME # Workaround for MySQL # Issue: https://github.com/googlecodelabs/feedback/issues/964#issuecomment-844554396 RUN apt-get update -y && apt-get install -y gcc libc-dev default-libmysqlclient-dev # Install dependencies COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt # Copy local code to the container image. COPY . . # Run the web service on container startup. CMD exec gunicorn --env DJANGO_SETTINGS_MODULE=$SETTINGS_MODULE --env SETTINGS_NAME=$SETTINGS_NAME --bind 0.0.0.0:$PORT --workers 1 --threads 8 --timeout 0 app.wsgi:application
これで、Cloud Buildのトリガー作成時に、
_SECRET_SETTINGS_NAMEや_SETTINGS_MODULEを設定すれば、
シークレットマネジャーやsettings.pyを切り替えられるようになる。
ハマったポイントのまとめ
- MySQLだと特別な設定が必要(Issue#964)
- DATABASE_URLを処理できるようにsettings.pyに追記が必要
- Dockerfileに
apt-get installの追記が必要
ModuleNotFoundErrorが出たら、pip install --upgradeが必要- 権限の設定が必要
<PROJECTNUM>@cloudbuild.gserviceaccount.comに「Cloud SQLクライアント」<PROJECTNUM-compute>@developer.gserviceaccount.comに「Secret Managerのシークレット アクセサー」<PROJECTNUM>@cloudbuild.gserviceaccount.comに「Secret Managerのシークレット アクセサー」
- 静的ファイル配信でwhitenoise
- CompressedStaticFilesStorageを使う
- CompressedManifestStaticFilesStorageを使うならcollectstatic
- 適宜環境変数の引き渡しが必要
- CloudBuild=>Docker=>gunicorn
すぐできると思ったらたくさん罠があった。。(´ω`)
以上!!