Lean Baseball

No Engineering, No Baseball.

Dashで作った分析ダッシュボードをCloud Runでサクッと運用する

現役選手ドラフトのルールと, 最近のトレード多すぎなアレで現役選手ドラフトの分析を諦めたマンです.*1

それはさておき, 私はつい先日, 「メジャーリーグのデータ分析・可視化をカジュアルにいい感じにやるためのデータ基盤が欲しい」と思い, 以下のような野球データ基盤を作りました.

野球データ基盤

ダッシュボード(このブログのメインテーマ)

基盤そのもののアーキテクチャやデータ関連の話題はこちらのブログおよび, PyConJP 2022の発表資料をご覧頂ければと思いますが, このダッシュボードを開発するにあたり,

  • Dash(分析ダッシュボード本体, 言語はPython)
  • Cloud Run(フルマネージド・サーバレスなコンテナ実行環境)
  • GitHub Actions(アプリケーションの自動テストとデプロイ, 所謂CI/CDの実行環境)

を使って「作ったものをサクッとデプロイして公開」するような感じにしたのですが, Dashをホストしてアプリケーションにする(もしくはカスタマイズ可能なダッシュボードはDashで作れますよ)という情報が思ったより少なくかつ, まあまあハマったのでTipsとして残したいと思います(というのがこのエントリーの主旨です*2).

ちなみに今日の「おしながき」はこちらとなります.

TL;DR

ひとまずDocker Containerにしてしまえば, どこでも動かせると思います.

なお, ホスト先はGoogle CloudでもAWSでもAzureでも何でも良いかと.

このエントリーの対象読者

「Dashを使ってるけどこれをどこかのサーバー・クラウドで動かしたい」方がメインターゲットとなります.

Dashで何ができるか?どうやって動かせるか??みたいな興味がある方も良いかと.

なお, Cloud Run, GitHub Actionsが何者か等の説明は端折っていますので, 知らない物については適宜調べながら読むことをおすすめします&DashはPython版を前提としてお話をします.

Dash is 何?

データ可視化(グラフを描いたり動かしたり)のライブラリとして, 天下取ったかもしれない説あるPlotlyをアプリケーションと作って運用するためのFrameworkもしくはLow Codeツールです.

dash.plotly.com

「Low-Code Data Apps」と本家で名乗ってるのですが正にそのとおりでして,

  • まずPlotlyでグラフを描きます(Jupyter notebookとかで)
  • DashでひとまずHello world的なアプリを作ります
  • Plotlyで描いたグラフとDashアプリをマージします

というような順序でサクッとデータ可視化アプリケーションが完成します.

ちなみにHello worldはこんな感じです(本家サイトより引用)

# Run this app with `python app.py` and
# visit http://127.0.0.1:8050/ in your web browser.

from dash import Dash, html, dcc
import plotly.express as px
import pandas as pd

app = Dash(__name__)

# assume you have a "long-form" data frame
# see https://plotly.com/python/px-arguments/ for more options
df = pd.DataFrame({
    "Fruit": ["Apples", "Oranges", "Bananas", "Apples", "Oranges", "Bananas"],
    "Amount": [4, 1, 2, 2, 4, 5],
    "City": ["SF", "SF", "SF", "Montreal", "Montreal", "Montreal"]
})

fig = px.bar(df, x="Fruit", y="Amount", color="City", barmode="group")

app.layout = html.Div(children=[
    html.H1(children='Hello Dash'),

    html.Div(children='''
        Dash: A web application framework for your data.
    '''),

    dcc.Graph(
        id='example-graph',
        figure=fig
    )
])

if __name__ == '__main__':
    app.run_server(debug=True)

Dash for Pythonのざっくりな構成・仕組みですが,

  • FlaskベースのWeb アプリケーションとして動いている(≒困ったときはFlaskでのWeb開発のアプローチが使える)
  • HTMLやJavaScriptのレンダリングはDashが用意している組込みのモジュール(html.Divとかhtml.H1みたいなのが用意されている)を使う&ここにオブジェクト(文字列やPlotlyのfigureなど)を渡せばいい感じに描画してくれる. かつ, Callbackを使って特定箇所だけ書き換えなどできる(なぜならDashがやってくれてるのはSPAだから)等, 非常に使い勝手が良い*3.
  • 自分でHTMLやJavaScriptの実装はいらない(すべてDashのモジュールを使ってPythonでかけばいい)一方, HTMLやJavaScriptを自分で書きたくなったときの対応は結構きつい(というかやらないほうが賢明)

