🚢

Django 製 Web サービスを Cloud Run + PlanetScale に移行する

2022/10/01に公開約11,500字

6年以上前から運営している Django 製のウェブサイトである srandom.com を VPS (CentOS, MySQL) から Cloud Run + PlanetScale に移行しました。

Cloud Run も PlanetScale も無料枠が大きいので、規模の小さいサービスであればほぼ無料で運用できる上、そのままスケールさせることもできるので便利そうです。

以下、作業手順を雑に書いていきます。

1. Dockerize

Cloud Run で動かすのでまずは Dockerfile 単体で実行できるようにする。

main.py
import os

import uvicorn

PORT = int(os.environ.get("PORT", 8000))
uvicorn.run(
    "app.asgi:application",
    host="0.0.0.0",
    port=PORT,
    log_level="info",
    proxy_headers=True,
)
  • .env を使うようにする
    • local_settings.py を使っていたがさすがに管理しにくいので django-environ を使った env ファイルでの管理に移行する
Dockerfile
FROM python:3.10-slim as builder

RUN apt-get update && apt-get -y install --no-install-recommends gcc libmariadb-dev

WORKDIR /app
COPY Pipfile Pipfile.lock ./

RUN pip install -U pip && pip install pipenv
RUN PIPENV_VENV_IN_PROJECT=1 pipenv install

FROM python:3.10-slim as runner

RUN apt-get update && apt-get -y install --no-install-recommends gcc libmariadb-dev ca-certificates

WORKDIR /app
COPY --from=builder /app /app
COPY . /app

EXPOSE 8000

ENV PORT 8000

CMD .venv/bin/python main.py

2. Cloud Storage

恒久的に保存しておく必要があるファイル(今回だと静的アセットやユーザがダウンロードするための CSV)の生成処理には Cloud Storage を使うようにする。

  • collectstatic

    • django-storages を使って、collectstatic を実行した時に Cloud Storage に static files がアップロードされるようにする。
  • Static URL

    • static の URL が Cloud Storage に向くように設定する。
    • static files 用のバケットは全員がアクセスできるように公開しておく
google_storage_bucket_iam_member.tf
resource "google_storage_bucket_iam_member" "all_users_are_legacy_object_reader_of_your_bucket" {
  bucket = "your-bucket"
  role   = "roles/storage.legacyObjectReader"
  member = "allUsers"
}
settings.py
if not DEBUG:
    DEFAULT_FILE_STORAGE = 'storages.backends.gcloud.GoogleCloudStorage'
    GS_BUCKET_NAME = 'your-bucket'
    GS_DEFAULT_ACL = None
    GS_QUERYSTRING_AUTH = False

    STATICFILES_STORAGE = 'storages.backends.gcloud.GoogleCloudStorage'
    STATIC_URL = 'https://storage.googleapis.com/your-bucket/'

3. PlanetScale

settings.py
DATABASES = {
    'default': {
        ...
        'OPTIONS': {
            'charset': 'utf8mb4',
            'ssl': {
                'ca': '/etc/ssl/certs/ca-certificates.crt',
            }
        },
    }
}

4. Cloud Run

  • Terraform で GCP リソースを諸々作成する
  • Cloud KMS で本番環境用の .env を encrypt する
    • gcloud kms encrypt --location asia-northeast1 --keyring app --key env --project $PROJECT --plaintext-file .env.production --ciphertext-file path/to/.env.enc
    • 生成した .env.enc はリポジトリに push する
  • 次のジョブを実行する GitHub Actions Workflow を作成する
    • PlanetScale 用に django_psdb_engine をダウンロード
      • git clone https://github.com/planetscale/django_psdb_engine.git
    • Cloud KMS で .env.enc を decrypt して .env を生成
      • 次のようなコマンドを実行する script を用意しておく
      • gcloud kms decrypt --location asia-northeast1 --keyring app --key env --project $PROJECT --plaintext-file .env --ciphertext-file path/to/.env.enc
    • Artifact Registry に Docker image を push
    • push した image を指定して Cloud Run にデプロイ
    • 後述する Cloud Run Jobs の更新
