🔥

Cloud Functionsを第2世代へ移行する

2024/04/23に公開

先日、筆者が開発/運営しているNitteをCloud Function 第1世代→第2世代に完全に移行しました🎉
本記事では、移行の流れを紹介しつつ、実際にやってみてわかったTips(💡)やはまりポイント(🚨)をご紹介します。公式ドキュメントも併せてご参照ください。

🔥 Cloud Functions 第2世代とは?

Cloud Function 第2世代では、第1世代インターフェースを保持しつつ、裏側をCloud Runに移行し機能が大幅に強化されました。

機能強化の中でも、特に大きいのは 同時実効性の向上 です。
第1世代では、1インスタンスにつき1件しかリクエストを捌けなかったのが、第2世代では1インスタンスで最大1000件まで同時に捌けるようになりました。これにより、サーバーレス関数の最大の弱点であるコールドスタートを最小限に抑え、レイテンシの大幅な改善が期待できます。

さらに、リクエスト最大時間の延長、より高いマシンスペックを選べるなど盛りだくさんです。未だパブリックプレビューではあるものの、公式ドキュメントでも第2世代の利用が推奨されています。

可能な限り、新しい関数には Cloud Functions(第 2 世代)を選択することをおすすめします。


🔧 関数の移行

まずはじめに、関数を移行します。

  • onRequestなど、関数を直接インポートする
  • regionなどの構成値は、関数の第一引数に指定する

が、全ての関数に共通する移行の方針です。
これを踏まえて1つずつ見ていきましょう。

https関数

a. onRequest を直接インポートします。
b. regionなどの構成値は、onRequestの第一引数で指定します。

// 第1世代
import functions from 'firebase-functions'
export const httpsV1 = functions
  .region('asia-northeast1')
  .https.onRequest((req, res) => {
    res.send('Hello from Function v1')
  })
 
// 第2世代
// a. onRequestを直接インポート
import { onRequest } from "firebase-functions/v2/https"
export const httpsV2 = onRequest({
  region: 'asia-northeast1' // b. 構成値はonRequestの第一引数で指定
}, (req, res) => {
  res.send('Hello from Firebase! V1')
})

💡書き換えのコツ

実際に書き換えを行う際は、以下のような流れで行うとスムーズに書き換えできます

  1. import functionsを削除、 onRequestをインポート

    - import functions from 'firebase-functions'
    + import { onRequest } from "firebase-functions/v2/https"
    export const https =
    	functions
      .region('asia-northeast1')
      .https
      .onRequest((req, res) => {
        res.send('Hello from Function v1')
      })
    
  2. functions〜https部分を削除

    export const https =
    -  functions
    -    .region('asia-northeast1')
    -    .https
    onRequest((req, res) => {
      res.send('Hello from Function v1')
    })
    
  3. 構成値を移植

    export const https =
      onRequest(
    +   { region: 'asia-northeast1' },
        (req, res) => {
          res.send('Hello from Function v1')
        })
    
    

💡 setGlobalOptionsの利用

上の例では、説明のために構成値{ region: 'asia-northeast1' } を書きましたが、毎回書くのは手間ですし、冗長ですよね?そんなときは、setGlobalOptions がで全ての関数のデフォルト値を設定できます。

// index.tsなどエントリーポイント
import { setGlobalOptions } from 'firebase-functions/v2'
setGlobalOptions({ region: 'asia-northeast1' })

こうすることで、

  • デフォルトでは、{} を渡すだけ
  • 個別に上書きしたい場合のみ構成値を指定する

形で書くことができます。

// デフォルト
export const defaultHttps =
  onRequest(
    {}, // デフォルト値 region: 'asia-norteast1'
    (req, res) => {
      res.send('Hello from Function v1')
    })

// 個別に上書き
export const overrideHttps =
  onRequest(
    { region: 'us-central1' }, // regionを変更
    (req, res) => {
      res.send('Hello from Function v1')
    })

trigger関数

続いて、trigger関数も移行します。基本はhttps関数と同じです。