という感じです.

ちなみにDash自体はPythonの他, 「R」「Julia」「F#」といった選択肢が用意されています&もっと詳しく知りたい方は「Pythonインタラクティブ・データビジュアライゼーション入門」という良い本があるのでこちらを参考にすると良いでしょう.

Dashアプリケーションを運用する

Dashそのもののアプリケーション開発はPythonとPlotly, Callbackの動きを上手く理解できたらイケると思いますが,

ホストして運用するまでに必要な事は結構あります.

これを順を追って解説します.

  • Image Build
  • Cloud Runにデプロイする
  • GitHub Actionsを使ったデプロイ

ちなみに, Dashのデモアプリの部分のアーキテクチャはこんな感じです.

このエントリーのプロダクト(実在するやつ)

  • DashのアプリはCloud Runにホスト. Cloud Buildでビルド→そのままデプロイ. ←このエントリーで触れる内容
  • Dashアプリにはデータを持っていないので, データはBackend API(RESTful)を叩いて取得.
    • Dashアプリ内にデータを取得するhttp clientが存在, 取得したやつをPandas DataFrameにしてアプリ内で利用
  • Backend APIの認証にAPI Gateway, データ本体はDatabase(Firestore)に保存済み

ざっくり説明するとこんな感じです&このエントリーで扱うのは主に太字の部分(Cloud Build -> Cloud Runへのホスト)となります.

Image Build

これはシンプルでして, FlaskやDjangoでWebアプリケーションを作ってDockerで動かすときと全く同じです.

Dockerfileはこんな感じです(ちなみにですが, パッケージの管理にpoetryを使っています).

# ここはビルド用の設定
FROM python:3.10-slim-buster as builder

WORKDIR /opt/app

RUN pip3 install poetry
COPY poetry.lock pyproject.toml poetry.toml ./
RUN poetry install --no-dev

# ここからは実行用imageの準備
FROM python:3.10-slim-buster as runner

# 起動に必要な設定の数々
ENV PYTHONPATH "${PYTHONPATH}:/opt/app/app"
RUN useradd -r -s /bin/false appuser
WORKDIR /opt/app

# builderの結果をコピー
COPY --from=builder /opt/app/.venv /opt/app/.venv
COPY app ./app
USER appuser
EXPOSE 8000
CMD ["/opt/app/.venv/bin/gunicorn", "-w", "2", "--log-level", "warning", "--access-logfile", "-", "--bind", ":8000", "app:server"]

ポイントとしては,

  • この後の「Cloud Runへのデプロイ」のため, 8000 Portを開けておく
  • 本番環境なので, ちゃんとしたWeb Serverを使う(Flaskの開発Serverは使わない). 具体的にはgunirornなどのWSGI対応Serverを使う

FlaskベースのWeb アプリケーションとして動いている(≒困ったときはFlaskでのWeb開発のアプローチが使える) のがDash for Pythonの良いところなので, 「足りないと思ったものはFlaskでのやり方を参考にアレンジしてやってみる」といい感じです.

Cloud Runにデプロイする

Cloud Runへのデプロイは(他のGoogle Cloudサービス同様)gcloudコマンド一発でイケます.

cloud.google.com

上記のQuick Startを真似してやるのも手ですが, 最近はArtifact Registryで管理するのがイケてるらしいので, そのやり方を取ってみました.

と言っても, やったことはCloud Buildの定義を作って向け先を変更しただけです.

# cloudbuild.yamlの中身

steps:
  - name: "gcr.io/cloud-builders/docker"
    args:
      [
        "build",
        "-t",
        "asia-northeast1-docker.pkg.dev/$PROJECT_ID/${_REPOSITORY}/${_IMAGE}:${_TAG}",
        ".",
      ]
images:
  - "asia-northeast1-docker.pkg.dev/$PROJECT_ID/${_REPOSITORY}/${_IMAGE}:${_TAG}"

cloudbuild.yamlを保存, 以下コマンドを実行

# image build
$ gcloud builds submit --config=cloudbuild.yaml \
   --substitutions=_REPOSITORY=test_application,_IMAGE=dashboard,_TAG=${your tag} .

