はなちるのマイノート

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

【C#】ASP.NET CoreのMinimal APIで簡単なWebAPIを作成〜GCPのCloud Runに認証付きで公開するまで

はじめに

今回はASP.NET CoreMinimal APIで簡単なWebAPIを作成〜Cloud Runに認証付きで公開するまでの方法について紹介したいと思います。

またちょこちょこコマンドを利用した操作をしてますが、bash/zshではなく私はPowerShell教なのでご注意ください。

Mimimal APIを用いたサーバー実装

最新の.NET9を用いて実装していきます。

$ dotnet new web -f net9.0 -n SampleWebAPI

またOpenAPIドキュメントを自動生成してSwaggerUIにて確認したいので、NSwag.AspNetCoreをインストールしておきます。

$ dotnet add package NSwag.AspNetCore --version 14.2.0

【C#】ASP.NET CoreのMinimalAPIでOpenAPIドキュメントを自動生成してSwaggerUIやReDocで可視化する(NSwag.AspNetCore) - はなちるのマイノート

Program.csの実装

まずはProgram.csに諸々の設定を書いていきます。

// Program.cs
using SampleWebAPI.Endpoints;

var builder = WebApplication.CreateBuilder(args);

// Loggerを追加
builder.Services.AddLogging(logging =>
{
    logging.ClearProviders();
    logging.AddConsole();
});

// OpenAPIドキュメントを自動生成するためにサービス追加(Minimal APIのみ必要)
builder.Services.AddEndpointsApiExplorer();

// 生成するOpenAPIドキュメントの設定
builder.Services.AddOpenApiDocument(config =>
{
    config.DocumentName = "SampleWebAPI";
    config.Title = "SampleWebAPI v1";
    config.Version = "v1";
});

var app = builder.Build();

// Endpointsを登録
app.RegisterSampleEndpoints();

// DevelopmentのときだけOpenAPIドキュメントを吐き出し、SwaggerUIを表示できるようにする
if (app.Environment.IsDevelopment())
{
    app.UseOpenApi();
    app.UseSwaggerUi(config =>
    {
        config.DocumentTitle = "SampleWebAPI";
        config.Path = "/swagger";
        config.DocumentPath = "/swagger/SampleWebAPI/swagger.json";
        config.DocExpansion = "list";
    });
}

app.UseHttpsRedirection();

if (app.Environment.IsDevelopment())
{
    // launchSettings.jsonから起動するとこちらが実行される
    app.Run();
}
else
{
    // Docker上ではこちらを実行するようにする
     app.Run($"http://0.0.0.0:{Environment.GetEnvironmentVariable("PORT") ?? "8080"}");
}

実装のコツとして、Endpointの定義をそれぞれ別.csに切り出すと見通しがよくなります。また不用意にhttp://0.0.0.0で公開しないでください。公衆wifiなどで不正にアクセスされる恐れがあるので注意です。

Endpointの実装

$ tree -L 2
.
├── Endpoints
│   └── SampleEndpoints.cs
├── Program.cs
├── Properties
│   └── launchSettings.json
├── SampleWebAPI.csproj
├── appsettings.Development.json
└── appsettings.json

上記のようにEndpointsディレクトリを用意して、そこに記述していきます。

// SampleEndpoints.cs
using Microsoft.AspNetCore.Http.HttpResults;

namespace SampleWebAPI.Endpoints;

public static class SampleEndpoints
{
    public static WebApplication RegisterSampleEndpoints(this WebApplication app)
    {
        var sample = app.MapGroup("/sample");
        
        // /sample/hoge にアクセスしたときにGetHogeメソッドを実行
        sample.MapGet("/hoge", GetHoge);
        
        // /sample/fuga にアクセスしたときにGetFugaメソッドを実行
        sample.MapGet("/fuga/{name}", GetFuga);

        return app;
    }
    
    internal static Results<BadRequest, Ok<string>> GetHoge(HttpContext context)
    {
        return TypedResults.Ok("Hoge");
    }
    
