🪝

Firestore triggers Cloud Run を実現する

2021/09/17に公開2

はじめに

Firebase Firestoreでは、特定パスのドキュメントの書き込みをトリガーにFirebase Functionsを起動し任意の処理を行うことができる。コレと同様なことを、Cloud Runを起動する形で実現できないかと考えていた。まあ悪くはないかなと言う形で実現できたので筆をとった。断りがない限り、Nativeモードで話を進めていく。

Google CloudのOpen Cloud SummitのEventarcの発表を聞いて興味を持ったのがきっかけで、実際にいろいろ触ってみるに至った。
https://cloudonair.withgoogle.com/events/open-cloud-summit?talk=d2-04

求める条件

Firestore triggers Functions の場合と同じことは最低限できてほしい。

  • 特定のコレクションに対してトリガーを設定できる
  • トリガーイベントとして Create/Update/Delete (or Write) を設定できる

結論

  • うまくいった方法
    • Firestore Data Access Audit Log → Cloud Logging Router Sink → Pub/Sub → Cloud Run
  • ちょっとむずかしいかなという方法
    • EventarcのFirestore Write系トリガー → Cloud Run
  • Audit Logリリース以前に僕が取っていた無理やりな方法
    • Firestore Triggered Functions → Cloud Run

それぞれの方法を解説する(最後の方法以外)。

前提

まず前提として、Firestoreのデータアクセス監査ログが最近プレビューリリースされた。1, 2個目の方法はこれがベースにある。プレビュー版なので仕様が変わる可能性があることに注意。
https://cloud.google.com/firestore/docs/audit-logging#data-access-audit-logs:-data_read-and-data_write

Eventarcを活用する(ちょっとむずかしいかなという方法)

まずこれから試したのと、ここで出てきた問題を解決したのが「うまくいった方法」なのでまずこちらについて解説する。興味ない方は次のSectionへ。

設定と動作

Eventarcについての公式ブログ記事は下記。簡単に言うとAudit Log or Pub/Pub topicをソース(トリガー)にして、CloudEventsフォーマットに情報を整えてCloud Runへイベントを送信できる機能。Cloud Eventsというのは、イベントドリブンアーキテクチャで利用されるイベントデータの仕様。
https://cloud.google.com/blog/ja/products/serverless/eventarc-unified-eventing-experience-google-cloud

Eventarcを設定するにはCloud Runのサービスのページにいく。トリガータブを開くと「EVENTARCトリガーを追加」ボタンがあるのでそれ押すといろいろ設定できる。トリガーとなるサービスやメソッドを、たくさんある中から選んで、どのリソースやリージョンの場合トリガーするのか、呼び出し時のサービスアカウントを指定できる。最後にこのCloud Runのパスを指定すればOK。

試しに立てたサーバーに、Eventarcから来たリクエストのBodyとHeaderをログに出力するとこんな感じになった。必要なさそうな情報は YOUR_* みたいな形で適宜省略してる。以下はFirestore(Native)のコンソールから /samples/:id にドキュメントを作成した場合。