# deploy
$ gcloud run deploy --image asia-northeast1-docker.pkg.dev/test_application/${your repository}/dashboard:${your tag} \
  --platform managed \
  --port 8000 \
  --memory 4Gi \
  --cpu 2 \
  --max-instances 4 \
  --min-instances 0

ちなみに上記のコマンドは要約すると, 「イメージをビルドした後, 指定したinstanceタイプ(2CPU/4GiB)8000portにリクエストを流してね(最大instance4つ, コールドスタンバイ)」となります.

GitHub Actionsを使ったデプロイ

これで手動デプロイはイケると思いますが,

  • mainブランチにpushしたらテスト→デプロイというCI/CDにしたい
  • コミットごとにテストを流したい

という継続的インテグレーションは欲しいと思い, Github Actionsで作りました.

ちなみに絵図にするとこういう感じです.

Actionsを使ったパイプライン

先に結論から書きます, .github/workflows/${任意のお名前, 何でもいい}.yaml をこんな感じで作ると「テストした後デプロイ」ができます.

# .github/workflows/dashboard.yaml というパス・名前で保存しています.
name: Dashboard
on: push
defaults:
  run:
    working-directory: dashboard
env:
  PROJECT_ID: ${{ secrets.GCP_PROJECT_ID }}
  REPOSITORY: test_application
  SERVICE_NAME: dashboard
  REGION: asia-northeast1
