Terraformを使ってGCPでセキュアなStreamlitアプリを構築してみた

こんにちは。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の承認をします。

外部ID承認

認証ページ設定

これにより、Email/PWを利用しての認証ページが自動作成されます。アクセスしてみると以下画面になります。

認証ページ

なお、システム管理者のみにユーザ追加、削除させたいのでIdentity Platformの設定をコンソールから変更しています。

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事例有)