google_cloud_run_service.tf
resource "google_cloud_run_service" "app" {
  name     = "app"
  location = local.region

  autogenerate_revision_name = true

  template {
    spec {
      containers {
        image = "gcr.io/cloudrun/hello" # 最初以外無視されるので何でも良い

        resources {
          limits = { "memory" : "512Mi", "cpu" : "2000m" }
        }
      }

      service_account_name = google_service_account.cloud_run_app.email
    }

    metadata {
      annotations = {
        "autoscaling.knative.dev/maxScale" = "5"
      }
    }
  }

  lifecycle {
    ignore_changes = [
      metadata,
      template.0.spec.0.containers.0.image,
      template.0.metadata.0.annotations["client.knative.dev/user-image"],
      template.0.metadata.0.annotations["run.googleapis.com/client-name"],
      template.0.metadata.0.annotations["run.googleapis.com/client-version"],
      traffic,
    ]
  }
}
.github/workflows/app.yml
name: app

on: push

jobs:
  deploy:
    if: github.ref == 'refs/heads/main'
    runs-on: ubuntu-latest

    env:
      CLOUDSDK_CORE_DISABLE_PROMPTS: 1
      SERVICE: app
      REGION: asia-northeast1
      IMAGE_APP: asia-northeast1-docker.pkg.dev/project/app/app:${{ github.sha }}
      IMAGE_CMD: asia-northeast1-docker.pkg.dev/project/cmd/cmd:${{ github.sha }}
    
    steps:
      - uses: actions/checkout@v3

      - name: Authenticate to Google Cloud
        uses: google-github-actions/auth@v0
        with:
          credentials_json: ${{ secrets.GOOGLE_APPLICATION_CREDENTIALS }}

      - name: Configure auth
        run: gcloud auth configure-docker ${{ env.REGION }}-docker.pkg.dev

      - name: Build and push containers
        run: |
          git clone https://github.com/planetscale/django_psdb_engine.git
          ./scripts/decrypt_env.sh production
          docker build . -f docker/Dockerfile -t ${{ env.IMAGE_APP }}
          docker build . -f docker/Dockerfile_cmd -t ${{ env.IMAGE_CMD }}
          docker push ${{ env.IMAGE_APP }}
          docker push ${{ env.IMAGE_CMD }}

      - name: Deploy to Cloud Run
        uses: google-github-actions/deploy-cloudrun@main
        with:
          service: ${{ env.SERVICE }}
          image: ${{ env.IMAGE_APP }}
          region: ${{ env.REGION }}

      - name: Update Cloud Run Jobs
        run: |
          gcloud beta run jobs update cmd --image ${{ env.IMAGE_CMD }} --region asia-northeast1

5. 定期実行ジョブの設定

元々 cron で Django のカスタムコマンドを実行してたので、バッチ用のエンドポイントを作るよりもコマンドをそのまま実行するようにしたい。
Cloud Run JobsCloud Scheduler を使えばこれを実現できる。

  • バッチコマンドごとに実行用の Dockerfile を作成する
  • それらを Artifact Registry に push
  • Cloud Run Job を作成する
  • 作成した Cloud Run Job を定期的に実行する Cloud Scheduler ジョブを作成する
Dockerfile_cmd
FROM python:3.10-slim as builder

RUN apt-get update && apt-get -y install --no-install-recommends gcc libmariadb-dev

WORKDIR /app
COPY Pipfile Pipfile.lock ./

RUN pip install -U pip && pip install pipenv
RUN PIPENV_VENV_IN_PROJECT=1 pipenv install

FROM python:3.10-slim as runner

RUN apt-get update && apt-get -y install --no-install-recommends gcc libmariadb-dev ca-certificates

WORKDIR /app
COPY --from=builder /app /app
COPY . /app

