public note

Streamlit on Cloud Run with Identity-Aware Proxy (IAP)

タイトルのとおり、Cloud Run で Streamlit を動かしてみました。また、特定の人のみがアクセスできるように、Identity-Aware Proxy(IAP) での保護を試しましたので、その設定やコードを紹介します。

Cloud Run で動かすのはすぐにできたのですが、複数の Streamlit コードを認証付きでいい感じにホストする手段を探すのにかなり苦戦しました...

Streamlit

Streamlit は、Web アプリケーションを簡単につくることができる Python モジュール です。

これは公式の GitHub リポジトリで紹介されているサンプルコードですが、

import streamlit as st

x = st.slider('Select a value')
st.write(x, 'squared is', x * x)

このコードを書いて streamlit run app.py とコマンドを実行するだけで、 Web アプリケーションサーバーが起動します。

https://raw.githubusercontent.com/streamlit/streamlit/develop/docs/_static/img/simple_example.png

個人の感想ですが、以下の点が便利だと感じました。

  • Jupyter Notebook(Google Colaboratory) で実装したコードを、複数人で見やすい形にして共有できる
  • コントロールの設置や入力値の取得を直感的に実装できる
  • st.write(object) のようにすると、Matplotlib や Plotly のグラフをそのままアプリケーションに表示できる
    • Plotlyの場合、通常と同じインタフェースなので操作感は変わらない
  • キャッシュ機構を備えており、その対象やクリアのタイミングを制御できる
  • つまり僕のようなフロントエンドぴよぴよくんでも簡単に扱える

Streamlit については紹介記事がたくさん見つかると思いますので、本題に戻ります。

構成図

f:id:ts223:20210806225225p:plain

Cloud Run を IAP で保護するには、その前段に Cloud Load Balancing を設置します。負荷分散というよりは、認証機能とマッピング機能をもたせることが目的です。

External HTTP(S) ロードバランサでリクエストを受けてバックエンドの Cloud Run に振り分け、複数の異なる Streamlit コードをホストできるようにしています。

なお、ドメイン取得や DNS は、ムームードメインを利用しましたので、後述のコードには含まれていません。

ソースコード

Cloud Run や Cloud Load Balancing、サービスアカウントなどは、Terraform で構築しました。*1

詳細については、こちらのリポジトリをご参照ください。

github.com

リクエストから Streamlit が起動するまで

この構成におけるリクエストの流れについて少し解説します。例として、https://example.com/streamlit-run-backend01 の URL にリクエストが来た場合について考えます。

まず最初に、アクセスしてきたアカウントがバックエンドサービスを起動する権限をもった Google アカウントであるかを確認します。権限がなければ 403 を返します。

権限をもっていることが確認できたら、サーバーレス NEGの URL マスクによって、パス末尾の streamlit-run-backend01 というサービス名の Cloud Run に自動的にマップされます。

そして Cloud Run に紐づくコンテナイメージが GCR から pull されて、Streamlit Web アプリケーションを起動します。

よいところ

  • アプリケーションに届く前に、Googleアカウントでのアクセス制御ができる
  • Cloud Run で Streamlit コードをホストできるので、稼働時間の圧縮・費用の削減につながる
  • 新しいCloud Run を追加すれば自動的にマッピングされる

設定のポイント

ここからは、苦労した点について書きます。

ユーザ認証方法

まず、Cloud Run でユーザ認証ってどうやるの?という点です。IAP を使う方法が今回の方法しか見つからず、"え、ほんとにこんなやるんですか...?"という気持ちになりました。Cloud Run を使いたかっただけなのですが...

でもやると決めたらやるのです。こうしてロードバランサや証明書、DNSなど、構成が大仰になっていきました。

Cloud Run の Ingress 設定

Terraform でどうやって Ingress を指定するのか?というのもちょっと手間取りましたが、結果的にはこうでした。

resource "google_cloud_run_service" "streamlit_run_default" {
  (中略)
  metadata {
    annotations = {
      "run.googleapis.com/ingress" = "internal-and-cloud-load-balancing"
    }
  }

IAP で必要な権限

roles/iap.httpsResourceAccessor を付与されているアカウントが、バックエンドサービスにアクセスできるようになります。Terraform では google_iap_web_backend_service_iam で設定できます。

パスルール設定

パスルールについてですが、当初は google_compute_url_map の host_rule と path_matcher で、ひとつずつパスとバックエンドサービスを紐付けていました。

resource "google_compute_url_map" "urlmap" {
  name            = "${var.lb_name}-lb"
  default_service = google_compute_backend_service.backend.id
  host_rule {
    hosts        = ["${var.lb-domain}"]
    path_matcher = "mysite"
  }
  path_matcher {
    name            = "mysite"
    default_service = google_compute_backend_service.backend_default.id

    path_rule {
      paths   = ["/streamlit-run-backend01"]
      service = google_compute_backend_service.backend_01.id
    }
    path_rule {
      paths   = ["/streamlit-run-backend02"]
      service = google_compute_backend_service.backend_02.id
    }
  }
}

この方法だと、google_compute_backend_service, google_compute_region_network_endpoint_group, google_iap_web_backend_service_iam_binding の三つを、Cloud Run の数だけ作成せねばならず、気が進みません。

Google さんはもっとスマートに実装しているはず...と探して見つけたのが、先に紹介した サーバーレス NEG の URL マスクです。以下のようにすると、上述した host_rule と path_matcher の設定が不要になります。

resource "google_compute_region_network_endpoint_group" "neg_default" {
  name                  = "${var.lb_name}-neg-default"
  network_endpoint_type = "SERVERLESS"
  region                = var.region
  cloud_run {
    url_mask = "${var.lb-domain}/<service>"
  }
}

cloud_run のあたりで 個別のサービスを指定せず、url_mask を設定するのがポイントです。 URL マスクの詳細な設定方法については、URL マスクを作成する をご覧ください。

リクエストURL に Streamlit の起動URLを合わせる

URL マスクによってリクエストが分岐するようになりましたが、Streamlit は http://0.0.0.0:8080 で起動するためか、うまく応答しませんでした。 そこで、Streamlit 起動オプションの baseUrlPath でリクエスト URL と同じパスとなるように調整したところ、無事起動できました。

(省略)
CMD streamlit run app.py \
    --browser.serverAddress="0.0.0.0" \
    --server.port=${PORT} \
    --server.baseUrlPath="/streamlit-run-backend01"

サーバーレス NEG と Cloud Run の設置リージョン

僕が最初試したときは、サーバーレス NEG と Cloud Run のリージョンを 'us-east1' に統一していたのですが、そのときはどうやっても 404 になってしまいました。Stack Overflow で同じように困っている人がいて、その原因がリージョンだったので、まさかねぇ...と思いつつ 'us-central1' に変更したら動きました。

これについては、本当にリージョンが原因なのかは検証していないのですが、こういう事象があったということだけ残しておきます。

参考にしたページ

Streamlit Deployment on Google Cloud Serverless Container Platform | by Jishnu | dSights Write | Jul, 2021 | Medium

Cloud Run で Identity-Aware Proxy (IAP) を使う

Google Cloud Serverless NEG | google-cloud-jp

Setting up a load balancer with Cloud Run, App Engine, or Cloud Functions

*1:ロードバランサの静的 IP アドレスを Terraform のoutput に設定していますので、DNS に適宜設定してください。