はなちるのマイノート

Unityをメインとした技術ブログ。自分らしくまったりやっていきたいと思いますー!

【Blazor+GCP+terraform】Blazor ServerをCloud Runで動作させてGCSマウントしたファイル一覧を表示する

概要

BlazorWeb UIコンポーネントを構築するためのWebフレームワークです。.NET使いであればWeb周りもC#で書きたいなーと思うかと思うのですが、それを叶えてくれるMS製フレームワークです。

Blazor は、さまざまな方法でホストできる Web UI コンポーネント (Razor コンポーネント) を構築するための Web フレームワークです。 Razor コンポーネントは、サーバー側では ASP.NET Coreで実行できるのに対し (Blazor Server)、クライアント側では WebAssembly ベースの .NET ランタイム上のブラウザーで実行できます (Blazor WebAssembly、Blazor WASM)。

ASP.NET Core Blazor のホスティング モデル | Microsoft Learn

ドキュメントの説明にもあるとおりいくつかのモードがあるのですが、その中でBlazor Serverを用いるとCloud Runといったクラウド上での実装がかなり楽になります。といってもそれぞれのモード(代表的なものはBlazor WASMBlazor Server)にはメリデメあるので詳しくは公式ドキュメント等を参照してみてください。
zenn.dev

Blazor Server ホスティング モデルを使用すると、コンポーネントは ASP.NET Core アプリ内からサーバー上で実行されます。 UI の更新、イベント処理、JavaScript の呼び出しは、WebSocket プロトコルを使用して SignalR 接続経由で処理されます。 接続されている各クライアントに関連付けられているサーバー上の状態は、"回線" と呼ばれます。 回線は特定のネットワーク接続に関連付けられず、一時的なネットワークの中断や、接続が切断されたときにクライアントがサーバーに再接続しようとすることを許容します。

ASP.NET Core Blazor のホスティング モデル | Microsoft Learn

今回はBlazor布教の意味も兼ねて、割と利用されるケースがありそうなGCS上のファイルを表示するサイトを構築していきます。

作り方

.NET 8 Blazor web applicationを作成する

Blazor Server(対話型Serverのモード)であるプロジェクトを作成します。

$ dotnet new blazor -n SampleBlazorApp -f net8.0 --no-https
テンプレート "Blazor Web アプリ" が正常に作成されました。
このテンプレートには、Microsoft 以外のパーティのテクノロジーが含まれています。詳しくは、https://aka.ms/aspnetcore/8.0-third-party-notices をご覧ください。

作成後の操作を処理しています...
/Users/.../SampleBlazorApp/SampleBlazorApp.csproj を復元しています:
  Determining projects to restore...
  /Users/.../SampleBlazorApp/SampleBlazorApp.csproj を復元しました (31 ミリ秒)。
正常に復元されました。
.
├── Components
│   ├── App.razor
│   ├── Layout
│   │   ├── MainLayout.razor
│   │   ├── MainLayout.razor.css
│   │   ├── NavMenu.razor
│   │   └── NavMenu.razor.css
│   ├── Pages
│   │   ├── Counter.razor
│   │   ├── Error.razor
│   │   ├── Home.razor
│   │   └── Weather.razor
│   ├── Routes.razor
│   └── _Imports.razor
├── Folder.DotSettings.user
├── Program.cs
├── Properties
│   └── launchSettings.json
├── SampleBlazorApp.csproj
├── appsettings.Development.json
├── appsettings.json
└── wwwroot
    ├── app.css
    ├── bootstrap
    │   ├── bootstrap.min.css
    │   └── bootstrap.min.css.map
    └── favicon.png

GCSのマウントしたファイルを読み込めるようにする

Blazorの細かい説明はしませんが、今回はGCS上のファイルを一覧で表示するStorage.razorPages内に定義をしました。

<!-- Components/Pages/Storage.razor -->
@page "/storage"

<PageTitle>Storage</PageTitle>

<h1>GCS Files</h1>

@if (fileNames == null)
{
    <p><em>Loading...</em></p>
}
else
{
    <ul>
        @foreach (var fileName in fileNames)
        {
            <li>@fileName</li>
        }
    </ul>
}