出力結果(ちょっと長いので折りたたんでる)
{
  "body": {
    "resource": {
      "labels": {
        "service": "firestore.googleapis.com",
        "project_id": "YOUR_PROJECT_ID",
        "method": "google.firestore.v1.Firestore.Write"
      },
      "type": "audited_resource"
    },
    "logName": "projects/YOUR_PROJECT_ID/logs/cloudaudit.googleapis.com%2Fdata_access",
    "protoPayload": {
      "status": {},
      "requestMetadata": {
        "destinationAttributes": {},
        "callerSuppliedUserAgent": "YOUR_UA",
        "requestAttributes": {
          "auth": {},
          "time": "2021-09-17T01:51:32.185016Z"
        },
        "callerIp": "YOUR_IP"
      },
      "authenticationInfo": {
        "principalEmail": "YOUR_EMAIL_ADDRESS"
      },
      "serviceName": "firestore.googleapis.com",
      "resourceName": "projects/YOUR_PROJECT_ID/databases/(default)",
      "serviceData": {},
      "methodName": "google.firestore.v1.Firestore.Write",
      "metadata": {
        "@type": "type.googleapis.com/google.cloud.audit.DatastoreServiceData"
      },
      "authorizationInfo": [
        {
          "granted": true,
          "permission": "datastore.entities.create",
          "resourceAttributes": {},
          "resource": "projects/YOUR_PROJECT_ID/databases/"
        },
        {
          "granted": true,
          "resource": "projects/YOUR_PROJECT_ID/databases/",
          "permission": "datastore.entities.update",
          "resourceAttributes": {}
        }
      ],
      "request": {
        "database": "projects/YOUR_PROJECT_ID/databases/(default)",
        "@type": "type.googleapis.com/google.firestore.v1.WriteRequest",
        "writes": [
          {
            "update": {
              "name": "projects/YOUR_PROJECT_ID/databases/(default)/documents/samples/ewMoAiRAA1t43S1PnSAv"
            }
          }
        ]
      }
    },
    "insertId": "-5jrcl3eizimu",
    "severity": "INFO",
    "timestamp": "2021-09-17T01:51:32.162978Z",
    "receiveTimestamp": "2021-09-17T01:51:32.436039856Z"
  },
  "headers": {
    "ce-methodname": "google.firestore.v1.Firestore.Write",
    "accept": "application/json",
    "x-forwarded-proto": "https",
    "ce-id": "projects/YOUR_PROJECT_ID/logs/cloudaudit.googleapis.com%2Fdata_access-5jrcl3eizimu1631843492162978",
    "ce-dataschema": "https://googleapis.github.io/google-cloudevents/jsonschema/google/events/cloud/audit/v1/LogEntryData.json",
    "traceparent": "00-8c6fc1640a9b6125909293da6aa41d0f-d0fbedd2fab6765c-01",
    "content-type": "application/json; charset=utf-8",
    "from": "noreply@google.com",
    "ce-source": "//cloudaudit.googleapis.com/projects/YOUR_PROJECT_ID/logs/data_access",
    "ce-specversion": "1.0",
    "ce-subject": "firestore.googleapis.com/projects/YOUR_PROJECT_ID/databases/(default)",
    "forwarded": "for=\"34.116.20.31\";proto=https",
    "host": "renderer-54pvsxgf2q-an.a.run.app",
    "x-cloud-trace-context": "8c6fc1640a9b6125909293da6aa41d0f/15058891269448562268;o=1",
    "content-length": "1566",
    "accept-encoding": "gzip, deflate, br",
    "ce-recordedtime": "2021-09-17T01:51:32.162978Z",
    "ce-time": "2021-09-17T01:51:32.436039856Z",
    "ce-type": "google.cloud.audit.log.v1.written",
    "ce-servicename": "firestore.googleapis.com",
    "user-agent": "APIs-Google; (+https://developers.google.com/webmasters/APIs-Google.html)",
    "x-forwarded-for": "34.116.20.31",
    "ce-resourcename": "projects/YOUR_PROJECT_ID/databases/(default)",
    "authorization": "Bearer YOUR_JWT"
  }
}

body はFirestoreのAudit Logがそのまま入っている模様。headers にある ce-* はCloud Events特有のものらしい。 authorization にはJWTが埋まってるので、アプリケーション側でこれを検証して、正しい呼び出し元か判断することができる(未認証を許可してる場合)。

試す分にはとても簡単。

難しポイント

特定のコレクションに対してトリガーを設定できる を実現することが難しい。設定画面のリソースのSpecific resourceを指定すればいけるんじゃないかと思うかもしれないが、これはあくまでリクエストボディの protoPayload.resourceName を対象にフィルターしてくれるだけだった。この状態だと1つのエンドポイントにすべてのドキュメントのWrite系の操作がリクエストされてしまう。「うちのサービスはWrite少ないのでアプリケーション側でさばきます!」ならこれで良いんじゃないだろうか。

この辺は今後改善されたりする可能性はあると思うので、現状特有の問題かもしれない。

Loggingのログルーターのシンクを活用(うまくいった方法)

