Github Actionsでdbt Docsを生成しApp Engine上でホスティングする

:::note info
本記事は [ZOZO Advent Calendar 2024](https://qiita.com/advent-calendar/2024/zozo) シリーズ 4 の 5 日目の記事です。
:::

# 概要
以前、[Github Actionsでdbt Docsを生成しGithub Pages上でホスティングする](https://qiita.com/i-125/items/e2e70e8c7bb88a6f291b)という記事を公開しました。
しかし Github Pages サイトをパブリック公開せず、リポジトリにアクセスできるユーザーだけに公開する設定にした場合、閲覧するためには Github アカウントを保持している必要があります。
データ基盤利用者のうち、必ずしもすべての利用者が Github アカウントを保持しているわけではないため、利便性を考慮して App Engine 上でホスティングするようにしました。
今回は App Engine 上でのホスティングを利用しつつ、Github Actions を使って main ブランチにマージした内容で`dbt docs`コマンドを実行しサイト反映する方法を紹介します。
※本記事では、アプリケーションおよび CI の実装にフォーカスし、Google Cloud のリソース実装の方法自体には触れません。

# アーキテクチャ
アーキテクチャの全体像を以下に示します。
![hosting.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/221323/e9b99f46-6253-5db4-f69a-e62eed5405fc.png)

この仕組みのポイントは以下の3つです。
- ドキュメント生成（GitHub Actionsで自動化）
  - mainブランチへのマージをトリガーに`dbt docs generate`コマンドを実行し、html ファイルなどを生成
  - 生成されたHTMLファイルを Cloud Storage バケット にアップロード
- ホスティング（Google App Engineを活用）
  - App Engine 上のアプリケーションが、常に Cloud Storage バケット内のHTML を参照して提供
  - この仕組みにより、`dbt docs generate`コマンドの成果物を Cloud Storage にアップロードするだけで、ドキュメントの更新が可能
- アクセス制御（Identity-Aware Proxyの活用）
  - Identity-Aware Proxy (IAP) を App Engine の手前に配置することにより、アクセスするユーザーには必ず認証を要求
  - 認証を通じて、社内メンバーのみがドキュメントサイトにアクセスできるように制御

当初 Cloud Run で実装することを考えていましたが、今回は事前に用意されているランタイム環境で十分かつ実装・運用がシンプルな App Engine で良いと判断しました。

# Google Cloud のリソース実装
:::note info
本記事では App Engine をスタンダード環境(ランタイム:`python312`)で構成することを前提としています。
:::

本記事では詳しい実装方法については割愛します。
今回 Cloud Run ではありませんが、IAP の適用は[Cloud Run+IAP(Identity-Aware Proxy)構成をTerraformで管理する](https://alpacat.com/posts/cloudrun-iap-with-terraform)の記事を参考にさせていただきました。
IAP によってドメイン単位での閲覧アクセスを許可することで、同一ドメインのメールアドレスを持つ全社員がドキュメントにアクセスできます。

# App Engine のアプリケーション実装
:::note info
App Engine のデプロイには gcloud コマンドの実行が必要です。
事前に Google Cloud CLI をインストールしてください。
https://cloud.google.com/sdk/docs/install#mac
:::

dbt Docs のホスティングに必要なアプリケーションを用意します。
Cloud Storage バケットの html を Python+flask で取得して表示するアプリケーションを実装します。
App Engine で静的ウェブサイトをホスティングする方法は Google 公式ドキュメントがありますので、一般的に必要な構成としてそちらも参照ください。

https://cloud.google.com/appengine/docs/legacy/standard/php/getting-started/hosting-a-static-website

## ファイルの作成
以下のように実装します。
```requirements.txt
-i https://pypi.org/simple
blinker==1.7.0; python_version >= '3.8'
cachetools==5.3.3; python_version >= '3.7'
certifi==2024.2.2; python_version >= '3.6'
charset-normalizer==3.3.2; python_full_version >= '3.7.0'
click==8.1.7; python_version >= '3.7'
flask==3.0.2; python_version >= '3.8'
google-api-core==2.18.0; python_version >= '3.7'
google-auth==2.29.0; python_version >= '3.7'
google-cloud-core==2.4.1; python_version >= '3.7'
google-cloud-storage==2.16.0; python_version >= '3.7'
google-crc32c==1.5.0; python_version >= '3.7'
google-resumable-media==2.7.0; python_version >= '3.7'
googleapis-common-protos==1.63.0; python_version >= '3.7'
gunicorn==21.2.0; python_version >= '3.5'
idna==3.6; python_version >= '3.5'
itsdangerous==2.1.2; python_version >= '3.7'
jinja2==3.1.3; python_version >= '3.7'
markupsafe==2.1.5; python_version >= '3.7'
packaging==24.0; python_version >= '3.7'
proto-plus==1.23.0; python_version >= '3.6'
protobuf==4.25.3; python_version >= '3.8'
pyasn1==0.5.1; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4, 3.5'
pyasn1-modules==0.3.0; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4, 3.5'
requests==2.31.0; python_version >= '3.7'
rsa==4.9; python_version >= '3.6' and python_version < '4'
six==1.16.0; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'
urllib3==2.2.1; python_version >= '3.8'
werkzeug==3.0.1; python_version >= '3.8'
```

```app.yml
runtime: python312 # 使用するランタイムを指定
service: dbt-docs # サービス名を指定
entrypoint: gunicorn -b :$PORT main:app

env_variables:
  CLOUD_STORAGE_BUCKET: "bucket-name" # GCSバケット名を指定

handlers: # ハンドラの設定
- url: /static # /static に対するリクエストを static ディレクトリにマッピング
  static_dir: static
- url: /.* # 上記以外のリクエストは全て main.py にマッピング
  script: auto
  ```
``` main.py
from flask import Flask, Response
from google.cloud import storage
import logging
import os
import tempfile
from typing import Any, Tuple

app = Flask(__name__)


def get_bucket() -> storage.Bucket:
    storage_client = storage.Client()
    return storage_client.get_bucket(os.environ.get("CLOUD_STORAGE_BUCKET"))


def download_and_read_file(bucket: storage.Bucket, file_name: str) -> str:
    blob = bucket.blob(file_name)

    with tempfile.NamedTemporaryFile(
        delete=False, dir="/tmp", suffix=f'.{file_name.split(".")[-1]}'
    ) as temp:
        blob.download_to_filename(temp.name)
        with open(temp.name, "r") as file:
            file_content = file.read()

    return file_content

# GCSから読み込んだ index.html を返す
@app.route("/")
def serve_index() -> Response:
    bucket = get_bucket()
    file_content = download_and_read_file(bucket, "index.html")
    return Response(file_content, mimetype="text/html")


# GCSから読み込んだ catalog.json を返す
@app.route("/catalog.json")
def serve_catalog() -> Response:
    bucket = get_bucket()
    file_content = download_and_read_file(bucket, "catalog.json")
    return Response(file_content, mimetype="application/json")


# GCSから読み込んだ manifest.json を返す
@app.route("/manifest.json")
def serve_manifest() -> Response:
    bucket = get_bucket()
    file_content = download_and_read_file(bucket, "manifest.json")
    return Response(file_content, mimetype="application/json")


@app.errorhandler(500)
def server_error(e: Any) -> Tuple[str, int]:
    logging.exception("An error occurred during a request.")
    return (
        """
    An internal error occurred: <pre>{}</pre>
    See logs for full stacktrace.
    """.format(
            e
        ),
        500,
    )


if __name__ == "__main__":
    app.run(host="127.0.0.1", port=8080)
```

ポイントとしては、以下3つです。

1. /tmpディレクトリの活用
App Engine のファイルシステムは基本的に 読み取り専用 ですが、/tmp ディレクトリのみ書き込み可能です。Cloud Storage から読み込んだ Blob データを App Engine 上で一時的に保存する場合、必ず/tmp配下を使用する必要があります。この制約に対応するため、アプリケーションで /tmp 配下を参照するルーティングを実装しました。
(しかしわざわざ /tmp ディレクトリを活用する以上は、キャッシュ活用するロジックを入れた方が良いです。こちらは検証ができたら記事を更新します。）

2. app.yamlの制約
App Engine の app.yaml ファイルでは、/tmp ディレクトリに配置されたファイル（例: catalog.jsonやmanifest.json）を直接参照することができません。
このため、アプリケーションコード内で明示的に /tmp のファイルを読み取るロジックを組み込む必要があります。

3. /tmpディレクトリの特性
/tmp は書き込み可能な唯一のディレクトリですが、リクエストやインスタンス間で内容が共有されない仕様です。つまり、あるリクエストで書き込んだデータは、別のリクエストや別インスタンスでは参照できません。この特性を考慮し、Blob データを必要なタイミングで Cloud Storage から都度取得する仕組みを採用しました。

もしアプリケーションコードをカスタマイズしたい場合は、App Engine の静的ファイルの保存と提供についての Google 公式ドキュメントがありますので、こちらも参照ください。

https://cloud.google.com/appengine/docs/standard/serving-static-files?tab=python

最後に App Engine のデプロイを行います。
```
gcloud app deploy
```

# Github Actions の実装
Github Actions は以下のように実装します。
dbt Docs のホスティングに必要なファイル(dbt artifacts)は以下の3つなので、それらを一時的に生成される target ディレクトリ配下から、先ほど作成した Cloud Storage バケット配下にコピーしています。
 - index.html
 - catalog.json
 - manifest.json
 
```yaml
    - name: Generate dbt docs
      run: |
        dbt docs generate
    
    - name: Upload dbt docs to GCS
      run: |
        gsutil cp target/index.html gs://bucket-name # 作成したGCSバケット名に置換する
        gsutil cp target/catalog.json gs://bucket-name # 作成したGCSバケット名に置換する
        gsutil cp target/manifest.json gs://bucket-name # 作成したGCSバケット名に置換する
```
App Engine 上の WEBアプリケーションが都度、Cloud Storage 上の html を取得して参照するので、main ブランチに対してマージする度に最新情報がホスティングサイトに反映されます。
実際に意図したページが表示されているかどうかはホスティング URL にアクセスしてご確認ください。

# まとめ
本記事では dbt のドキュメントを共通管理するために、Github Actions を使って App Engine 上でdbt Docs をホスティングする方法を紹介しました。
さすがに Github Pages でホスティングするよりもやることは増えますが、IAP によってドメイン単位での閲覧アクセスを許可することできるのが便利です。
もし誤りや他に良い方法があれば、ぜひコメントよろしくお願いいたします。