くらげになりたい。

くらげのようにふわふわ生きたい日曜プログラマなブログ。趣味の備忘録です。

Django+Cloud Run+Cloud SQL(MySQL)+Cloud Build

最近、CloudRunとCloudBuildをよく使うので、
Django+CloudSQLも簡単とできるだろ〜と思ったら、
大ハマリしたときの備忘録。

CloudSQL(MySQL)だとライブラリが対応してなくてハマるっぽい。。(´・ω・`)

基本的な流れは公式ドキュメントを参照。
Cloud Run 環境で Django を実行する  |  Python  |  Google Cloud

Cloud SQL(MySQL)とかは設定済みの想定

公式ドキュメントの構成

公式ドキュメントの内容を読みすすめると以下の感じの構成になっている。

  • 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

  • IAMページに移動
  • <PROJECTNUM>@cloudbuild.gserviceaccount.comに、 ロール「Cloud SQL クライアント」を追加

「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_KEYDEBUGは、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_MODULESETTINGS_NAMEを受け取るように変更
  • SETTINGS_MODULESETTINGS_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

すぐできると思ったらたくさん罠があった。。(´ω`)

以上!!