Cloud Loggingには、特定のフィルターにマッチしたログエントリを別の場所へ流すという機能がある。Audit Logとして上記のリクエストボディの内容が流れているわけなので、拾いたい内容にマッチするフィルターを作ってPub/Subに送ってあげればあとはCloud Runで拾えるようになる。

まずFirestore Data Access Audit Logを有効にする。

次にPub/Subのトピックを作成する。そのトピックのサブスクリプションを、Push型にしつつエンドポイントはご自身のサーバーに設定して作成する。

最後にCloud Loggingで、ログルーティングシンクを作成する。トピックは👆で作成したものを選ぶ。

フィルターは👇みたいな感じにすれば、 /samples/:id のCreateだけ拾えるようになる。詳細な記法については 公式のこちら に書いてある。(地味にめんどくさいのは currentDocument.exists はAdmin/Client SDKのどっちから作成されたかによって、フィールドごと存在していたりしていなかったりすること。)

protoPayload.methodName = "google.firestore.v1.Firestore.Write" AND
protoPayload.request.writes.update.name =~ "projects\/YOUR_PROJECT_ID\/databases\/\(default\)\/documents\/samples\/[^/]+$" AND
(NOT protoPayload.request.writes.currentDocument:* OR protoPayload.request.writes.currentDocument.exists = false)

ここまで設定できれば、Firestoreのドキュメントを作成すると以下のようなリクエストボディが送られてくる。

{
  "message": {
    "data": "[Audit LogがBASE64エンコードされたやつ]",
    "messageId": "3065956847068101",
    "publishTime": "2021-09-17T05:51:40.637Z",
    "publish_time": "2021-09-17T05:51:40.637Z",
    "message_id": "3065956847068101",
    "attributes": {
      "logging.googleapis.com/timestamp": "2021-09-17T05:51:39.061628Z"
    }
  },
  "subscription": "projects/YOUR_PROJECT_ID/subscriptions/YOUR_SUBSCRIPTION_ID"
  }
}

これで完成。あとは必要なトリガーごとにシンクを作成していけばいける。けっこう大変だと思うのでTerraformとかでコード管理してコピペで増やせる感じにするのがよさそう。

おわりに

冷静になったボク「Cloud Runアプリケーションコードからロジックが走ったタイミングで。Create/Update/DeleteのイベントをPub/Subに流すほうが楽じゃね?」
→ ほんまそれな(まあ確実にここのデータが作成されたときに動かしたいみたいなユースケースなら今回の方法はなしではないと思うけど)。

試せていないこと

PubSubにもメッセージをフィルタする機能がある模様。これを活用すればシンクはいらないかも?どこまで詳細にフィルターできるか次第。
https://cloud.google.com/pubsub/docs/filtering?hl=ja

おまけ

メモみたいなものなので興味ある人だけどうぞ。

Firestore Datastoreモードのときの Audit Log

Nativeモードと結構違っている。protoPayload.metadataにpathが入っていたり。

Audit Log
{
  "protoPayload": {
    "@type": "type.googleapis.com/google.cloud.audit.AuditLog",
    "status": {},
    "authenticationInfo": {
      "principalEmail": "YOUR_EMAIL_ADDRESS"
    },
    "requestMetadata": { "省略": "します" },
    "serviceName": "datastore.googleapis.com",
    "methodName": "google.datastore.v1.Datastore.Commit",
    "authorizationInfo": ["省略"],
    "resourceName": "projects/YOUR_PROJECT_ID/databases/(default)",
    "request": {
      "mutations": [
        {
          "insert": {
            "key": {
              "partitionId": {
                "namespaceId": "sample"
              },
              "path": [
                {
                  "kind": "Test"
                }
              ]
            }
          }
        }
      ],
      "projectId": "YOUR_PROJECT_ID",
      "mode": "NON_TRANSACTIONAL",
      "@type": "type.googleapis.com/google.datastore.v1.CommitRequest"
    },
    "metadata": {
      "keys": [
        "projects/YOUR_PROJECT_ID/databases/(default)/namespaces/sample/documents/Test/__id5632499082330112__"
      ],
      "@type": "type.googleapis.com/google.cloud.audit.DatastoreServiceData"
    }
  },
  "insertId": "7s8nt6eboiwg",
  "resource": {
    "type": "audited_resource",
    "labels": {
      "project_id": "YOUR_PROJECT_ID",
      "service": "datastore.googleapis.com",
      "method": "google.datastore.v1.Datastore.Commit"
    }
  },
  "timestamp": "2021-09-17T06:10:15.811549Z",
  "severity": "INFO",
  "logName": "projects/YOUR_PROJECT_ID/logs/cloudaudit.googleapis.com%2Fdata_access",
  "receiveTimestamp": "2021-09-17T06:10:16.358258823Z"
}