onCreate

a. onDocumentCreated として単体でimportします。
b. documentのパスなどの構成値は、第一引数に指定します。
c. 引数がeventに統合され、paramsの取得の仕方、snapshotdataの取得の仕方が変更になっているので書き換えが必要です。

// 第1世代
import functions from "firebase-functions"
export const onCreateTriggerV1 =
  functions
    .firestore
    .document('/Hoge/{id}')
    .onCreate(async (snapshot, context) => {
      const { id } = context.params
      const data = snapshot.data()
    })

// 第2世代
// a. onDocumentCreated として単体でimport
import { onDocumentCreated } from 'firebase-functions/v2/firestore'
export const onCreateTriggerV2 = onDocumentCreated(
  {
    document: '/Hoge/{id}' // b. documentのパスなどの構成値は、第一引数で指定
  }, async (event) => {
     // c. paramsの取得
    const { id } = event.params
    // c. snapshotの取得
    const snapshot = event.data
    // c. dataの取得
    const data = snapshot!.data()
  })

onUpdate

a. onDocumentUpdated として単体でimportします。
b. documentのパスなどの構成値は、第一引数に指定します。
c. 引数はeventに統合され、change はevent.dataから取得する形に変更されています。

// 第1世代
import functions from "firebase-functions"
export const onUpdateTriggerV1 = functions.firestore
  .document('/Hoge/{id}')
  .onUpdate(async (change, context) => {
    const { id } = context.params
    const beforeData = change.before.data()
    const afterData = change.after.data()
  })

// 第2世代
// a. onDocumentUpdated として単体でimport
import { onDocumentUpdated } from 'firebase-functions/v2/firestore'
export const onUpdateTriggerV2 = onDocumentUpdated({
  document: '/Hoge/{id}' // b. documentのパスなどの構成値は、第一引数で指定
}, async (event) => {
  // c. paramsの取得
  const { id } = event.params
  
  // c. changeの取得
  const change = event.data!
  const beforeData = change.before.data()
  const afterData = change.after.data()
})

🚨 第2世代のonDocumentUpdatedは差分がない場合もトリガーされる

onDelete

a. onDocumentDeletedとして、単体でimportします。
b. documentのパスなどの構成値は、第一引数に指定します。
c. 引数がeventに統合され、paramsの取得の仕方、snapshotdataの取得の仕方が変更になっているので書き換えが必要です。

// 第1世代
import functions from "firebase-functions"
export const onDeleteTriggerV1 =
  functions
    .firestore
    .document('/Hoge/{id}')
    .onDelete(async (snapshot, context) => {
      const { id } = context.params
      const data = snapshot.data()
    })

// 第2世代
// a. onDocumentDeletedとして、単体でimport
import { onDocumentDeleted } from 'firebase-functions/v2/firestore'
export const onDeleteTriggerV2 = onDocumentDeleted(
  {
    document: '/Hoge/{id}' // documentのパスなどの構成値は、第一引数で指定
  }, async (event) => {
    // c. paramsの取得
    const { id } = event.params
    // c. snapshotの取得
    const snapshot = event.data
    // c. dataの取得
    const data = snapshot!.data()
  })

scheduled関数

最後はschedule関数です。これも他の関数と同様に
a. onScheduleを単体import
b. 構成値は関数の第一引数に指定
の流れで、移行できます。

// 第1世代
import functions from 'firebase-functions'
export const scheduleV1 = functions.pubsub
  .schedule('1 of month 10:00')
  .timeZone('Asia/Tokyo')
  .onRun(async (_) => {
    console.log('V1 schedule')
  })

// 第2世代
// a. onScheduleを単体import
import { onSchedule } from 'firebase-functions/v2/scheduler'
export const scheduleV2 = onSchedule({ 
  schedule: '1 of month 10:00', // b. 構成値は関数の第一引数に
  timeZone: 'Asia/Tokyo'
}, async (event) => {
  console.log('V1 schedule')
})

