Cloud Run functions × TypeScript 開発をしよう！【セキュリティ：Cloud Endpoints を利用してAPIキーでの認証を付与する】

# はじめに
この記事では以下のことについて説明しています。
- Cloud Run functions用の Cloud Endpoints を構成する方法
- Terraformで Cloud Endpoints を構成する方法

**注意：ここで説明していることができるリージョンは限定されています。 `asia-northeast1` ではできていても他ではできない可能性があります。**

---

Cloud Run Functionsで作成したAPIを例えば以下のようなケースで利用しようと想定しています。
- 公開APIで使ってもらいたい
- 公開にはするものの誰でも無制限にリクエストを送信できる状況は阻止したい
- 認証を通したユーザでも1日に何回も呼べる状況にはしたくない


こういうニーズにとてもぴったりなサービスがあります。Cloud Endpoints です。

# Cloud Endpointsとは

APIのAPIキーによる保護、モニタリング、分析、使用量の上限設定を行えるようにするAPIのマネジメントツールです。

https://cloud.google.com/endpoints/docs?hl=ja

これをCloud Run functionsと組みわせて使う方法について書いていきます。以下の記事に大体書いてありますが、本記事ではカスタムドメインを設定したものを構成していこうと思います（ドメインはお名前.comで事前に取得したものを使います）。

https://cloud.google.com/endpoints/docs/openapi/set-up-cloud-functions-espv2?hl=ja