@code {
    private string[]? fileNames = null;

    protected override void OnInitialized()
    {
        const string directoryPath = "/mnt/gcs";
        fileNames = Directory.Exists(directoryPath) ? Directory.GetFiles(directoryPath, "*", SearchOption.AllDirectories) : ["ファイルが見つかりませんでした。"];
    }
}

本当にSystem.IOでファイルを探しているだけです。GCSをmountさせると、これだけでアクセスできてしまいます。
www.hanachiru-blog.com


またこれほど楽に操作できるのも、Blazor Serverの恩恵です。例えばBlazor WASMだと別途Web API Serverを立てないといけなく、セキュリティ面などで考慮することが多いです。しかしBlazor Serverはサーバー上で動作するため、これだけで良いのです。

サーバーのURL指定

サーバーが直接設定されていない場合にlistenするURLを指定しておきます。なんかわざわざ指定する必要ない(appsettings.jsonや環境変数でいける気がする)と思うのですが、私は直接指定してしまいました。あんまり良くないかもです。
ASP.NET Core の Web ホスト | Microsoft Learn
ASP.NET Core Kestrel Web サーバーのエンドポイントを構成する | Microsoft Learn

// Program.cs

using SampleBlazorApp.Components;

// Kestrelを使用してWebサーバーを作成
var builder = WebApplication.CreateBuilder(args);

// Add services to the container.
builder.Services.AddRazorComponents()
    .AddInteractiveServerComponents();

// URLを取得
var url = builder.Configuration["Kestrel:Endpoints:Http:Url"] ?? "http://0.0.0.0:8080";

var app = builder.Build();

// Configure the HTTP request pipeline.
if (!app.Environment.IsDevelopment())
{
    app.UseExceptionHandler("/Error", createScopeForErrors: true);
}

app.UseStaticFiles();
app.UseAntiforgery();

app.MapRazorComponents<App>()
    .AddInteractiveServerRenderMode();

app.Run(url);

ポート番号を設定する

appsettings.jsonでprodでの設定を変更することができます。appsettings.Development.jsonは開発用ですね。

これはMicrosoft.Extensions.Configurationを利用して実現しています。
learn.microsoft.com

{
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft.AspNetCore": "Warning"
    }
  },
  "AllowedHosts": "*",
+ "Kestrel": {
+   "Endpoints": {
+     "Http": {
+       "Url": "http://*:8080"
+     }
+   }
+ }
}

appsettings.jsonKestrelの項目を追加しました。これはKestrelサーバーのエンドポイント設定を示しています。具体的には、HTTPプロトコルを使用して、ポート8080でリッスンするように設定されています。

Kestrelサーバーは、ASP.NET CoreアプリケーションのためのクロスプラットフォームなWebサーバーですね。HTTP/1.1HTTP/2HTTP/3WebSocketにWebプロトコルは対応しています。

learn.microsoft.com

launchSettings.jsonを用いてデバッグ可能にする

{
  "$schema": "http://json.schemastore.org/launchsettings.json",
  "iisSettings": {
    "windowsAuthentication": false,
    "anonymousAuthentication": true,
    "iisExpress": {
      "applicationUrl": "http://localhost:11633",
      "sslPort": 0
    }
  },
  "profiles": {
    "http": {
      "commandName": "Project",
      "dotnetRunMessages": true,
      "launchBrowser": true,
+     "applicationUrl": "http://localhost:8080",
      "environmentVariables": {
        "ASPNETCORE_ENVIRONMENT": "Development"
      }
    },
    "IIS Express": {
      "commandName": "IISExpress",
      "launchBrowser": true,
      "environmentVariables": {
        "ASPNETCORE_ENVIRONMENT": "Development"
      }
    }
  }
}

IIS ExpressとはMicrosoftが提供する軽量で自己完結型のバージョンのInternet Information Services (IIS)です。Webページを配信するようなサーバーソフトウェアで、ローカルマシンで簡単に実行できます。
learn.microsoft.com

commandNameProjectのプロファイルに関しては、dotnet runコマンドが打ち込まれたものと同等です。またASPNETCORE_ENVIRONMENTDevelopmentに設定されているので、appsettings.Development.jsonが利用されます。