    internal static Results<BadRequest<string>, Ok<string>> GetFuga(HttpContext context, string name)
    {
        if(string.IsNullOrEmpty(name))
        {
            return TypedResults.BadRequest("Name is required.");
        }
        return TypedResults.Ok("Fuga");
    }
}

HttpContextからヘッダーの情報を取得とかができます。またResultsのような返り値の型定義の仕方によって、OpenAPIドキュメントの返り値が自動で生成できるのかどうかが変わるので要注意です。

launchSettings.jsonでlocalhostにサーバーを立てる

実際にlocalhostにサーバーを立てて実験してみましょう。launchSettings.jsonを開いていただき、再生ボタンを押します。(実行/デバッグ構成から実行してもOKです)

launchSettings.json

http://localhost:5206/sample/hoge(ご自身のポート番号に合わせてください)にアクセスすると、正しく値が返ってくることが確認できます。

localhostにアクセスした様子

またhttp://localhost:5206/swaggerにアクセスすると、SwaggerUIが立ち上がります。

SwaggerUI

Dockerで動作させる

ここまででサーバー実装は終わったので、次はDockerfileを記述していきます。

$ tree -L 2
.
├── Dockerfile
├── Endpoints
│   └── SampleEndpoints.cs
├── Program.cs
├── Properties
│   └── launchSettings.json
├── SampleWebAPI.csproj
├── appsettings.Development.json
└── appsettings.json

Macで開発するときは少し面倒くさいのですが、以下のようにTARGETARCHを用いて実装してあげます。

# Cloud Runにあげるときは--platform=linux/amd64を指定してdocker buildしてください
FROM --platform=$BUILDPLATFORM mcr.microsoft.com/dotnet/sdk:9.0 AS build-env

ARG TARGETARCH
WORKDIR /App

COPY . ./

RUN dotnet restore ./SampleWebAPI.csproj -a $TARGETARCH
RUN dotnet publish ./SampleWebAPI.csproj -c Release -o ./out --no-restore -a $TARGETARCH

FROM mcr.microsoft.com/dotnet/aspnet:9.0
WORKDIR /App
COPY --from=build-env /App/out .
ENTRYPOINT ["dotnet", "SampleWebAPI.dll"]

【Docker, C#】Docker buildで「--platform linux/amd64」を指定した際にdotnet restoreでスタックしてしまう対策 - はなちるのマイノート

実際に動作するのか試してみましょう。

# ビルド
$ docker build . -t sampe-webapi-sandbox --no-cache

# 127.0.0.1:8080のようにしないとデフォルト設定だと0.0.0.0だと認識されてしまうので要注意!!
$ docker run -it --rm -p 127.0.0.1:8080:8080 --name sampe-webapi-sandbox-container sampe-webapi-sandbox

# 利用し終わったら後片付け
$ docker image rm sampe-webapi-sandbox

GCPにデプロイする準備

実際にGCPにデプロイするためにterraformを用意してあげましょう。

$ tree
.
└── terraform
    ├── cloud_run.tf
    ├── main.tf
    ├── outputs.tf
    ├── terraform.tf
    └── variables.tf

.tfを記述する

# terraform.tf
terraform {
  required_providers {
    google = {
        source  = "hashicorp/google"
      version = ">= 5.24.0"
    }

    local = {
      source  = "hashicorp/local"
      version = "~> 2.0"
    }
  }
  
  # あらかじめGCSにバケットを作成しておく必要があるので注意
  # Terraformの設定ファイルにおいて、GCSをバックエンドとして使用
  backend "gcs" {
    bucket  = "<your-bucket-name>"
    prefix  = "terraform/state"
  }

  required_version = ">= 1.0.0"
}
# variables.tf
variable "project_id" {
  description = "gcp project id"
  default    = "<your-project-id>"
  type = string
}

variable "region" {
  description = "gcp region"
  default     = "asia-northeast1"
  type = string
}

