Python + Django + Highcharts + Coogle Cloud Cloud Run + Cloud Storage + Litestream で食べたリンゴの割合をグラフ化してみた

今まで、「食べたリンゴの割合をグラフ化するアプリ」をHerokuで動かしていました。
Python + Django + Highcharts + Herokuで食べたリンゴの割合をグラフ化してみた - メモ的な思考的な

そんな中、個人的に使ってみたかったGoogle Cloudで動かせるか気になったので、必要な機能を検証してみました。

 
検証してみても特に気になるところはなかったため、本格的にGoogle Cloud へ移行しようと考えました。

そこで、上記の記事に加えて追加の移行作業を行ったため、内容をメモしておきます。

 
目次

 

環境

なお、以下の記事の作業は実施済とします。
Djangoアプリを、Coogle Cloud の Cloud Run + Cloud Storage + Litestream な環境で動かしてみた - メモ的な思考的な

 
また、移行前後で使っている機能は以下の通りです。

Herokuだと 役割 Google Cloudでは
Dyno Djangoアプリをホスト Cloud Run
Dyno 静的ファイルの配信 Cloud Storage
Heroku Postgres Djangoアプリのデータベース SQLite + Litestream + Cloud Storage
Heroku Scheduler ツイートを定期的に収集 Cloud Scheduler

 

どのリージョンを使うか検討し、us-west1 (オレゴン) を使う

高頻度で使うようなDjangoアプリではないため、一番安価かつ多機能なリージョンを選択しようと考えました。

Cloud RunやCloud Storageの無料枠を見ると、北米のリージョンに適用されそうでした。

また、Cloud Runにカスタムドメインを利用する場合は、特定のリージョンのみ可能そうでした。
Cloud Run のドメイン マッピングの制限事項 | カスタム ドメインのマッピング  |  Cloud Run のドキュメント  |  Google Cloud

そこで、

  • 北米のリージョン
    • そのうち、一番日本に近そうなリージョン
  • カスタムドメインが使える

を考慮して、 us-west1 (オレゴン) に各リソースを用意することにしました。

   

Djangoの静的ファイル用の設定を追加

今回、Djangoの静的ファイル(JavaScriptCSS)はCloud Storageから配信するため、設定を追加します。

 

Cloud Storageに、Djangoの静的ファイル用のバケットを作成

静的ファイル用のバケットは公開バケットとして作成します。

Litestream用のバケットの設定に加え、 allUsersStorage オブジェクト閲覧者 の権限を付与します。

もし、静的ファイル用のバケットを公開しない場合、デプロイして動作させると、ログに以下のエラーが出ます。

AttributeError: you need a private key to sign credentials.the credentials you are currently using <class 'google.auth.compute_engine.credentials.Credentials'> just contains a token. see https://googleapis.dev/python/google-api-core/latest/auth.html#setting-up-a-service-account for more details.

 

django-storageを使って、静的ファイルをCloud Storageから配信するよう設定

Google Cloudのチュートリアルの通り、 django-storages を使って静的ファイルをCloud Storageから配信できるよう設定します。
Cloud Run 環境での Django の実行  |  Python  |  Google Cloud

django-storages の公式ドキュメントに従い、 pip install django-storages[google] でインストールします。
Google Cloud Storage — django-storages 1.13.1 documentation

続いて、本番環境の settings.py に設定を行います。

# staticファイルの設定
DEFAULT_FILE_STORAGE = 'storages.backends.gcloud.GoogleCloudStorage'
GS_BUCKET_NAME = os.environ['GS_BUCKET_NAME']
STATICFILES_STORAGE = 'storages.backends.gcloud.GoogleCloudStorage'

# 公開バケットを使用
GS_QUERYSTRING_AUTH = False
GS_DEFAULT_ACL = None

 

Cloud Runの起動時にcollectstaticを実行するように設定を追加

Cloud Storageから配信できるよう、Cloud Runの起動時に collectstatic を実行し、静的ファイルをCloud Storageに置くようにします。

起動時に実行される run.sh に追記します。

python manage.py collectstatic --noinput --settings dj_ringo_tabetter.settings.production

 
なお、 --noinput オプションを追加していますが、これは「すでに静的ファイル置き場にファイルが存在する場合、以下のようなメッセージが出て入力待ちになる」のを回避するためです。

You have requested to collect static files at the destination
location as specified in your settings.

This will overwrite existing files!
Are you sure you want to do this?

Type 'yes' to continue, or 'no' to cancel

 

Secret ManagerにTwitter APIの情報などの秘匿情報を置く

外部公開を防ぎたいものを記載します。

なお、Cloud RunではSecret Managerの内容を環境変数に読み込むこともできるようです。
シークレットを使用する  |  Cloud Run のドキュメント  |  Google Cloud

ただ、ベストプラクティスを読むと、環境変数に読み込むのではなく、ライブラリを使って動的に取得したほうが良さそうです。
Secret Manager のベスト プラクティス  |  Secret Manager のドキュメント  |  Google Cloud