httpの左側にある再生ボタンを押すと、Blazorが立ち上がって画面が立ち上がるはずです。またなぜこのような設定をするかというと、launchSettings.jsonを経由することでブレークポイントなどのデバッグができるので、開発がかなり捗るはずです。(やらなくても大丈夫ではあります)

launchSettings.json

プロジェクトをビルドする

プロジェクトをLinux向けにビルドします。なぜLinuxかというと、Cloud RunがLinux x64だからです。

コンテナ イメージ内の実行可能ファイルは、Linux 64 ビット用にコンパイルする必要があります。Cloud Run は特に Linux x86_64 ABI 形式をサポートしています。

https://cloud.google.com/run/docs/container-contract?hl=ja#languages

# -c|--configuration <CONFIGURATION> : ビルド構成
# -f|--framework <FRAMEWORK> : ターゲットフレームワーク
# -r|--runtime <RUNTIME_IDENTIFIER> : 指定されたランタイムのアプリケーションをビルド
# -o|--output <OUTPUT_DIRECTORY> : 出力ディレクトリのパス
# --sc|--self-contained [true|false] : アプリケーションと併せて .NET ランタイムを発行するか
$  dotnet publish -c Release -f net8.0 -r linux-x64 -o ./bin/release/net8.0/publish/ --self-contained false
  Determining projects to restore...
  /Users/.../SampleBlazorApp/SampleBlazorApp.csproj を復元しました (1.07 秒)。
  SampleBlazorApp -> /Users/.../SampleBlazorApp/bin/Release/net8.0/linux-x64/SampleBlazorApp.dll
  SampleBlazorApp -> /Users/.../SampleBlazorApp/bin/release/net8.0/publish/

といっても基本はDockerfileにてビルド処理を記述するので、自分で毎回叩く必要はありません。

Dockerfileを用意する

ビルド&実行するためのDockerfileを記述します。上記で実験したdotnet publishに加えて、それを実行する処理を追加しています。またlinux x64周りが少しややこしいので注意です。

# .NET SDKのImage(https://mcr.microsoft.com/product/dotnet/sdk/about)を指定, .NET CLI + .NET runtime + ASP.NET Coreから成り立つ
FROM --platform=$BUILDPLATFORM mcr.microsoft.com/dotnet/sdk:8.0 AS build-env
ARG TARGETARCH
WORKDIR /App

# 全てのファイルをコンテナにコピーする
COPY . ./

# csprojを見て依存関係を解決する
RUN dotnet restore -a $TARGETARCH

# ビルドしてpublishする
RUN dotnet publish -c Release -o out -a $TARGETARCH

# SampleBlazorAppの実行
FROM mcr.microsoft.com/dotnet/aspnet:8.0
WORKDIR /App
COPY --from=build-env /App/out .
ENTRYPOINT ["dotnet", "SampleBlazorApp.dll"]


またちゃんと動作確認するには、docker buildしてdocker runします。

# 手元でデバッグ用
$ docker build -t sampleblazorapp-img -f  Dockerfile .

# linux x64向け (手元で確認するためなら必要ないです)
$ docker build -t sampleblazorapp-img -f  Dockerfile . --build-arg BUILDPLATFORM=linux --build-arg TARGETARCH=x64

# コンテナを作成して実行
$ docker run -it -p 8088:8088 --name sampleblazorapp-cnt sampleblazorapp-img

Artifact Registoryにアップロードする

以下の記事でも書いたものと同様の手法をとっていきます。
【GCP, terraform】Cloud Runをterraformで構築して.NETで構築した最小構成のウェブサーバーをデプロイする - はなちるのマイノート
クイックスタート: Docker コンテナ イメージを Artifact Registry に保存する  |  Artifact Registry documentation  |  Google Cloud


まずはgcloudがインストールされている前提でログインをします。

$ gcloud auth login
$ gcloud init

まだレポジトリを作成していない場合は作成します。

$ gcloud artifacts repositories create blazor-sample --location=asia-northeast1 --repository-format=docker
Create request issued for: [blazor-sample]
Waiting for operation [projects/---] to complete...done.                                                               
Created repository [blazor-sample].

以下コマンドでDocker用のArtifact Registory認証を行い、build &pushを行います。

# Docker用Artifact Registory認証
$ gcloud auth configure-docker asia-northeast1-docker.pkg.dev

