こんにちは。Insight EdgeでDeveloperをしている熊田です。
昨今はインフラ環境を構築する際に、Infrastructure as Code(IaC)を検討することが多くなっているかと思います。 これまで私自身はIaCに触れてきませんでしたが、Terraformを使ってWebアプリを構築してみたいとは常々思っていました。 そんなときに、Streamlitフレームワークで開発したアプリをGCP上に構築する機会がありましたので、そのとき使用したコード等を本記事で紹介したいと思います。
目次
Streamlitについて
Streamlitをご存知でない方に向けての説明になりますが、StreamlitはPythonのWebアプリケーションフレームワークです。一番の特徴は、フロントエンドもPythonで書けることかと思います。 加えて、直感的にUI実装できるので学習コストが低く、幅広くチャートライブラリをサポートしています。そのような特徴から、データ分析結果をお手軽にダッシュボードで可視化したいときに便利です。 そういった利点があり、データサイエンティストを多く抱える弊社では、Streamlitを使ったアプリ開発が増えてきています。
以下はStreamlitを使ったサンプルコードです。
### main.py ### import streamlit as st import pandas as pd import numpy as np # タイトル st.title("Streamlit Sample") # サイドバー with st.sidebar: st.title("[MENU]") option = st.sidebar.radio("表示形式を選択してください", ("折れ線グラフ", "棒グラフ", "面グラフ","テーブル")) # ランダムなデータを生成 chart_data = pd.DataFrame(np.random.randn(20, 3), columns=["a", "b", "c"]) # チャートを表示 if option == "折れ線グラフ": st.line_chart(chart_data) elif option == "棒グラフ": st.bar_chart(chart_data) elif option == "面グラフ": st.area_chart(chart_data) elif option == "テーブル": st.table(chart_data)
インフラ/セキュリティ要件
Webアプリを構築するにあたってのインフラ要件を確認していきます。
Webアプリ開発の前段であるデータ分析にてGCPのサービスが使われており、Webアプリも同クラウドプラットフォーム上で完結させたかったため、今回はGCPで構築します。
StreamlitがWebSocket通信をするため、コンテナ実行環境はWebSocket通信をサポートしているCloud Runを利用することとしました。
セキュリティ要件としては、HTTPS通信、DDos対策、ユーザ認証を行う必要がありました。 加えて、今回のアプリは弊社開発メンバとエンドユーザの双方がアクセスするのですが、以下のような制約がありました。
弊社開発メンバ | エンドユーザ |
---|---|
Googleアカウントあり | Googleアカウントなし |
VPNなし (固定IPなし) |
VPNあり (固定IPあり) |
そのため、弊社開発メンバとエンドユーザの認証方法を分けることにし、弊社開発メンバはSSO認証、 エンドユーザはEmail/PW認証&IP制限を設けて、セキュリティ要件を満たすようにしました。 セキュリティ対策では、GCPの以下サービスを利用して構築します。なお、本記事では各サービス機能の説明はしませんので、ご了承ください。
- Load Balancer
- Cloud Armor
- Identity-Aware Proxy(IAP)
- Identity Platform
全体のインフラ構成図は以下の通りです。
構築してみた
実際に使用したコードをお見せして、つまづいたポイントなどを紹介したいと思います。
### Dockerfile ### FROM --platform=linux/amd64 python:3.10 RUN pip install streamlit COPY src /app/src WORKDIR /app/src CMD ["streamlit", "run", "main.py"]
### docker-build.sh ### PROJECT_ID=<your-project-id> REGION=<your-region> PREFIX=<your-prefix> REPOSITORY=$REGION-docker.pkg.dev/$PROJECT_ID/$PREFIX-repo IMAGE=streamlit-image TAG=latest APP=$PREFIX-app docker image build -t $IMAGE:$TAG . docker tag $IMAGE:$TAG $REPOSITORY/$IMAGE:$TAG docker push $REPOSITORY/$IMAGE:$TAG gcloud run deploy $APP \ --project $PROJECT_ID \ --image $REPOSITORY/$IMAGE:$TAG \ --platform managed \ --region $REGION \ --ingress internal-and-cloud-load-balancing \ --allow-unauthenticated \ --memory 2Gi \ --cpu 1 \ --max-instances 1 \ --timeout 900 \ --concurrency 80 \ --port 8501
Cloud Runをデプロイするために、Dockerfileとshellスクリプトを作成しています。 初期構築時はTerraformからshellスクリプトを実行します。初期構築後、Cloud Runをデプロイし直したい時はshellスクリプトのみを実行することを想定しています。
なお、外部ネットワークからCloud Runへ直接アクセスさせず、必ずLoad Balancerを経由させたいので、gcloud run deploy
のオプションに
--ingress internal-and-cloud-load-balancing
を指定しています。
Terraformで構築する
Terraformを使って構築するにあたり、以下作業を行うことを前提としています。
1. Terraform実行用サービスアカウントの作成
2. ドメインの取得、DNSレコードの設定
3. Google Cloud APIsをコンソールから有効化
4. OAuth同意画面をコンソールから設定(こちらの記事のOAuth同意画面を設定する を参考にしました)
5. Identity Platformの設定(こちらのユーザ作成 まで参照ください)
実行するTerraformコード(ver:1.4.7)は以下の通りです。
### variables.tf ### variable "project_id" { description = "project id" type = string default = <your-project-id> } variable "project_number" { description = "project number" type = string default = <your-project-number> } variable "default_region" { description = "The default region for resources" default = <your-region> } variable "credentials_file" { description = "The path to the credentials file" default = <your-credentials-file> } variable "prefix" { description = "The prefix to use for all resources" default = "streamlit-app" } variable "oauth_domain" { description = "The domain to use for oauth" default = <your-oauth-domain> } variable "non_oauth_domain" { description = "The domain to use for non-oauth" default = <your-non-oauth-domain> }
### main.tf ### ## Provider ## provider "google" { credentials = file(var.credentials_file) project = var.project_id region = var.default_region } data "google_cloud_run_service" "default" { name = "${var.prefix}-app" location = var.default_region depends_on = [ google_artifact_registry_repository.my-repo ] } ## Artifact Registry ## resource "google_artifact_registry_repository" "my-repo" { location = var.default_region repository_id = "${var.prefix}-repo" description = "docker repository" format = "DOCKER" provisioner "local-exec" { command = "sh docker-build.sh" } } ## SSL Certificate ## resource "google_compute_managed_ssl_certificate" "default" { name = "${var.prefix}-ssl-certificate" managed { domains = [ var.oauth_domain, var.non_oauth_domain, ] } } ## Network Endpoint Group ## resource "google_compute_region_network_endpoint_group" "default" { name = "${var.prefix}-neg" region = var.default_region network_endpoint_type = "SERVERLESS" cloud_run { service = data.google_cloud_run_service.default.name } } ## Load Balancer Backend Service ## resource "google_compute_backend_service" "oauth" { name = "${var.prefix}-oauth-backend-service" timeout_sec = 30 backend { group = google_compute_region_network_endpoint_group.default.id } iap { oauth2_client_id = google_iap_client.oauth_client.client_id oauth2_client_secret = google_iap_client.oauth_client.secret } depends_on = [ google_iap_client.oauth_client ] } resource "google_compute_backend_service" "non-oauth" { name = "${var.prefix}-non-oauth-backend-service" timeout_sec = 30 backend { group = google_compute_region_network_endpoint_group.default.id } iap { oauth2_client_id = google_iap_client.non-oauth_client.client_id oauth2_client_secret = google_iap_client.non-oauth_client.secret } security_policy = google_compute_security_policy.non-oauth.id } ## Load Balancer ## resource "google_compute_url_map" "default" { name = "${var.prefix}-url-map" default_service = google_compute_backend_service.oauth.id host_rule { hosts = [var.non_oauth_domain] path_matcher = "non-oauth" } path_matcher { name = "non-oauth" default_service = google_compute_backend_service.non-oauth.id } } ## Load Balancer Proxy ## resource "google_compute_target_https_proxy" "default" { name = "${var.prefix}-https-proxy" url_map = google_compute_url_map.default.id ssl_certificates = [google_compute_managed_ssl_certificate.default.id] } ## Load Balancer Forwarding Rule ## resource "google_compute_global_forwarding_rule" "oauth" { name = "${var.prefix}-oauth-forwarding-rule" target = google_compute_target_https_proxy.default.id ip_address = google_compute_global_address.oauth.address port_range = "443-443" } resource "google_compute_global_forwarding_rule" "non-oauth" { name = "${var.prefix}-non-oauth-forwarding-rule" target = google_compute_target_https_proxy.default.id ip_address = google_compute_global_address.non-oauth.address port_range = "443-443" } ## IP Address ## resource "google_compute_global_address" "oauth" { name = "${var.prefix}-oauth-global-address" } resource "google_compute_global_address" "non-oauth" { name = "${var.prefix}-non-oauth-global-address" } ## IAP OAuth Client ## resource "google_iap_client" "oauth_client" { display_name = "IAP OAuth Client" brand = "projects/${var.project_number}/brands/${var.project_number}" } resource "google_iap_client" "non-oauth_client" { display_name = "IAP Non OAuth Client" brand = "projects/${var.project_number}/brands/${var.project_number}" } ## IAP IAM ## resource "google_iap_web_backend_service_iam_binding" "default" { web_backend_service = google_compute_backend_service.oauth.name role = "roles/iap.httpsResourceAccessor" members = [ "domain:<your-domain>", "user:<your-user>", ] } ## Cloud Armor ## resource "google_compute_security_policy" "non-oauth"{ name = "${var.prefix}-non-oauth-security-policy" description = "non-oauth security policy" rule { action = "allow" priority = 0 match { versioned_expr = "SRC_IPS_V1" config { src_ip_ranges = ["<your-ip>"] } } description = "allow rule" } rule { action = "deny(403)" priority = 2147483647 match { versioned_expr = "SRC_IPS_V1" config { src_ip_ranges = ["*"] } } description = "default deny rule" } }
上記Terrafomコードですが、IAPのEmail/PW認証に関する設定が不十分な(いま現在、Terraformがサポートしていない)ため、 ここからはコンソール上で設定していきます。まずは、外部IDの承認をします。
これにより、Email/PWを利用しての認証ページが自動作成されます。アクセスしてみると以下画面になります。
なお、システム管理者のみにユーザ追加、削除させたいのでIdentity Platformの設定をコンソールから変更しています。
手っ取り早く外部IDを利用した簡単な認証を作成できるのが良いですね。 Email以外にもMicrosoftアカウントやX(旧Twitter)アカウントなども選択できるので、いつか試してみたいです。
つまずきポイント
Terrafom実行でつまずいたところは色々ありましたが、大きなところでいうと2点です。
まず1点目は、OAuth同意画面の設定です。前提④で実行した内容ですが、
当初は google_iap_brand
を利用してTerraformで設定しようと考えていました。しかし、applyしようとすると Error 400: Support email is not allowed
のエラーが出てしまいました。
調べてみても有効な解決策を見つけられませんでしたが、どうやらOAuth同意画面の設定はTerraformでの管理に向かないようです。 というのも、APIを介してのOAuth同意画面の変更、削除はできないとのことです。変更するにはコンソールから操作する必要があります。 であれば、Terraformで管理することは必須でありませんので、コンソール上から設定することにしました。
※調査の際は、こちらの記事を参考にさせていただきました。 Terraform で IAP 設定する
2点目は、Terraform構築が無事完了しアクセスしてOAuth認証も成功して、
アプリのトップ画面を開こうとすると The IAP service account is not provisioned
のエラーが出たところです。公式ドキュメント によると、
IAPサービスアカウントを作成しないとCloud Runを起動できず、このエラーになるようです。
gcloud beta services identity create --service=iap.googleapis.com --project=<your-project-id>
上記を実行したところ無事アプリを開くことができました。以前にコンソールでIAPを有効化したときは実施していなかったので戸惑いましたが、 公式ドキュメントをよく読むと、APIを介してIAPを有効化した際は、上記コマンドでIAPサービスアカウントを作成する必要があるようです。
まとめ/感想
以上、Terraformを使ってGCP上にでセキュアなアプリを構築してみたときのお話でした。 認証機能を自前で実装となるとかなり大変なので、こういったIAPやAWS Cogniteのような認証サービスを利用できるのはとてもありがたいですね。 ただ、今回はIAP周りのIaC化がとてもややこしく手間どりました。触ってみて、IAP周りのTerraformおよびGCP APIは両方とも現在も開発中っぽく、ここの部分については無理にIaC化しなくても良さそうと感じましたが、必要作業について自分なりに整理できたのでやってみてよかったです。
社内に限らず、社外でもStreamlitを使ったアプリ開発が今後増えていくと思われますので、この記事が参考になれば幸いです。
参考
Cloud Run+Cloud SQL構成をTerraformで管理する
GCP上でセキュアなCloud Runを構築する方法(Terraform事例有)