variable "sample_webapi_docker_registry_url" {
  description = "artifact registry url (without '/' suffix)"
  default = "<your-artifact-registry-url>"
  type = string
}

variable "sample_webapi_docker_tag" {
  description = "artifact registry tag"
  type = string
}
# main.tf
provider "google" {
  project = var.project_id
  region = var.region
}

# Cloud Runを有効化
resource "google_project_service" "run_api" {
  service = "run.googleapis.com"
  disable_on_destroy = false
}

resource "google_service_account" "sample-webapi-worker" {
  account_id   = "sample-webapi-worker"
  display_name = "Sample WebAPI Worker Account"
}

resource "google_service_account" "sample-webapi-invoker" {
  account_id   = "sample-webapi-invoker"
  display_name = "Sample WebAPI Invoker Account"
}

resource "google_service_account_key" "sample-webapi-worker-service-account-key" {
  service_account_id = google_service_account.sample-webapi-worker.name
}

resource "google_service_account_key" "sample-webapi-invoker-service-account-key" {
  service_account_id = google_service_account.sample-webapi-invoker.name
}

resource "local_file" "sample-webapi-worker-service-account-key" {
  filename             = "./output/secrets/sample-webapi-worker-service-account-key.json"
  content              = base64decode(google_service_account_key.sample-webapi-worker-service-account-key.private_key)
  file_permission      = "0600"
  directory_permission = "0755"
}

resource "local_file" "sample-webapi-invoker-service-account-key" {
  filename             = "./output/secrets/sample-webapi-invoker-service-account-key.json"
  content              = base64decode(google_service_account_key.sample-webapi-invoker-service-account-key.private_key)
  file_permission      = "0600"
  directory_permission = "0755"
}

resource "google_service_account_iam_member" "sample-webapi-worker-service-account-iam-member" {
  service_account_id  = google_service_account.sample-webapi-worker.name
  role                = "roles/iam.serviceAccountUser"
  member              = "serviceAccount:${google_service_account.sample-webapi-worker.email}"
}
# cloud_run.tf
resource "google_cloud_run_v2_service" "server" {
  name     = "sample-webapi-server"
  location = var.region
  ingress = "INGRESS_TRAFFIC_ALL"
  deletion_protection = false

  template {
    containers {
      image = "${var.sample_webapi_docker_registry_url}:${var.sample_webapi_docker_tag}"
    }
    
    service_account = google_service_account.sample-webapi-worker.email
  }
  
  depends_on = [google_project_service.run_api]
}

resource "google_cloud_run_v2_service_iam_member" "sample-webapi-worker-policy" {
  location    = google_cloud_run_v2_service.server.location
  project = google_cloud_run_v2_service.server.project
  name        = google_cloud_run_v2_service.server.name
  role    = "roles/run.invoker"
  member = "serviceAccount:${google_service_account.sample-webapi-worker.email}"
}

resource "google_cloud_run_v2_service_iam_member" "sample-webapi-invoker-policy" {
  location    = google_cloud_run_v2_service.server.location
  project = google_cloud_run_v2_service.server.project
  name        = google_cloud_run_v2_service.server.name
  role    = "roles/run.invoker"
  member = "serviceAccount:${google_service_account.sample-webapi-invoker.email}"
}
# outputs.tf
output "service-url" {
  value       = google_cloud_run_v2_service.server.uri
  description = "The URL on which the deployed service is available"
}

output "sample-webapi-worker-service-account" {
  description = "service account for Sample WebAPI worker"
  value = google_service_account.sample-webapi-worker.email
}

output "sample-webapi-invoker-service-account" {
  description = "service account for Sample WebAPI invoker"
  value = google_service_account.sample-webapi-invoker.email
}

gcloud CLIを用いて下準備する

gcloud CLIを既にインストール済の前提で進めていきます。まずは認証を通しておきましょう。

# ログインする
$ gcloud auth login
$ gcloud auth application-default login