Nativeモードでいろいろ試した記録

/samples/:id に コンソールからcreateしたときのaudit log
{
  "protoPayload": {
    "@type": "type.googleapis.com/google.cloud.audit.AuditLog",
    "status": {},
    "authenticationInfo": {
      "principalEmail": "YOUR_EMAIL_ADDRESS"
    },
    "requestMetadata": { "省略": "します" },
    "serviceName": "firestore.googleapis.com",
    "methodName": "google.firestore.v1.Firestore.Write",
    "authorizationInfo": [
      {
        "resource": "projects/YOUR_PROJECT_ID/databases/",
        "permission": "datastore.entities.create",
        "granted": true,
        "resourceAttributes": {}
      },
      {
        "resource": "projects/YOUR_PROJECT_ID/databases/",
        "permission": "datastore.entities.update",
        "granted": true,
        "resourceAttributes": {}
      }
    ],
    "resourceName": "projects/YOUR_PROJECT_ID/databases/(default)",
    "request": {
      "database": "projects/YOUR_PROJECT_ID/databases/(default)",
      "@type": "type.googleapis.com/google.firestore.v1.WriteRequest",
      "writes": [
        {
          "update": {
            "name": "projects/YOUR_PROJECT_ID/databases/(default)/documents/samples/9u7juy72zRXy2fMaeqlN"
          }
        }
      ]
    },
    "metadata": {
      "@type": "type.googleapis.com/google.cloud.audit.DatastoreServiceData"
    }
  },
  "insertId": "-4zrtfgehmtyg",
  "resource": {
    "type": "audited_resource",
    "labels": {
      "service": "firestore.googleapis.com",
      "project_id": "YOUR_PROJECT_ID",
      "method": "google.firestore.v1.Firestore.Write"
    }
  },
  "timestamp": "2021-09-17T00:36:21.802701Z",
  "severity": "INFO",
  "logName": "projects/YOUR_PROJECT_ID/logs/cloudaudit.googleapis.com%2Fdata_access",
  "receiveTimestamp": "2021-09-17T00:36:22.469546210Z"
}

👇 updateのときはupdateしたフィールドまでわかるようになってる。ちなみにdeleteのときは writes.updatewrites.delete になってる。

同じドキュメントをupdate
{
  "protoPayload": {
    "@type": "type.googleapis.com/google.cloud.audit.AuditLog",
    "status": {},
    "authenticationInfo": {
      "principalEmail": "YOUR_EMAIL_ADDRESS"
    },
    "requestMetadata": { "省略": "します" },
    "serviceName": "firestore.googleapis.com",
    "methodName": "google.firestore.v1.Firestore.Write",
    "authorizationInfo": [
      {
        "resource": "projects/YOUR_PROJECT_ID/databases/",
        "permission": "datastore.entities.update",
        "granted": true,
        "resourceAttributes": {}
      }
    ],
    "resourceName": "projects/YOUR_PROJECT_ID/databases/(default)",
    "request": {
      "@type": "type.googleapis.com/google.firestore.v1.WriteRequest",
      "writes": [
        {
          "update": {
            "name": "projects/YOUR_PROJECT_ID/databases/(default)/documents/samples/9u7juy72zRXy2fMaeqlN"
          },
          "updateMask": {
            "fieldPaths": [
              "hello"
            ]
          },
          "currentDocument": {
            "exists": true
          }
        }
      ],
      "database": "projects/YOUR_PROJECT_ID/databases/(default)"
    },
    "metadata": {
      "@type": "type.googleapis.com/google.cloud.audit.DatastoreServiceData"
    }
  },
  "insertId": "pt68mfe911sc",
  "resource": {
    "type": "audited_resource",
    "labels": {
      "project_id": "YOUR_PROJECT_ID",
      "service": "firestore.googleapis.com",
      "method": "google.firestore.v1.Firestore.Write"
    }
  },
  "timestamp": "2021-09-17T00:46:53.023738Z",
  "severity": "INFO",
  "logName": "projects/YOUR_PROJECT_ID/logs/cloudaudit.googleapis.com%2Fdata_access",
  "receiveTimestamp": "2021-09-17T00:46:53.216964721Z"
}

