Firebase Cloud Functionsで関数をモジュール分割しつつ、Cold Start対策も行う

Sunday, July 12, 2020

FirebaseのCloud Functionsを使って開発する上で、関数をモジュール分割して管理する方法やCold Start対策の方法を見たことがある/実践している方が多いかなと思います。

一方で、この2つを組み合わせて導入しようとするととても大変で、重複したコードが大量発生したり、今後新しい関数を追加する毎に記述コストが高くなったりする可能性がでてきます。

今回はそれぞれのテクニックの簡単な紹介と、その2つを組み合わせつつ、できる限りコードをクリーンに保つ方法を紹介します。

関数をモジュール分割して管理する

何も考えずにエントリーポイントになるindex.tsに多量の関数を定義すると管理するのが大変になります。
Cloud Functionsでは、それぞれの関数を適切な単位でモジュール分割し、それらを構造的にexportすることで擬似的にエンドポイントのpathを区切ることができるようになっています。

例えば、以下のようにindex.tsに直接4つの関数を定義していたとして、これらをモジュール分割して管理していく方法を紹介します。

// index.ts
import * as functions from 'firebase-functions'

export const userOnCreate = functions.firestore
  .document('user/{userID}')
  .onCreate(async (snapshot, context) => {
    ...
  })

export const userOnUpdate = functions.firestore
  .document('user/{userID}')
  .onUpdate(async (snapshot, context) => {
    ...
  })

export const postOnCreate = functions.firestore
  .document('post/{postID}')
  .onCreate(async (snapshot, context) => {
    ...
  })

export const postOnUpdate = functions.firestore
  .document('post/{postID}')
  .onUpdate(async (snapshot, context) => {
    ...
  })

この4つの関数を、user.ts, post.tsの2つのファイルにそれぞれ分割して定義するようにします

// user.ts
import * as functions from 'firebase-functions'

export const onCreate = functions.firestore
  .document('user/{userID}')
  .onCreate(async (snapshot, context) => {
    ...
  })

export const onUpdate = functions.firestore
  .document('user/{userID}')
  .onUpdate(async (snapshot, context) => {
    ...
  })

// post.ts
import * as functions from 'firebase-functions'

export const postOnCreate = functions.firestore
  .document('post/{postID}')
  .onCreate(async (snapshot, context) => {
    ...
  })

export const postOnUpdate = functions.firestore
  .document('post/{postID}')
  .onUpdate(async (snapshot, context) => {
    ...
  })

このとき、2つのファイルで同じ関数名があったとしても問題ありません。
これらのファイルを、index.tsファイルでimportし、それらをそのままexportします。
import * as User from './user'でimportされるものは、先程定義した関数を含むObjectとなるため、スプレッド演算子を活用してexportすると、
const定義するObjectの中にそのまま関数の定義を展開することができます。

// index.ts
import * as User from './user'
import * as Post from './post'

export const user = { ...User }
export const post = { ...Post }

このように定義することで、最終的にindex.tsからは以下のObjectがexportされることになります:

exports = {
  user: {
    onCreate: <...関数の実体>,
    onUpdate: <...関数の実体>
  },
  post: {
    onCreate: <...関数の実体>,
    onUpdate: <...関数の実体>
  }
}

ちなみに分割前は以下のような構造となっていました:

// (Before)
exports = {
  userOnCreate: <...関数の実体>,
  userOnUpdate: <...関数の実体>,
  postOnCreate: <...関数の実体>,
  postOnUpdate: <...関数の実体>
}

一つネストが深くなることで、同名の関数であっても問題なく扱うことができるようになります。関数の命名に悩むこともなくなりますね。
このようにexportされることで、関数のエンドポイント名がv1-user-onCreateとなります。

更にv1, firestore, user/postといった形でよりネストさせて分割する方法もおすすめです。
バージョニングしたい、firestore,storage,pubsubといったトリガーの種類ごとに分割したいなど、それぞれのニーズに合わせてネストの深さを調節すると良いと思います。