# 現在のログイン情報を調べる
$ gcloud auth list 

TerraformのtfstateをGCS上で管理するためにGCSバケットを作成しておきましょう。

# tf-state-sample-webapi-serverで定義する
$ gcloud storage buckets create "gs://<your-bucket-name>" `
  --project <your-gcp-project-id>`
  --uniform-bucket-level-access `
  --location "asia-northeast1"

Artifact RegistoryにImageをアップロードする

次にArtifact Regisotryにレポジトリを作成し、docker buildを行ってpushします。

# レポジトリを作成していない場合のみ実行
$ gcloud artifacts repositories create <your-repository-name> --location=asia-northeast1 --repository-format=docker
# Docker用Artifact Registory認証
$ gcloud auth configure-docker asia-northeast1-docker.pkg.dev

# docker buildの際に--platform linux/amd64 を付与してください
$ docker build ./ -t asia-northeast1-docker.pkg.dev/<your-project-id>/<your-repository-name>/<your-image-name> --platform=linux/amd64

# push
$ docker push asia-northeast1-docker.pkg.dev/<your-project-id>/<your-repository-name>/<your-image-name>

terraform applyを実行する

最後にterraform initterraform applyを実行して、GCPにデプロイをしましょう。

$ terraform init

$ terraform plan

$ terraform apply

Cloud Runにアクセスする

正しくデプロイできていると、ローカルにサービスアカウントのキーが./output/secrets.jsonとして吐き出されているはずです。

それでログインをして、Cloud Runにアクセスしてみましょう。

$ gcloud auth activate-service-account --key-file="<path-to-your-service-account>"

# ログインしているサービスアカウントを確認
$ gcloud auth list

# リクエスト送信
$ Invoke-WebRequest `
  -Uri <your-cloud-run-rul>/sample/hoge `
  -Method Get `
  -Headers @{
          "Content-Type" = "application/json"
          "Authorization" = "Bearer $(gcloud auth print-identity-token)"
  }


StatusCode        : 200
StatusDescription : OK
Content           : "Hoge"
RawContent        : HTTP/1.1 200 OK
                    Date: Thu, 20 Mar 2025 09:37:03 GMT
                    Server: Google
                    Server: Frontend
                    ...
Headers           : {[Date, System.String[]], [Server, System.String[]], [Alt-Svc, System.String[]], [Transfer-Encoding, System.Stri
                    ng[]]…}
Images            : {}
InputFields       : {}
Links             : {}
RawContentLength  : 6
RelationLink      : {}

ちなみに認証無しで実行してもログインできません。

# Authorizationヘッダーなしの場合
$ Invoke-WebRequest `
   -Uri <your-cloud-run-rul>/sample/hoge `
   -Method Get `
   -Headers @{
           "Content-Type" = "application/json"                                                  
   }

403 Forbidden

Error: Forbidden
Your client does not have permission to get URL /sample/hoge from this server.

ActionsでCI/CDを準備するための小ネタ

terraformのセットアップ

hashicorp/setup-terraformを用いてセットアップするとよいでしょう。

steps:
- uses: hashicorp/setup-terraform@v3
  with:
    terraform_version: "1.1.7"

github.com

gcloud authを行う

google-github-actions/authを用いると良いでしょう。

jobs:
  job_id:
    # Any runner supporting Node 20 or newer
    runs-on: ubuntu-latest

    # Add "id-token" with the intended permissions.
    permissions:
      contents: 'read'
      id-token: 'write'

    steps:
    - uses: 'actions/checkout@v4'

    - uses: 'google-github-actions/auth@v2'
      with:
        project_id: 'my-project'
        workload_identity_provider: 'projects/123456789/locations/global/workloadIdentityPools/my-pool/providers/my-provider'

github.com

gcloudのセットアップを行う

google-github-actions/setup-gcloudを用いるとよいでしょう。

- uses: 'google-github-actions/setup-gcloud@v2'
  with:
    version: '>= 416.0.0'

github.com