👇 protoPayload.request.writes.currentDocument.exists フィールドが false で存在してる…。👆 のcreateではなかったのに。

Admin SDKで batch create
{
  "protoPayload": {
    "@type": "type.googleapis.com/google.cloud.audit.AuditLog",
    "status": {},
    "authenticationInfo": {
      "principalEmail": "firebase-adminsdk-ik3id@YOUR_PROJECT_ID.iam.gserviceaccount.com",
      "serviceAccountKeyName": "//iam.googleapis.com/projects/YOUR_PROJECT_ID/serviceAccounts/firebase-adminsdk-ik3id@YOUR_PROJECT_ID.iam.gserviceaccount.com/keys/c7928016d6e78413f0276fee3d0842ee911ddd77"
    },
    "requestMetadata": { "省略": "します" },
    "serviceName": "firestore.googleapis.com",
    "methodName": "google.firestore.v1.Firestore.Commit",
    "authorizationInfo": [
      {
        "resource": "projects/YOUR_PROJECT_ID/databases/",
        "permission": "datastore.entities.create",
        "granted": true,
        "resourceAttributes": {}
      }
    ],
    "resourceName": "projects/YOUR_PROJECT_ID/databases/(default)",
    "request": {
      "@type": "type.googleapis.com/google.firestore.v1.CommitRequest",
      "database": "projects/YOUR_PROJECT_ID/databases/(default)",
      "writes": [
        {
          "currentDocument": {
            "exists": false
          },
          "update": {
            "name": "projects/YOUR_PROJECT_ID/databases/(default)/documents/samples/3"
          }
        },
        {
          "currentDocument": {
            "exists": false
          },
          "update": {
            "name": "projects/YOUR_PROJECT_ID/databases/(default)/documents/samples/4"
          }
        }
      ]
    },
    "metadata": {
      "keys": [
        "projects/YOUR_PROJECT_ID/databases/(default)/documents/samples/3",
        "projects/YOUR_PROJECT_ID/databases/(default)/documents/samples/4"
      ],
      "@type": "type.googleapis.com/google.cloud.audit.DatastoreServiceData"
    }
  },
  "insertId": "of5uqse9zyoc",
  "resource": {
    "type": "audited_resource",
    "labels": {
      "project_id": "YOUR_PROJECT_ID",
      "method": "google.firestore.v1.Firestore.Commit",
      "service": "firestore.googleapis.com"
    }
  },
  "timestamp": "2021-09-17T05:04:42.888944Z",
  "severity": "INFO",
  "logName": "projects/YOUR_PROJECT_ID/logs/cloudaudit.googleapis.com%2Fdata_access",
  "receiveTimestamp": "2021-09-17T05:04:43.676252968Z"
}

👇 protoPayload.request.writes.currentDocument.exists フィールドが false で存在してる…。