exports = {
  v1: {
    // firestoreトリガーに関する関数
    firestore: {
      user: {
        onCreate: <...関数の実体>,
        onUpdate: <...関数の実体>
      },
      post: {
        onCreate: <...関数の実体>,
        onUpdate: <...関数の実体>
      }
    },
    // storageトリガーに関する関数
    storage: {
      ...
    },
    // pubsubトリガーに関する関数
    pubsub: {
      ...
    }
  }
}

参考

公式のドキュメントにも、関数をモジュール化して複数の関数を管理する方法が紹介されています。

モジュール分割した関数のデプロイ

デプロイコマンドで関数を指定するときは、ハイフン(-)ドット(.)に変わる点に注意しましょう。

# v1-user以下に属する(複数の)関数をデプロイする
firebase deploy --only functions:user

# v1-user-onCreateの関数のみをデプロイする
firebase deploy --only functions:user.onCreate

モジュール化することで、特定のモジュール以下の関数(群)のみデプロイする、といったことが可能になります。
これも非常に重要な点で、関数の定義が数十個ある場合に、単純にfirebase deploy --only functionsを実行してしまうと、しばしばデプロイに失敗することがあります。
この失敗を回避するためにも、モジュール毎にデプロイを実行することが推奨されます。

稀に失敗する原因は、Cloud FunctionsのWrite(デプロイもしくは削除)の割当の上限に関係しています。詳しくは下記のドキュメントに説明が記載されています。

# 一度に多量の関数をデプロイすると稀に失敗することがある
$ firebase deploy --only functions

# モジュール毎にデプロイすることで、稀に失敗することを防ぐことができ、ある程度まとまった単位でのデプロイが可能になる
$ firebase deploy --only functions:v1
$ firebase deploy --only functions:admin
$ firebase deploy --only functions:v1.pubsub

関数のエンドポイントが変わるときの注意点

もし今回紹介した方法を適応することによって、関数のエンドポイント名に変更が生じる場合は何点か注意すべきことあります。
以下は簡単な説明になります。詳細は公式ドキュメントを参照ください。
(余談ですが、後から関数のリージョンを変えた場合も同様に注意が必要になります。)

イベントトリガーの場合

FirestoreのonCreateのようなイベントトリガー関数の場合は、旧いfunctionsを削除し、新しいエンドポイントの関数をデプロイすれば概ね問題ありません。
(厳密に言えば入れ替える間のダウンタイムに関しては気にしておく必要があります)

HTTPトリガー、Callable Functionsの場合

これらはエンドポイントが変わるとクライアント側に影響がでてきます。
Webアプリケーションなどであればアップデート対応は用意ですが、既にリリースして運用しているモバイルアプリケーションの場合はアップデートが必要になります。
また、クライアントからのアクセスが有る限りは古い関数を維持しておく必要があります。
その場合は古い関数と、新しくモジュール分割して定義した関数とで同じ関数の処理をexportするようにして対応します.

Cold Start対策を行う

関数が数個~十数個ほど、依存するモジュールも大してない状態であれば、関数のCold Startはさして問題になりません。
しかし、関数が数十個と増えてきたときに、多量の関数の定義をエントリーポイントとなるindex.tsで各種モジュールをimportし、関数をexportしていると、すべてのモジュールをロードしようとしてしまうために、Cold Startの時間が伸びてしまいます。

その場合は、

  • メモリの割当を変更する
  • 実行される関数(とその関数に依存するモジュールのみ)ロードされるようにする
  • FirestoreやStorageと同じリージョンで関数をデプロイする

といった対策を講じることである程度Cold Startの時間を短縮することができます。
これらのうち、はじめの2つについて説明していきます。

1.メモリの割当を変更する

こちらはとても簡単で、runWith()関数を最初に呼び出してから、各種関数の定義をするだけで済みます。 

import * as functions from 'firebase-functions'

// 割り当てるメモリを1GBに変更
export const foo = functions.runWith({ memory: '1GB' })
  .firestore
  .onCreate(async (snapshot, context) => {
    ...
  })