jobs:
  test:
    name: test
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      - uses: actions/setup-python@v4
        with:
          python-version: 3.10.6
      - name: Run image
        uses: abatilo/actions-poetry@v2.0.0
        with:
          poetry-version: 1.1.15
      - name: Install & test
        run: poetry install
      - name: type check
        run: poetry run mypy app/*.py app/visualization_module/*.py app/cloud/*.py
      - name: code check
        run: poetry run flake8 .
      - name: Set env
        run: echo "PYTHONPATH=./app" >> $GITHUB_ENV
      - name: Run test
        run: poetry run pytest .
  build:
    name: Build
    runs-on: ubuntu-latest
    needs: test
    if: (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/dev')
    permissions:
      contents: "read"
      id-token: "write"
    steps:
      - id: "checkout"
        name: "Checkout"
        uses: actions/checkout@v3
      - id: "auth"
        name: "Authenticate to Google Cloud"
        uses: "google-github-actions/auth@v0"
        with:
          token_format: "access_token"
          workload_identity_provider: "${{ secrets.GCP_WID_PROVIDER }}"
          service_account: "${{ secrets.GCP_SERVICE_ACCOUNT }}"
      - id: "docker-auth"
        name: Authorize Docker
        uses: "docker/login-action@v1"
        with:
          username: "oauth2accesstoken"
          password: "${{ steps.auth.outputs.access_token }}"
          registry: "${{ env.REGION }}-docker.pkg.dev"
      - id: "docker-build"
        name: Build Docker image
        run: docker build -t "${{ env.REGION }}-docker.pkg.dev/${{ env.PROJECT_ID }}/${{ env.REPOSITORY }}/${{ env.SERVICE_NAME }}:${{ github.sha }}" .
      - id: "docker-push"
        name: Push Docker Image
        run: docker push "${{ env.REGION }}-docker.pkg.dev/${{ env.PROJECT_ID }}/${{ env.REPOSITORY }}/${{ env.SERVICE_NAME }}:${{ github.sha }}"
  deploy:
    name: Deploy
    runs-on: ubuntu-latest
    needs: build
    if: github.ref == 'refs/heads/main'
    permissions:
      contents: "read"
      id-token: "write"
    steps:
      - id: "checkout"
        name: "Checkout"
        uses: actions/checkout@v3
      - id: "auth"
        name: "Authenticate to Google Cloud"
        uses: "google-github-actions/auth@v0"
        with:
          workload_identity_provider: "${{ secrets.GCP_WID_PROVIDER }}"
          service_account: "${{ secrets.GCP_SERVICE_ACCOUNT }}"
      - id: "deploy"
        name: "Deploy to Cloud Run"
        uses: "google-github-actions/deploy-cloudrun@v0"
        with:
          service: "${{ env.SERVICE_NAME }}"
          region: "${{ env.REGION }}"
          image: "${{ env.REGION }}-docker.pkg.dev/${{ env.PROJECT_ID }}/${{ env.REPOSITORY }}/${{ env.SERVICE_NAME }}:${{ github.sha }}"
          env_vars: "GCP_LOGGING_NAME=${{ secrets.GCP_LOGGING_NAME }}, API_BASE_URL=${{ secrets.API_BASE_URL }}, DATE_START=${{ env.DATE_START }}, DATE_END=${{ env.DATE_END }}"
          flags: "--cpu=4 --memory=4Gi --min-instances=0 --max-instances=3 --port=8000"
          secrets: "GCP_AUTH=${{ secrets.GCP_AUTH }}, API_KEY=${{ secrets.API_KEY }}, BASIC_AUTH_USER=${{ secrets.BASIC_AUTH_USER }}, BASIC_AUTH_PASSWORD=${{ secrets.BASIC_AUTH_PASSWORD }}"
      - id: "output"
        name: Show Output
        run: echo ${{ steps.deploy.outputs.url }}

jobごとにやってることは以下の通りです.

  • test jobでテスト実行(すべてのケースで実行)
    • Python3.10環境を作る&必要なパッケージをインストール
    • mypy, flake8, pytestを順番に実行
  • build jobでCloud Buildを使ってDocker Build -> artifacts(image)をpush
  • deploy jobでCloud Runにデプロイ
    • Workload Identityを使ってGoogle Cloudに認証(仕組みはbuild時と全く同じ).
    • artifactsからimageを引っ張ってコンテナをデプロイ

この一連の処理をGitHub Actionsですべて行うことができます.

なお, このworkflowの構築は結構ハマりました(のでtipsとして書いてみました).*5

おまけ: Cloud Run以外のサービス候補

私の個人開発・プロダクトの運用環境は(余程のことが無い限り)Google Cloudでの運用にしているので, 今回はCloud Runにしましたが.

コンテナ上で動くアプリケーションなので, 他のパブリッククラウド・PaaSでも基本的に動作する(はず)です.

Dash関連の事例を見ると, 以下のエントリーではHerokuを使って動かしたりしている例もあります.

helve-blog.com

ライブラリのインストールさえクリアできれば何とかなりそうですね*6.

また, 私が挙げた例だと「一旦Docker buildしてimageにしてからコンテナ化」なので, 理屈上はAWS App Runnerなど, 他の「コンテナを起点としたサーバレス系サービス」でも行けそうだなと思っています.

aws.amazon.com

試す機会があればやってみようかと.

結び

というわけで, 「DashとCloud Runを使ったデータダッシュボード運用とCI/CD」という話でした.

本格的なデータ分析ダッシュボードだと, TableauやLockerの導入から入ればいいのかなと思いますが, コスト的な課題だったりCustomize要件が多いときはやっぱりDash(と一昔前ならRedash)が便利だったりするので, セルフホスティングして運用云々...みたいな手段は有効だろうと思っています, 強くはおすすめしませんが.

個人的には野球ネタで作ってるこの辺のダッシュボードはいずれファンの方が使えるデータサイトとして整備できればなと思っています.

ということで次回作にご期待ください, 最後までお読み頂きありがとうございました.

*1:今年の最初にやりたい宣言をしていたかつ, 周りからの期待値・リクエストもあったのですがわからんすぎるので一年見送ることに. あと, 最近の中日トレードの動きよ(以下略)

*2:ググるとわかるのですが, Dashアプリケーションの作り方を探すのには困らないものの,OSS版をホストして運用というTipsが思ったより少なく...これは残したほうが良さそうと思い書いてみました.

*3:使い勝手が良いのは事実ですが, グラフや描画するオブジェクトが増えたり, 複数のCallbackトリガーがあると実装が煩雑&見栄えも悪くなるというCallback地獄も待っているので容量用法には気をつけたほうが良いかもしれません笑

*4:credential.jsonを作って中身をGitHub Actionsのsecretsに保存して認証する方式もありますが, こちらは非推奨かつセキュリティリスクも若干あるのでオススメしません.

*5:テストとデプロイはサクッと行けたのですが, ビルドを挟む所が正直難儀でした...

*6:Google App Engine(GAE)とかもそうですが, PaaSだとインストール可能なライブラリの制限がある可能性が高いので, 依存するライブラリ・フレームワーク次第ではうまく行かない可能性が高いと思ったほうがいいです, これはDashに限らずPaaS系全体の観点として.