現役選手ドラフトのルールと, 最近のトレード多すぎなアレで現役選手ドラフトの分析を諦めたマンです.*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ツールです.
「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コマンド一発でイケます.
上記の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で作りました.
ちなみに絵図にするとこういう感じです.
先に結論から書きます, .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- Workload Identityを使ってGoogle Cloudに認証*4. なお, 事前準備としてGitHub Actions からのキーなしの認証の有効化および, Workload Identity連携の設定を済ませておく(初回のみ)
- Docker Buildしてartifactsに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を使って動かしたりしている例もあります.
ライブラリのインストールさえクリアできれば何とかなりそうですね*6.
また, 私が挙げた例だと「一旦Docker buildしてimageにしてからコンテナ化」なので, 理屈上はAWS App Runnerなど, 他の「コンテナを起点としたサーバレス系サービス」でも行けそうだなと思っています.
試す機会があればやってみようかと.
結び
というわけで, 「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系全体の観点として.