AdminSDKからcreate
{
  "protoPayload": {
    "@type": "type.googleapis.com/google.cloud.audit.AuditLog",
    "status": {},
    "authenticationInfo": {
      "principalEmail": "firebase-adminsdk-ik3id@YOUR_PROJECT_ID.iam.gserviceaccount.com",
      "serviceAccountKeyName": "//iam.googleapis.com/projects/YOUR_PROJECT_ID/serviceAccounts/firebase-adminsdk-ik3id@YOUR_PROJECT_ID.iam.gserviceaccount.com/keys/c7928016d6e78413f0276fee3d0842ee911ddd77"
    },
    "requestMetadata": { "省略": "します" },
    "serviceName": "firestore.googleapis.com",
    "methodName": "google.firestore.v1.Firestore.Commit",
    "authorizationInfo": [
      {
        "resource": "projects/YOUR_PROJECT_ID/databases/",
        "permission": "datastore.entities.create",
        "granted": true,
        "resourceAttributes": {}
      }
    ],
    "resourceName": "projects/YOUR_PROJECT_ID/databases/(default)",
    "request": {
      "@type": "type.googleapis.com/google.firestore.v1.CommitRequest",
      "writes": [
        {
          "currentDocument": {
            "exists": false
          },
          "update": {
            "name": "projects/YOUR_PROJECT_ID/databases/(default)/documents/samples/fromcode"
          }
        }
      ],
      "database": "projects/YOUR_PROJECT_ID/databases/(default)"
    },
    "metadata": {
      "@type": "type.googleapis.com/google.cloud.audit.DatastoreServiceData",
      "keys": [
        "projects/YOUR_PROJECT_ID/databases/(default)/documents/samples/fromcode"
      ]
    }
  },
  "insertId": "vmsmvmehtl5u",
  "resource": {
    "type": "audited_resource",
    "labels": {
      "service": "firestore.googleapis.com",
      "method": "google.firestore.v1.Firestore.Commit",
      "project_id": "YOUR_PROJECT_ID"
    }
  },
  "timestamp": "2021-09-17T05:08:12.392411Z",
  "severity": "INFO",
  "logName": "projects/YOUR_PROJECT_ID/logs/cloudaudit.googleapis.com%2Fdata_access",
  "receiveTimestamp": "2021-09-17T05:08:12.811630375Z"
}

👇 protoPayload.request.writes.currentDocument.exists フィールド存在せんのかーい。

ClientSDK から setDoc
{
  "protoPayload": {
    "@type": "type.googleapis.com/google.cloud.audit.AuditLog",
    "status": {},
    "authenticationInfo": {
      "principalEmail": "service-420206120486@firebase-rules.iam.gserviceaccount.com"
    },
    "requestMetadata": { "省略": "します" },
    "serviceName": "firestore.googleapis.com",
    "methodName": "google.firestore.v1.Firestore.Write",
    "authorizationInfo": [
      {
        "resource": "projects/YOUR_PROJECT_ID/databases/",
        "permission": "datastore.entities.create",
        "granted": true,
        "resourceAttributes": {}
      },
      {
        "resource": "projects/YOUR_PROJECT_ID/databases/",
        "permission": "datastore.entities.update",
        "granted": true,
        "resourceAttributes": {}
      }
    ],
    "resourceName": "projects/YOUR_PROJECT_ID/databases/(default)",
    "request": {
      "@type": "type.googleapis.com/google.firestore.v1.WriteRequest",
      "database": "projects/YOUR_PROJECT_ID/databases/(default)",
      "writes": [
        {
          "update": {
            "name": "projects/YOUR_PROJECT_ID/databases/(default)/documents/samples/fromclient"
          }
        }
      ]
    },
    "metadata": {
      "@type": "type.googleapis.com/google.cloud.audit.DatastoreServiceData"
    }
  },
  "insertId": "-gx43lfegc48o",
  "resource": {
    "type": "audited_resource",
    "labels": {
      "project_id": "YOUR_PROJECT_ID",
      "method": "google.firestore.v1.Firestore.Write",
      "service": "firestore.googleapis.com"
    }
  },
  "timestamp": "2021-09-17T05:21:41.078647Z",
  "severity": "INFO",
  "logName": "projects/YOUR_PROJECT_ID/logs/cloudaudit.googleapis.com%2Fdata_access",
  "receiveTimestamp": "2021-09-17T05:21:42.100440859Z"
}

Discussion

Takuto SatoTakuto Sato

Cloud LoggingのrouterはBQにも繋がると考えると、FirestoreでHistory TableつくりたいときBQによろしくできちゃうんですね

mogamoga

Logにデータの中身まで吐かれるわけじゃないのでそんなに単純にはいかねぇ気がします!