# Cloud Run funcitions に Cloud Endpoints を設定する流れ
Cloud Run funcitions にCloud Endpointsを設定する方法はざっくり、
1. Cloud Run上に [ESPv2](https://cloud.google.com/endpoints/docs/openapi/glossary?hl=ja#extensible_service_proxy_v2) コンテナを起動させる
1. その起動した Cloud Run のサービスに来るリクエストを傍受する Cloud Endpointsのサービスを立ち上げる
1. Cloud Run のリクエストを Cloud Run functions に横流しする

という流れです。

Cloud Run をAPIゲートウェイとして機能するサーバーにして、APIリクエストを受信したら認証、モニタリング、ロギングなどの処理を行い、リクエストをバックエンドサービス（Cloud Run functions）へ渡すという構成です。

Cloud Endpointsはこの Cloud Run に来たリクエストの情報をまとめて、コンソールの [エンドポイント/サービス]で見られるようにしてくれます。ついでにAPIキーも設定できるようにしてくれます。

![espv2-serverless-cloud-functions.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/374872/805dbcfb-c026-3ae7-ccbf-d3a5998af3d2.png)
> 引用：https://cloud.google.com/endpoints/docs/openapi/set-up-cloud-functions-espv2

# 前準備
### 必要なAPIの有効化
gcloud コマンドではなく Terraform によって構築していくので、一部GCPのAPIを手動で有効にする必要があります。gcloud コマンドで有効にしましょう。
1. Service Control API
1. Service Management API
1. Cloud Endpoins API

```bash
gcloud services enable servicemanagement.googleapis.com
gcloud services enable servicecontrol.googleapis.com
gcloud services enable endpoints.googleapis.com
```

### APIゲートウェイとして起動させる ESPv2 Cloud Run サービスを事前に作っておく
Cloud Endpointsを構成する上で事前にAPIゲートウェイとして機能させる ESPv2 Cloud Run サービスのホスト名が必要になってきます。なので事前に gcloud コマンドで作成しておきます。

```bash
gcloud run deploy gateway \
    --image="gcr.io/cloudrun/hello" \
    --allow-unauthenticated \
    --platform managed \
    --project=プロジェクト名 \
    --region=asia-northeast1
```

これを実行すると出力される [Service URL] を控えておきましょう。

```bash
Deploying container to Cloud Run service [gateway] in project [advent-calendar-2024-w] region [asia-northeast1]
✓ Deploying new service... Done.                                                                                                                                     
  ✓ Creating Revision...                                                                                                                                             
  ✓ Routing traffic...                                                                                                                                               
  ✓ Setting IAM Policy...                                                                                                                                            
Done.                                                                                                                                                                
Service [gateway] revision [gateway-00001-pv2] has been deployed and is serving 100 percent of traffic.
Service URL: https://xxx.a.run.app # <- これを控えておく
```

また、この出力されたドメインのEndpoints サービスを有効化しておきます。

```bash: 例
gcloud services enable xxx.a.run.app
```

### Terraform に Local Values を追加
そして Local Values を tfファイルに定義しておきます。事前に作成した ESPv2 Cloud Run サービスのホスト名を `gateway_domain` に定義します。また、後ほど使用する ESPv2イメージのバージョンを固定しておきます。

```tf
locals {
  project         = "プロジェクトID"
  region          = "asia-northeast1"
  zone            = "asia-northeast1-a"
  gateway_domain  = "事前に作成した Cloud Run のホスト名"
  ESPv2_image_ver = "2.51.0" # バージョン固定
}
```

# 1. Cloud Endpoints を構成する
OpenAPI 仕様 v2.0 に準拠した、関数の surface と認証要件を記述した OpenAPI ドキュメントを用意します。ルートディレクトリに `openapi-functions.yml` を作成します。そして以下の必須項目を満たすようにします。

- `host` の項目に、事前準備で作成した ESPv2 サービスのホスト名で書き替えるため `${CLOUD_RUN_HOST}` と記載
- APIキーの認証設定 `securityDefinitions` ディレクティブを設定
- `security ディレクティブに `api_key: []` を設定
- Cloud Run functions のエンドポイントを `x-google-backend` の `backend` に記述
    - Terraform のリソース作成時に書き換えるため `${CLOUD_RUN_FUNCTIONS_HOST}` と書いておく
    - `{リージョン名}-{プロジェクトID}-.cloudfunctions.net/関数名` の形式のものがここに入る

```yml: openapi-functions.yml
swagger: '2.0'
info:
  title: Cloud Endpoints + GCF
  description: Sample API on Cloud Endpoints with a Google Cloud Functions backend
  version: 1.0.0
host: ${CLOUD_RUN_HOST}
schemes:
  - https
produces:
  - application/json
securityDefinitions: # API キーの定義の制限事項
  api_key:
    type: "apiKey"
    name: "x-api-key"
    in: "header"
security: # これによりAPI全体に認証がかかる
  - api_key: []
paths:
  /hello:
    get:
      summary: Greet a user
      operationId: hello
      x-google-backend:
        address: ${CLOUD_RUN_FUNCTIONS_HOST}
        protocol: h2
      responses:
        '200':
          description: A successful response
          schema:
            type: string
```

### 参考
https://cloud.google.com/endpoints/docs/openapi/restricting-api-access-with-api-keys?hl=ja

https://cloud.google.com/endpoints/docs/openapi/openapi-limitations?hl=ja#api_key_definition_limitations

# 2. Cloud Endpointsのリソースを定義
事前に作成した ESPv2 Cloud Run サービスのホスト名を `service_name` に設定し、`templatefile` を利用して `openapi_config` にて yml に引数を渡して、リソース作成後に書き換えてもらいます。
```tf
resource "google_endpoints_service" "openapi_service" {
  service_name   = local.gateway_domain
  project        = local.project
  openapi_config = templatefile("${path.module}/openapi-functions.yml", {
    CLOUD_RUN_HOST           = local.gateway_domain
    CLOUD_RUN_FUNCTIONS_HOST = google_cloudfunctions2_function.default.service_config[0].uri
  })
}
```

# 3. ESPv2 サービスのランタイムサービスアカウントに必要なIAMポリシーを追加する
Service Management と Service Control を呼び出す権限を ESPv2 サービスアカウントに追加します。
ランタイムサービスアカウントは `{プロジェクト番号}-compute@developer.gserviceaccount.com` の形式です。

https://cloud.google.com/functions/docs/securing/function-identity?hl=ja

```tf: servicemanagement, servicecontroller と Cloud Run functions 呼び出しのIAMポリシーを追加する
resource "google_project_iam_member" "espv2_service_account_service_controller" {
  project = local.project
  role    = "roles/servicemanagement.serviceController"
  member  = "serviceAccount:${data.google_project.project.number}-compute@developer.gserviceaccount.com"
}

resource "google_project_iam_member" "espv2_service_account_function_invoker" {
  project = local.project
  role    = "roles/cloudfunctions.invoker"
  member  = "serviceAccount:${data.google_project.project.number}-compute@developer.gserviceaccount.com"
}
```

# 4. ESPv2 イメージをビルドする
公式は動的に値を設定しようとしてややこしい手順になっています。なのでこちらで ESPv2 Cloud Run サービスに使うイメージのバージョンは固定してあげます。

### 注意点
- `google_endpoints_service` リソースが作成されないと取得できない情報があるので、`depends_on` に指定しておきます。
- `openapi.yml` の情報を編集すると Cloud Endpoints の config_id が変更されるため、ESPv2 Cloud Run サービス用のコンテナイメージの再ビルドを行わないといけません。しかし `null_resource` は `triggers` で指定した内容が変更されたときにのみ再実行される仕組みになっているため、`triggers` で config_id の指定をする必要があります。
- `null_resource` が追加されたことで `terraform plan` をすると `Error: Inconsistent dependency lock file` が発生すると思います。

```bash
$ terraform plan 
╷
│ Error: Inconsistent dependency lock file
│ 
│ The following dependency selections recorded in the lock file are inconsistent with the current configuration:
│   - provider registry.terraform.io/hashicorp/null: required by this configuration but no version is selected
│ 
│ To update the locked dependency selections to match a changed configuration, run:
│   terraform init -upgrade
```

nullプロバイダーを初めて使う際に出てくるエラーです。以下を実行しましょう。

```bash
terraform init -upgrade
```

### ESPv2 Cloud Run サービスのイメージビルド `null_resource`

```tf: ESPv2 イメージビルド
resource "null_resource" "building_new_image" {
  triggers = {
    config_id          = google_endpoints_service.openapi_service.config_id
    cloud_run_hostname = google_endpoints_service.openapi_service.service_name
  }
  
  provisioner "local-exec" {
    command     = "chmod +x gcloud_build_image; ./gcloud_build_image -s $cloud_run_hostname -c $config_id -p ${local.project} -v ${local.ESPv2_image_ver}"
    environment = {
      config_id          = google_endpoints_service.openapi_service.config_id
      cloud_run_hostname = google_endpoints_service.openapi_service.service_name
    } 
  }

  depends_on = [
    google_endpoints_service.openapi_service
  ]
}
```

# 5. ビルドしたイメージで事前に作っておいた ESPv2 Cloud Run サービスを更新する
あらかじめ作成しておいた ESPv2 Cloud Run サービス を、先ほどビルドしたイメージで更新します。先ほど `null_resource` で作成したイメージを使用するようにします。

ただし gcloud で作成したので リソースが Terraform管理下になっておりません。このまま `terraform apply` をすると同じ名前の Cloud Runサービスを作ろうとして、結果重複作成のエラーが発生します。

なので事前に作成した Cloud Run サービスをTerraform の管理下に移行する必要があります。

```bash
terraform import google_cloud_run_v2_service.gateway \
 projects/プロジェクト名/locations/リージョン/services/[Cloud Run サービス名]
```

これを実行することで、Cloud Run サービスが重複作成されず、既存の ESPv2 Cloud Run サービスのイメージが更新されるようになります。

```
Plan: 13 to add, 1 to change, 0 to destroy.
```

ここで作成する ESPv2 Cloud Run サービスのリソースは以下を設定しています。
- `name` は事前に作成した ESPv2 Cloud Run サービスの名称と同じにする
- リクエスト時のみに起動する
- 認証なし
- `depends_on` で実行順を指定

```tf: APIゲートウェイ(Cloud Run)の作成
resource "google_cloud_run_v2_service" "gateway" {
  name                = "gateway"
  location            = local.region
  deletion_protection = false

  template { 
    containers {
      # null_resource で作成したイメージを使用
      image = format(
        "gcr.io/%s/endpoints-runtime-serverless:%s-%s-%s",
        local.project,
        local.ESPv2_image_ver,
        google_endpoints_service.openapi_service.service_name,
        google_endpoints_service.openapi_service.config_id
      )
      # 起動設定
      resources {
        limits = {
          "cpu" = "1"
          "memory" = "1Gi"
        }
        cpu_idle          = true
        startup_cpu_boost = false
      }
    }
  }

  depends_on = [
    google_endpoints_service.openapi_service,
    null_resource.building_new_image
  ]
}

# 
resource "google_cloud_run_v2_service_iam_binding" "binding" {
  project  = local.project
  location = google_cloud_run_v2_service.gateway.location
  name     = google_cloud_run_v2_service.gateway.name
  role     = "roles/run.invoker"
  members = [
    "allUsers"
  ]
}
```

# 6. いざ `terraform apply`
完成した `main.tf` は以下のレポジトリに載せています。

https://github.com/kei-tenkawa/advent-calendar-2024/blob/fa8b8b88419b57387a2e5c12e0c4e8c88b23ec1a/main.tf

ここまで作成した `main.tf` で `terraform apply` をすると、以下のURLが出力されます。

```bash
Apply complete! Resources: 0 added, 0 changed, 0 destroyed.

Outputs:

function_uri = "https://xxx.a.run.app"
gateway_uri = "https://yyy.a.run.app"
```

ここで出力された `gateway_uri` に出力されたURLを呼び出してみます。

```bash
$ curl https://yyy.a.run.app
{"message":"The current request is not defined by this API.","code":404}
```

おっアクセスできないようになっていますね。`openapi-functions.yml` で `/hello` パスに Cloud Run functions のURLのマッピングを行っているので、このURLではアクセスできないようになっています。

と言うことで `https://yyy.a.run.app/hello` で呼び出してみましょう。

```bash
$ curl https://yyy.a.run.app/hello                                                                             
{"message":"UNAUTHENTICATED: Method doesn't allow unregistered callers (callers without established identity). Please use API Key or other form of API consumer identity to call this API.","code":401}
```

APIキーをヘッダーに入れていないので認証エラーがちゃんと出ました。

# 7. APIキーの発行
今度は APIキーを発行します。APIキーの発行は Terraform で行うと面倒くさそうなので gcloud コマンドで行います。

以下の手順を踏みます。
- キーの発行
- キーに制限をかける

### キーの発行
`test` という名前でキーを発行します。
```bash
 gcloud services api-keys create --display-name=test
```

キーが作成されたか確認します。

```bash
gcloud services api-keys list
```

ここで作成したキーの `uid` を控えます。

### キーに制限をかける
APIキーはどこからでも利用できたらいけません。ESPv2 Cloud Run サービスのエンドポイント（Terraform の ouput で出力される ESPv2 Cloud Run サービスのURL）でのみ使用できるようにします。
その他に利用されるIPやリファラー、プラットフォームの制限もかけられますが、今回は行いません。
```bash
gcloud services api-keys update APIキーID \
--api-target=service=[ESPv2 Cloud Run サービスのエンドポイント]
```

コンソール画面をみると、ちゃんと反映されています。

![スクリーンショット 2024-12-23 0.24.39.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/374872/bf9344ad-c315-0c1e-bb63-ca476dd16dc3.png)


### 8. APIキーを使って呼び出してみる
あとは APIキーをヘッダーに含めて読んでみます。

```bash
$ curl -H "x-api-key:APIキー" https://yyy.a.run.app/hello
Hello, World!
```

無事、呼び出すことができました。

### 参考
https://cloud.google.com/docs/authentication/api-keys?hl=ja#before_you_begin

# おわりに
本記事ではAPIキー認証をCloud Run functionsのエンドポイントに付与する方法を紹介しました。公式のやり方に則って、一回事前に ESPv2 Cloud Run サービスを作っておく方法で行いましたが、カスタムドメインで行う方法の方がラクです。次回以降の記事で紹介したいと思います。

# おまけ
`terraform destroy` をして再度 `terraform apply` しようとしてもで Cloud Endpointsの再生成に関するエラーが発生すると思います。Cloud Endpoints サービスは削除後30日間、同じ名前のサービスを作成できなくなります。
再生成したい場合は以下を実行してあげましょう。

```bash
gcloud endpoints services undelete ドメイン名 --project=プロジェクトID
```

ここの運用はイケていない気がするのでなんとかしたさはありますね。