CMD .venv/bin/python manage.py command
google_cloud_scheduler_job.tf
resource "google_cloud_scheduler_job" "command" {
  name             = "command"
  description      = "Cloud Run Job: command"
  schedule         = "30 0 * * *" # At 0:30
  time_zone        = "Asia/Tokyo"
  attempt_deadline = "600s" # 10m

  retry_config {
    retry_count = 1
  }

  http_target {
    http_method = "POST"
    uri         = "https://asia-northeast1-run.googleapis.com/apis/run.googleapis.com/v1/namespaces/project/jobs/command:run"

    oauth_token {
      service_account_email = "xxx-compute@developer.gserviceaccount.com" # Default compute service account
    }
  }
}

6. サイト移行

ここまでの作業で一通り Cloud Run + PlanetScale での動作を確認できたので、サイト移行の作業をしていく。

  • 現状の本番環境サイトをメンテナンスモードに設定する
    • メンテナンスモード中であることが表示され、サイトの全ての機能が使えなくなる(既存の DB にレコードを追加してほしくないため)
  • DB のデータ移行
    • 本番 DB レコードを dump して PlanetScale の DB に import する
  • ドメイン設定
    • Cloud Run の IP 向けに A, AAAA レコードを作成する
    • DNS のネームサーバを Cloud DNS に変更する
    • Cloud Run のドメインマッピングを使ってドメインをアプリに紐づける(SSL 証明書も発行される)
      • 予想以上にドメインマッピングの反映に時間がかかってサイトにアクセスできない時間が続いてしまった
  • カスタムドメインでの接続が確認できたら Cloudflare を設定する
    • Cloud Storage バケット用のドメインを設定する場合は Page Rules を使ってそのドメイン下のみ SSL の設定を SSL (Flexible) に設定する必要がある
    • 以前のサイトの設定と競合して(?)エッジ証明書が削除済みという表示になって発行されなかったのでここを参考に certificate_authoritylets_encrypt に設定したらちゃんと発行された
curl -X PATCH "https://api.cloudflare.com/client/v4/zones/[zone_id]/ssl/universal/settings" -H "X-Auth-Email: [email]" -H "X-Auth-Key: [Global API Key]" -H "Content-Type: application/json" --data '{"certificate_authority":"lets_encrypt"}'

7. Cloud Monitoring

エラーログを監視して Slack にアラート通知を飛ばしたいので Cloud Monitoring でアラートポリシーを作成する。

WARNING レベルのログを一定以上検知したらアラートを飛ばすポリシーの例

google_logging_metric.tf
resource "google_logging_metric" "cloud_run_app_warning" {
  name        = "cloud-run-app-warning"
  description = "Warning log count of Cloud Run 'app'"
  filter      = "resource.type = \"cloud_run_revision\" AND resource.labels.service_name = \"app\" AND severity = \"WARNING\""
  metric_descriptor {
    metric_kind = "DELTA"
    value_type  = "INT64"
  }
}
google_monitoring_alert_policy.tf
resource "google_monitoring_alert_policy" "cloud_run_app_warning_count" {
  display_name = "[app] Warning log count is high"
  combiner     = "OR"
  conditions {
    display_name = "logging/user/cloud-run-app-warning [COUNT]"
    condition_threshold {
      threshold_value = 10
      duration        = "0s"
      comparison      = "COMPARISON_GT"
      trigger {
        count = 1
      }
      aggregations {
        alignment_period   = "600s"
        per_series_aligner = "ALIGN_COUNT"
      }
      filter = "metric.type=\"logging.googleapis.com/user/cloud-run-app-warning\" resource.type=\"cloud_run_revision\""
    }
  }
  notification_channels = [
    # これを取得する方法がわからなかったので、一度手動で作成して import した
    "projects/project/notificationChannels/xxx"
  ]
  alert_strategy {
    auto_close = "259200s"
  }
}

指定した閾値を超えるとこんな感じの通知が来る。

所感

ここ数年 Go ばかり書いていたので久しぶりに Django を触れて楽しかった。(諸々手直しする必要があったので)
長い間惰性で VPS 運用をやってきたので、いい感じにクラウドに乗っけられてよかったです。
Cloud SQL や AlloyDB は高いし Heroku も無料プランが廃止されてしまうので、PlanetScale のようなサービスは個人開発者にとっては本当にありがたいですね。

Discussion

ログインするとコメントできます