そこで、前回の記事を参考に、 google-cloud-secret-manager 経由でSecret Managerから値を取得するようにします。
Google Cloud Secret Manager にあるシークレットの値を、ローカル環境のPythonスクリプトで取得してみた - メモ的な思考的な

# Secret Managerから値を取得する関数
def fetch_secret_manager(self):
    if settings.DEBUG:
        client = secretmanager.SecretManagerServiceClient.from_service_account_json('gcp_credential.json')
    else:
        # Cloud Run上であれば、認証情報は取得できている
        client = secretmanager.SecretManagerServiceClient()

    path = client.secret_version_path(os.environ['GCP_PROJECT_ID'], 'twitter_tokens', 'latest')
    response = client.access_secret_version(name=path)
    value = response.payload.data.decode('UTF-8')

    # strからdictにする
    return json.loads(value)

 

秘匿するまでもないが環境変数として設定したいものはデプロイ時に設定する

Litestremaの REPLICA_URL など、秘匿するまでもないが環境変数として設定したいものはデプロイ時に設定するようにします。

そのため、デプロイ時に

gcloud beta run deploy ringo-tabetter \
  --set-env-vars REPLICA_URL=gcs://*** \

として環境変数を設定し、ソースコードでは os.environ環境変数から値を取得できるようにします。

 

Cloud Loggingへログを出力できるようにする

デフォルトの設定では、Cloud Loggingにログを出力できません。

そのままではトラブルシューティングがやりづらいので、以下を参考に google-cloud-logging を使ってCloud Loggingにログ出力できるようにします。

 
本番のsettings.pyに以下を追加します。

LOGGING = {
    'version': 1,
    'disable_existing_loggers': False,
    'formatters': {
        'verbose': {
            'format': '{module} {message}',
            'style': '{',
        },
    },
    'handlers': {
        'cloud_logging': {
            'class': 'google.cloud.logging.handlers.CloudLoggingHandler',
            'client': google.cloud.logging.Client(),
            'formatter': 'verbose'
        }
    },
    'loggers': {
        'django': {
            'handlers': ['cloud_logging'],
            'level': 'INFO'
        },
    }
}

 

Cloud Schedulerを設定する

Cloud Schedulerを使い、自分のツイートを定期的に収集します。

ただ、Heroku Schedulerとは異なり、Cloud SchedulerではDjangoのカスタムコマンドを実行できないことから

  • Cloud SchedulerからHTTPリクエストができるDjangoのエンドポイントを作成
  • Djangoのエンドポイントの中で、カスタムコマンドを実行

としました。

また、

  • Cloud Schedulerの設定
  • google-auth を使って、Cloud Schedulerのリクエストを受け付けるエンドポイント作成

については、以前の記事通りです。
Django + google-authで、Google Cloud SchedulerからのHTTPリクエストのみ受け付けるAPIエンドポイントを作成する - メモ的な思考的な

 
以前の記事と異なるのは、Djangoでリクエストを受け付けたら、 call_command でカスタムコマンドを実行する必要があることです。
Running management commands from your code | django-admin と manage.py | Django ドキュメント | Django

ソースコードはこんな感じです。

class GatherTweetView(View):
    """ Cloud Schedulerからのリクエストを受け付ける View """

    http_method_names = ['get']
    client_id = os.environ['SCHEDULER_CLIENT_ID']

    def get(self, request, *args, **kwargs) -> RingoJsonResponse:
        authz_header = request.headers.get('Authorization')
        received_id_token = authz_header.replace('Bearer', '').lstrip()

        try:
            # 認証
            id_token.verify_oauth2_token(received_id_token, requests.Request(), self.client_id)

            # tweetの収集
            call_command('gather_tweets')

            return RingoJsonResponse({
                'status': 'success'
            })
        except ValueError as e:
            print(e)
            return RingoJsonResponse({
                'status': 'unauthorized'
            })

 

Google Domainsで購入したドメインを設定

Herokuと異なり、Cloud RunではURLが ringo-tabetter-***-**.a.run.app と、ランダムな文字列が含まれてしまいます。

そこで、 Google Domains で購入したドメインサブドメインDjangoアプリを割り当ててみます。

 
Cloud Runをカスタムドメインで動かすため、Cloud Runの カスタムドメインの管理 にて マッピング を追加します。

以下の内容で設定します。

項目
マッピングするサービス Cloud Runで作成したringo-tabetterアプリを選択
確認済ドメイン thinkami.dev
サブドメインを指定 ringo-tabetter

 
設定するとGoogle DomainsのDNSに追加するDNSレコードの内容が表示されます。

そのDNSレコードをGoogle DomainsのDNSに設定し、しばらく待つとCloud Runへのマッピングが終わります。

 

専用のサービスアカウントを作成

デフォルトのサービスアカウントでは権限が広めなため、使うものだけに制限したサービスアカウントを新規作成します。

今回は、Cloud Run用とCloud Scheduler用の2つを用意し、それぞれのサービスに割り当てます。