デフォルトは256MBになっています。 実はメモリの割当を増やすことで、実行される環境のCPUもアップグレードされます。 256MBだと割り当てられるCPUは400MHzですが、1GBのメモリを割り当てると1.4GHzまで上昇します。
ただしその分関数実行あたりの料金は上がるため、関数がどれだけ実行されるか、重たい処理をするような関数なのかを判断してチューニングをすると良いと思います。
下記のリンクから詳細が確認できます。

また、余談ですが自分の場合はすべての関数にデフォルトで512MBのメモリと、asia-northeast1のリージョンを設定したいため、以下のようなラッパーを用意しています。

// base_function.ts
import * as f from 'firebase-functions'
import { SUPPORTED_REGIONS } from 'firebase-functions'
const functions = (
  runtimeOptions: f.RuntimeOptions = { memory: '512MB' },
  region: typeof SUPPORTED_REGIONS[number] = 'asia-northeast1'
) => f.runWith(runtimeOptions).region(region)

export default functions
// index.ts

import functions from 'path/to/base_function'

// memory: 512MB, region: asia-northeast1
export const foo = functions()
  .firestore
  .document('foo/{fooID}')
  .onCreate(async (snapshot, context) => {
    ...
  })

// memory: 1GB, region: asia-northeast1, timeout: 300 sec
export const bar = functions({ memory: '1GB', timeout: 300 })
  .firestore
  .document('bar/{barID}')
  .onCreate(async (snapshot, context) => {
    ...
  })

ラッパーしたfunctions関数を使用することで、全ての関数に基本的に適応したいオプションを設定することが出来、
特定の関数ではメモリやタイムアウトを個別に設定する、といったことが可能になります。
リージョンの指定も、関数1つ1つに設定していると面倒ですし設定漏れも起きうるのでこのようにしておくと楽することができます。

2.実行される関数(とその関数に依存するモジュールのみ)ロードされるようにする

上記のメモリの割当の変更でもある程度改善はできますが、それでも関数が数十個となると、よりチューニングする必要がでてきます。
その場合は、次のようなワークアラウンドを投入することで、更にチューニングすることが可能です:

// before
export const foo = functions()
  .firestore
  .document('foo/{fooID}')
  .onCreate(async (snapshot, context) => {
    ...
  })

export const bar = functions()
  .firestore
  .document('bar/{barID}')
  .onCreate(async (snapshot, context) => {
    ...
  })

// after
const functionName = process.env.FUNCTION_NAME ?? process.env.K_SERVICE ?? ''
if (!functionName || functionName === 'foo') {
  exports.foo = functions()
  .firestore
  .document('foo/{fooID}')
  .onCreate(async (snapshot, context) => {
    ...
  })
}

if (!functionName || functionName === 'bar') {
  exports.foo = functions()
  .firestore
  .document('bar/{barID}')
  .onCreate(async (snapshot, context) => {
    ...
  })
}

デプロイ時には functionNameがundefinedになり、関数が実行される時(Cold Startする時)はfunctionNameに実行する関数名が代入されることを活用して、 functionNameがundefinedまたは実行したい関数名と一致するときのみ、関数をexportするようにします。

process.env.FUNCTION_NAMENode v8までで有効な環境変数、process.env.K_SERVICENode v10以降で有効な環境変数となっています。
例のために2つ書いていますが、自分の環境に合わせてどちらか片方を記述するだけで構いません。

この方法により、関数のデプロイ時はすべての関数がexportされデプロイされ、関数が実行されるときは指定の関数に関係するモジュールのみロードされるようになるため、 不要なimportを最小限に抑えてCold Startの時間を短くすることができます。

この方法のデメリットとしては、特にTypeScriptで書いている方には辛いデメリットになりますが、import文によるモジュールのimportができなくなります。
ファイルの先頭でimport文によるモジュールのimportをしてしまうと、その時点でそのファイルの中身がロードされてしまうので、余計なモジュールがロードされてしまうため、if文でtrueになったときのみ、requireを使ってモジュールをロードをする必要があります。