# docker buildの際に、Mac M1なら --platform linux/amd64 を付与してください
$ docker build ./ -t asia-northeast1-docker.pkg.dev/{PROJECT_ID}/blazor-sample/blazor-sample-image

# push
$ docker push asia-northeast1-docker.pkg.dev/{PROJECT_ID}/blazor-sample/blazor-sample-image

正しく動作すればArtifact Registoryに上がっているはずです。

terraformによりCloudRunを構築する

あとはこれらを組み合わせて、Cloud Runを構築します。

.
├── main.tf
├── outputs.tf
├── provider.tf
└── variables.tf

main.tf

# GCSバケット構築
resource "google_storage_bucket" "default" {
  # 重複できないので適当にnameを変える必要があります
  name          = "blazor-sample"
  location      = var.region
  force_destroy = true
}

# Cloud Run構築
resource "google_cloud_run_v2_service" "default" {
  name     = "blazor-sample"
  location = var.region
  ingress  = "INGRESS_TRAFFIC_ALL"
  deletion_protection = false

  template {
    service_account = google_service_account.cloud_run_service_account.email
    
    execution_environment = "EXECUTION_ENVIRONMENT_GEN2"

    containers {
      # Artifact RegisotryにあげたImageを指定する
      image = "asia-northeast1-docker.pkg.dev/----/blazor-sample/blazor-sample-image:latest"
      volume_mounts {
        name       = "bucket"
        mount_path = "/mnt/gcs"
      }
    }

    volumes {
      name = "bucket"
      gcs {
        bucket    = google_storage_bucket.default.name
        read_only = false
      }
    }
  }
}

# 外部からアクセスするようにIAM Policy設定
# allUsers(全てのユーザー)にroles/run.invoker(サービスとジョブの呼び出し、ジョブ実行のキャンセルが可能)を付与する
data "google_iam_policy" "noauth" {
  binding {
    role    = "roles/run.invoker"
    members = ["allUsers"]
  }
}

# IAMポリシーをCloud Runに適応する
resource "google_cloud_run_v2_service_iam_policy" "policy" {
  location    = var.region
  name        = google_cloud_run_v2_service.default.name
  policy_data = data.google_iam_policy.noauth.policy_data
}

# Cloud Run用のサービスアカウントを作成
resource "google_service_account" "cloud_run_service_account" {
  account_id   = "cloud-run-service-account"
  display_name = "Cloud Run Service Account"
}

# Cloud RunのサービスアカウントにGCSのAdmin権限を付与
resource "google_project_iam_member" "cloud_run_storage_admin" {
  project = var.project_id
  role    = "roles/storage.objectAdmin"
  member  = "serviceAccount:${google_service_account.cloud_run_service_account.email}"
}

# Cloud Runのサービスアカウントにroles/iam.serviceAccountUserロールを付与する
resource "google_project_iam_member" "cloud_run_service_account_user" {
  project = var.project_id
  role    = "roles/iam.serviceAccountUser"
  member  = "serviceAccount:${google_service_account.cloud_run_service_account.email}"
}

outputs.tf

output "service_url" {
  value       = google_cloud_run_v2_service.default.uri
  description = "The URL on which the deployed service is available"
}

provider.tf

terraform {
  required_providers {
    google = {
      source  = "hashicorp/google"
      version = "6.2.0"
    }
  }
}

provider "google" {
  project = var.project_id
  region  = var.region
}

variables.tf

variable "project_id" {
  default = "<your project id>"
  description = "Project ID"
}

variable "region" {
  default = "asia-northeast1"
  description = "Region"
}

これらを実行するには以下コマンドを打ち込みます。

$ terraform init
$ terraform apply
...

Apply complete! Resources: 6 added, 0 changed, 0 destroyed.

Outputs:

service_url = "https://----run.app"

結果

正しく実行されるとOutputsにURLが表示されるはずです。それを開くとBlazor Serverにより構築されたサイトが見れるはずです。

またGCS上にファイルを配置すると、それも表示してくれます。

実際に動作している様子

さいごに

特定の範囲にだけ見れるようにしたい場合は、Cloud Load BalancingIdentity-Aware Proxyを利用すれば実現できます。

どっかのタイミングでそこら辺も書きたいなと思ってます。