なお、サービスアカウント作成後にサービスアカウントのロールを変更したい場合は、サービスアカウントのメニューではなく、IAMから行うようです。

 

Cloud Run用

以下のロールを持つサービスアカウントを作成します。

ロール名 目的 公式ドキュメント
Cloud Run管理者 Cloud Run実行 Cloud Run IAM roles  |  Cloud Run Documentation  |  Google Cloud
Secret Managerのシークレットアクセサー Secret Managerの読み取り IAM を使用したアクセス制御  |  Secret Manager のドキュメント  |  Google Cloud
ストレージ管理者 Litestreamなどで、バケット・オブジェクトの読み書き Cloud Storage に適用される IAM のロール  |  Google Cloud
ログ書き込み Cloud Loggingへの書き込み IAM によるアクセス制御  |  Cloud Logging  |  Google Cloud

 

Cloud Scheduler用

Google Cloud のリソースにアクセスするわけではないので、ロールの割当はせず、サービスアカウントのみ作成します。

 

Djangoのsettings.pyを修正

SECRET_KEY

Google Cloudへデプロイするにあたり、 SECRET_KEY を再生成することにしました。

そこで、以下のstackoverflowを参考に、Django shellにて SECRET_KEY を再生成しました。
Effects of changing Django's SECRET_KEY - Stack Overflow

再生成した SECRET_KEY は、デプロイ時に環境変数へ設定してDjangoアプリ起動時に読み込めるようにします。

 

Cloud Runへのデプロイコマンド

gcloud beta run deploy コマンドでデプロイします。

gcloud beta run deploy ringo-tabetter \
  --source .  \
  --set-env-vars REPLICA_URL=gcs://<Cloud Storageのバケットパス> \
  --set-env-vars SCHEDULER_CLIENT_ID=<Cloud SchedulerのクライアントID> \
  --set-env-vars GCP_PROJECT_ID=<Google CloudのprojectId> \
  --set-env-vars GS_BUCKET_NAME=<静的ファイルのバケット> \
  --set-env-vars TWITTER_USER_ID=<ツイート収集対象のTwitter User ID> \
  --set-env-vars DJANGO_SECRET_KEY=<シークレットキー> \
  --max-instances 1 \
  --execution-environment gen2 \
  --no-cpu-throttling \
  --allow-unauthenticated \
  --region us-west1 \
  --service-account <Cloud Runのサービスアカウント> \
  --project <Google CloudのプロジェクトID>

 

動作確認

https://ringo-tabetter.thinkami.dev にアクセスしたところ、グラフが表示されました*1

 
なお、「カスタムドメインだけアクセス可」とはしていないので、Cloud Runで払い出されたURLでもアクセスできます。
https://ringo-tabetter-syqtxyot6q-uw.a.run.app

Cloud Runから払い出されたURLをアクセス不可にする場合は、以下が参考になるかもしれません。

 

不要なリソースの削除

(2023/1/4 追記)

Cloud Runへデプロイした場合、Cloud StorageやArtifact Registryに都度オブジェクトが生成されます。

ただ、そのまま放置しておいてもデータは削除されず課金されてしまいます。

そこで、過去のデプロイデータなど不要なものを削除します。

 

Cloud Storage

Cloud Storageには Cloud Runへデプロイする際の Cloud Build データが生成されます。

ただ、Artifact Registryへの生成が済んでしまえばCloud Buildデータは不要になるようです。
Cloud Functions for Firebaseの利用で、異様にGCPのStorageが消費されると思ったら..

そのため、Cloud Build用のバケット (***_cloudbuild) のオブジェクトを削除するようにします。

ただ、手動で削除するのは手間なので、簡単に設定できるCloud Storageのライフサイクルルールでオブジェクトを削除するようにします。

上記参考記事に従い、以下を設定します。

項目 内容
アクション オブジェクトの削除
オブジェクトの条件 オブジェクトが生成されてから1日以降

 

Artifact Registry

こちらもビルドされたデータが残り続けるため、定期的に削除します。

ただ、Cloud Storageとは異なり、簡単に設定できるものがないことから、公式ドキュメントでは gcr-cleaner が案内されています。
イメージを管理  |  Artifact Registry のドキュメント  |  Google Cloud

gcr-cleaner については、以下の記事で詳しく解説されていました。
Cloud Build+Cloud Runでできた不要なContainer Registryを自動削除する(gcr-cleaner) - くらげになりたい。

ただ、現時点ではアプリのデプロイ回数が少ないことから、まだ gcr-clerner の設定はせず、手動で削除するようにしています。

 

その他

今回やったことの範囲外かもしれませんが、以下の記事も参考になりました。

 
最後になりましたが、長い間Herokuにお世話になりました。ありがとうございました。

 

ソースコード

Githubに上げました。
https://github.com/thinkAmi/dj_ringo_tabetter

今回のプルリクはこちらです。
https://github.com/thinkAmi/dj_ringo_tabetter/pull/20

*1:リダイレクトされるので、実際には https://ringo-tabetter.thinkami.dev/hc/total が表示されます