requireするモジュールのパスが変わった場合に気づきづらいという問題が発生します(その場合はデプロイエラーが発生するはずなので全く気づけないことはないはずですが…。)

(上記の例では他のモジュールから関数をimportしてexportすることをしていないため問題にはなりません、このデメリットに関しては以降の例で出てきます。)

このテクニックに関しては以前、Gincoさんの記事で触れられていたものになります。(他にも調べると海外のブログ記事やStackOverflowなどでも見つけることができます。

上記2つのテクニックを組み合わせる

前置きが長くなりましたが、ここからが本題になります。

まずは愚直に、2つのテクニックを組み合わせた例をご紹介します。
説明を簡略化するために、index.tsで、user.tsに定義したonCreate,onUpdateの関数をデプロイすることにします。

// index.ts

const functionName = process.env.FUNCTION_NAME ?? process.env.K_SERVICE ?? ''

exports.user = {}
if (!functionName || functionName === 'user-onCreate') {
  exports.user.onCreate = require('./user').onCreate 
}

if (!functionName || functionName === 'user-onCreate') {
  exports.user.onUpdate = require('./user').onUpdate 
}

このような書き方の問題点は以下の通りになります:

  • 関数を定義する度にif文を書かないといけない
  • モジュール分割して関数を定義している場合、import文の代わりにrequireを使うことになるが、そうするとrequire()の後の関数名が自動補完されないため、定義元の関数名を変更するとエラーになる
  • ===演算子で関数名を1つ1つチェックしていくのはとても大変

関数が3,4つ程度であれば我慢して書けますがこれが50個近い関数に適応…となるととてもしんどいですし、関数名を変えたり新規で関数を追加するときにとても骨が折れます。
(そもそも3,4つ程度ならこのテクニックは不要ですね…)
また、v1/firestore/userのようにネストを深くしてモジュール分割をしたい場合に、exportsや関数名のチェックもより複雑化してしまいます。

そこで、いくつか便利な関数を定義し、関数をドメインごとに分離しつつも、それぞれの関数が最小限のimportをするだけで済むようにCold Start対策をする方法を紹介していきます。

理想

理想としては、最初に説明したこの形にほぼ近い形でモジュール分割しつつCold Start対策を導入したいところです:

// index.ts
import * as User from './user'
import * as Post from './post'

export const user = { ...User }
export const post = { ...Post }

これくらいの記述で済むのであれば、負担にはなりませんよね。
ですので、これにできる限り近い形で書けるように、いくつか便利関数を定義していきます。

便利関数の定義

その前に、まずはエントリーポイントとなるindex.tsと同じ階層に、root.tsを作成し、以下のコードを追加しておきます:

import * as path from 'path'
const rootPath = path.resolve(__dirname)
export default rootPath

これによって、エントリーポイントとなるindex.tsのカレントディレクトリのpathが取得できるようになります。これは次に紹介する関数の中で活用します。

// utils/deploy.ts

import rootPath from '../root'

const isDeployingOrExecuting = (paths: string[]) => {
  const functionName = process.env.FUNCTION_NAME ?? process.env.K_SERVICE ?? ''
  return (
    !functionName ||
    new RegExp(`^${paths.join('-')}.*$`).test(functionName)
  )
}

const exportFunctionsModule = (paths: string[], _exports: any) => {
  if (isDeployingOrExecuting(paths)) {
    const moduleName = paths.slice(-1)[0]
    const modulePath = [rootPath, paths.join('/')].join('/')
    console.log(`export module [${moduleName}]: ${modulePath}`)
    _exports[moduleName] = {
      ...require(modulePath)
    }
  }
}

const exportFunction = (paths: string[], _exports: any, f: () => any) => {
  if (isDeployingOrExecuting(paths)) {
    const functionName = paths.slice(-1)[0]
    console.log(`export function: ${paths.join('-')}`)
    _exports[functionName] = f()
  }
}

export { exportFunctionsModule, exportFunction }

(途中に記述されているconsole.logは削除しても構いません。)
それぞれの関数について見ていきましょう。

isDeployingOrExecuting

isDeployingOrExecuting関数では、先にお見せした関数名の定義の有無と関数名の一致によってbool値を返す関数になっています。
前回との違いは、引数に「関数のエンドポイントを-で分割した文字列の配列」を受け取るようにし、
functionName前方一致するかどうかをチェックしている点になります。
===での比較ではなく正規表現による前方一致にすることで、モジュールの起点となるそれぞれのindex.tsにてモジュールをexportするかどうかの判定を行うことができるようになります。

isDeployingOrExecuting(['v1', 'firestore', 'user'])

と書けば、 関数名がv1-firestore-user-*のときにtrueを返します。
isDeployingOrExecuting(['v1', 'firestore', 'user', 'onCreate])と関数名を最後までしっかり書けば従来どおり関数名の一致を調べることもできます。

exportFunctionsModule

exportFunctionsModule関数では、関数のエンドポイントを-で分割した文字列の配列と、そのファイルのexports変数を渡し、
isDeployingOrExecutingがtrueのときに、配列の最後の要素の文字列をモジュール名として、モジュールをexportします。
このときのモジュール名は、ディレクトリ名と一致するようにします。例えば、

- index.ts
- v1
  |- index.ts
  |- firestore
      |- index.ts
      |- user.ts

とディレクトリを分けているときに、v1/index.tsの中ではこの関数を次のように呼び出します。

// firestore/index.tsの内容をexportする
exportFunctionsModule(['v1', 'firestore', exports)

これによって、関数名がv1-firestore-*の場合に、user.tsをexportするようになります。

引数としてexportsを受け取り、そのオブジェクトに対してモジュールをロードして代入しているのには理由があります。
もし引数として受け取らず、このファイル内のexportsを使用してしまうと、この便利関数を定義したファイルのexportsに代入されてしまうためうまくいきません。
また、exportsは参照渡しされるため、この関数の呼び出し元のexportsに正しくモジュールがexportされるようになります。

exportFunction

exportFunction 関数では、「関数のエンドポイントを-で分割した文字列の配列」、「exports」、「関数の実体を返す関数」の3つを引数に取り、
isDeployingOrExecutingがtrueの場合に関数の実体をexportします。

exportFunction(['v1', 'firestore', 'user', 'onCreate'], exports, () => functions().firestore
  .document(path)
  .onUpdate(async (snapshot, context) => {
    console.log(snapshot, context);
  }))

と書けば、デプロイ時もしくは実行する関数がv1-firestore-user-onCreateの場合に、関数の実体がexportされます。

それぞれのドメインのindex.tsではexportFunctionsModule関数を使い、実際の関数定義をしているファイルではexportFunctionを使うことでモジュール分割しつつ、実行される関数のみをロードするような書き方が可能になります。
また、一部型を当てはめることが難しい箇所でanyを使用しているため、必要に応じてその行やこのファイルのみanyに関するlintのwarningやerrorを無効にしておくことをおすすめします。


これらの関数を駆使することで、if文を関数の数だけ書いたり、モジュールのロードで記述するrequire()のpathをすべてハードコーディングするのではなく、
ある程度自動的に定まるように記述することができ、将来的に関数や関数をまとめたモジュールを追加することが容易になります。

実際に適応してみる

実際に、次のようにv1,firestoreとディレクトリを作成し、その配下にuser.tsを起き、2つの関数をデプロイする際の例をお見せします。

- index.ts
- v1
  |- index.ts
  |- firestore
      |- index.ts
      |- user.ts
      |- ...

エントリーポイントとなるindex.tsの他に、各ディレクトリにもindex.tsを配置していることに注意してください。
各ディレクトリにindex.tsを配置することで、ディレクトリ名でindex.tsの内容をロードすることができるようになります。

まずはv1/firestore/user.tsを実装してみます。

// v1/firestore/user.ts
import { exportFunction } from 'path/to/utils/deploy'
import functions from 'path/to/utils/base_functions'

const _exportFunction = (name: string, f: () => any) =>
  exportFunction(['v1', 'firestore', 'user', name], exports, f)

const path = '/v/{version}/users/{userID}'

_exportFunction('onCreate', () => functions().firestore
  .document(path)
  .onCreate(async (snapshot, context) => {
    console.log(snapshot, context);
  }))

_exportFunction('onUpdate', () => functions().firestore
  .document(path)
  .onUpdate(async (snapshot, context) => {
    console.log(snapshot, context);
  }))

記述の重複を減らすために、このファイル内でexportFunction関数を薄くラップしています。
また、functions()はCold Start対策のメモリの割当のところでお見せした、メモリやregion情報を事前にデフォルト定義してラップした、FunctionBuilderを返す関数です。

これで、デプロイ時もしくは実行する関数名がv1-firestore-user-onCreateもしくはv1-firestore-user-onUpdateの関数の場合にのみ、関数の実体がexportされます。
次に、userモジュールを、v1-firestore以下にexportするために記述が必要なv1/firestore/index.tsにコードを追加していきます。

// v1/firestore/index.ts
import { exportFunctionsModule } from 'path/to/utils/deploy'

const domains = [
  'user',
]

domains.forEach(d =>
  exportFunctionsModule(['v1', 'firestore', d], exports)
)

たったこれだけの記述で実現できます。 今後user以外のモジュールが増えた場合は、モジュール名(XXX.ts)をdomainsに追加するだけで済みます。

const domains = [
  'user',
  'post',
  'comment'
]

// 以降のコードは変更なし

次はv1/index.tsを実装しましょう。

// v1/index.ts
import { exportFunctionsModule } from 'path/to/utils/deploy'

const domains = [
  'firestore',
]

domains.forEach(d =>
  exportFunctionsModule(['v1', d], exports)
)

こちらも先程と同じような記述で済みます。
最後にエントリーポイントとなるindex.tsを実装します。

// index.ts
import { exportFunctionsModule } from 'path/to/utils/deploy'
import * as admin from 'firebase-admin'

admin.initializeApp()

const domains = [
  'v1'
]

domains.forEach(d => 
  exportFunctionsModule([d], exports)
)

ここまで実装ができたら、コードをビルドした後、デプロイしてみましょう。
問題がなければv1-firestore-user-*の名前でそれぞれの関数がデプロイされます。モジュール分割した状態を維持しつつ、Cold Start対策も同時に行うことができました。

Cloud Functionsのログで、デプロイ時に関数毎にどのモジュールや関数をチェックしてデプロイしようとしているかのログを次のように確認することができます。

割と少ないコード量で実現できたものの、どうしてもモジュール名がハードコーディングになってしまうので、気になる方は、function_paths.tsといったファイルを適当に作り、定数定義して使用するようにすれば、ディレクトリ名を変更した時の修正漏れをある程度防げるかと思います。

// function_paths.ts
const v1 = 'v1'
const firestore = 'firestore'
...
import { exportFunctionsModule } from 'path/to/utils/deploy'
import * as P from 'path/to/function_paths'
const domains = [
  P.user,
]

domains.forEach(d =>
  exportFunctionsModule([P.v1, P.firestore, d], exports)
)

サンプル

GitHubにサンプルを掲載しています。
ブログでは編集の都合上userドメインに関する例しかお見せしていないですが、下記サンプルではより実際のサービスに近い構成を再現するためにより多くの関数をモジュール分割しています。

まとめ

早い段階から、関数をドメイン毎に分離して整理しておくことで、index.tsの中身が肥大化するのを防ぐことができます。
また、サービスの成長に伴い関数が増え、関数実行時にimportされるモジュールが増え、Cold Startの時間が増加してしまう場合にはCold Start対策を行うことも、重要になってきます。

今回はその2つのテクニックを共存させつつ、できる限りシンプルに定義できる方法を紹介しました。

関連記事

TechFirebaseTipsCloud Functions

開発/本番環境でCloud Functionsのregionを切り替える