🗒️ 環境変数をfunctions.configからparamsへ移行

第1世代で利用できたfunctions.config は廃止され、代わりに第2世代ではparams モジュールを使用します。

paramsモジュールを使用することで、

  • デプロイ時の値の設定漏れを防ぐことができる
  • 設定できる値にバリデーションをかけることができる

などのメリットがあります。

通常の環境変数

これまでfunctions.configで定義していた、環境変数は

  1. defineString で宣言
  2. .value()でアクセス

する形に書き換えることができます。

// 第1世代
import functions from 'firebase-function'
// 単純にconfigから取得できた
functions.config().someParam

// 第2世代
import { defineString } from 'firebase-functions/params'
import { onRequest } from 'firebase-functions/v1/https'

// a. defineString で宣言(キーはアッパースネークでないといけない)
const someParam = defineString('SOME_PARAM')
export const hello = onRequest((req, res) => {
  // b. .value()でアクセス
  res.send(someParam.value())
})

秘匿性の高い環境変数

秘匿性の高いAPIキーなどのパラメータは、

  1. defineSecret で宣言
  2. secrets: 利用する値を宣言
  3. .value()でアクセス

の流れで、.envファイルではなくシークレットマネージャーを通して管理することができます。

import { defineSecret } from 'firebase-functions/params'
// a. defineSecret で宣言
const someAPIKey = defineSecret('SOME_API_KEY_2');

export const playground = onRequest({
  region: 'asia-east1',
  secrets: [someAPIKey] // b. 利用する値を宣言
}, (req, res) => {
  // c. .value()でアクセス
  console.log('discordApiKey:', someAPIKey.value())
  res.send()
})

🚀 デプロイ

最後はいよいよデプロイです。

第1世代の関数と同名で第2世代の関数をデプロイできないため、公式では、古い関数をリネームしておきリダイレクトさせる方法が紹介されています。
しかし、この方法で、関数を1つずつ移行するのは非常に手間がかかるので、「絶対に無停止で移行したい」でなければ、単純にメンテにして第1世代全部削除→第2世代全部デプロイがおすすめです。

流れ

  1. サービスをメンテナンスモードに入れる

  2. GCPにアクセスして、第1世代の関数を全て削除。
    コマンドがなさそうだったので、画面から行いました。
    この時関数の数が全部でいくつあるかチェックしておきましょう

  3. 第2世代の関数を全てデプロイ
    関数の数が、第1世代の時と同じであることを確認します

  4. 動作確認

🚨 ”Your client does not have permission to get URL …"エラーが が出る。

💡 minInstanceの活用

minInstanceは、関数の最小のインスタンスの数です。これを1に設定することで、コールドスタートを防止し、レイテンシを大幅に改善できます。(※常時インスタンスを立ち上げるため、費用がかかります。料金シュミレータで確認してから設定するようにしましょう。)

この機能自体は第1世代から利用できた機能ですが、先述の通り、第2世代から1インスタンスで複数のリクエストを同時に捌けるようになったため、minInstanceの恩恵をより大きく受けられるようになりました。登録時の認証など、遅いことでユーザー体験を損ねたくない場合は、使用を検討してみることをお勧めします。

指定するには構成値の minInstances に数字を指定します。

export const authenticate = onRequest({
  minInstances: 1,
}) //...

実際には、stagingなど他の環境で誤って使用されないように、環境を判定して指定するのが良いでしょう。

export const authenticate = onRequest({
  minInstances: detectEnv() === 'production' ? 1 : 0,
}

まとめ

以上、今回ご紹介した流れで、そこまで手間なく全て移行することができました。
移行した上で、minIstances を使うことで、コールドスタートの悩みはかなり減ったので、費用対効果は十分あったと考えています。

次世代への移行を検討してみてはいかがでしょうか🔥
実際に移行される際は↓の公式ドキュメントも併せてご参照ください。

参考:
第1世代のNode.js関数を第2世代にアップグレードする
Cloud Functions バージョンの比較
環境を構成する

Discussion