Edited at

[Firebase] Cloud Functionsで消耗したくない人のために、開発環境のベストプラクティスをまとめていったらDockerに行き着いた話(随時更新)

この記事はFirebase #2 Advent Calendar 2018の10日目の記事です。

いきなり脱線して恐縮ですが、本日12月10日はFirebase Japan User Group(FJUG)が運営する、Firebase Meetupが開催されました!!第8回目の今回は記念すべきコミュニティ成立1周年ということで、豪華なドラフトビールや特大ケーキも用意され、最高に盛り上がりました!!ヽ(^。^)ノ

自分もスタッフとしてFirebaseの盛り上がりを肌で感じさせて頂きました!

Firebase Meetup #8 @FiNC Technologies - connpass

firebase-meetup-2.jpg


はじめに

取り乱してスイマセン、ここからマジメな話です。

Firebaseでアプリ開発を行う際に、ほとんどのアプリで必要になるであろうサービスの1つが、このCloud Functionsでしょう。

ちょっとしたREST APIを用意したり、ユーザーへPush通知を送ったり、負荷の高い画像や音声ファイルを処理したり・・と、幅広いユースケースで便利に使うことができます!


Cloud Functions で可能な処理  |  Firebase


ただ、実際に使っている中で『あれっ・・・なんか上手く動かないぞ・・』とハマってしまうことが何度かあったので、このエントリーでは快適にCloud Functionsを使うための環境構築や設定面でのベストプラクティスをまとめたいと思います!!(と言いつつ、もっと"ベストっぽい"のあるだろ・・・と思う箇所もあるので、誹謗中傷以外のコメントは大歓迎です!)

ちなみに、内容の半分くらいは公式ドキュメントを読めば分かることなのですが、Cloud Functionsをプロジェクトに導入し始めのときは、『ちょっとした処理を関数化するだけで良いのでスタートガイドだけで充分だった』ということも多いのではないでしょうか?自分の場合は特に↓↓このへんのページを『もっと早い段階で目を通しておけば良かった・・・』と思ったので、そういった意味でもこれからFirebaseを始める人たちの助けになれば良いなと思っています。

Cloud Functions-1.png


Firebaseプロジェクトを作成

Firebaseコンソールからプロジェクトの作成を行います。(今回はデモ用にfirebase-demoというプロジェクトを作成)


Dockerコンテナ内でNode.jsとFirebase CLIのインストール


なぜDocker?

いきなりタイトルを回収してしまいますが、ここで説明しない訳にはいかないので、Dockerを使う理由を自分なりの経験を元に説明してみます。Docker自体の有用性はここでは触れませんが、Cloud Functions(というかFirebase CLI)と併用する大きな理由は個人的には下記の2つだと思います。


【1】ホストマシンの環境を汚さず、依存性をコンテナに閉じ込めることができる

Cloud Functionsは現在、Node.jsのv6とv8をサポートしていますが、それに合わせようとするとNode.jsのバージョン管理を行うツールが必要です。公式のドキュメントではnvmが推奨されていますが、これを使うのであれば、環境をまったく汚さないDockerの方が良いと思います。また、後述しますが、ローカル環境でのテストのためにGOOGLE_APPLICATION_CREDENTIALSという環境変数にプロジェクトによって異なる値をセットする必要があります。これもやろうと思ったら、nvmだと別のツールが必要です。Dockerであればその限りではありません。


【2】GoogleアカウントごとのFirebaseログインが不要になる

これはプライベート用と仕事用で複数のGoogleアカウントを使い分けている方にしか恩恵はないのですが、プロジェクトを切り替えるたびにfirebase loginで再認証を行う必要がなくなります。この認証情報を保存したファイルは、ユーザーのホームディレクトリ直下の隠しディレクトリに作成されるのですが、再認証を行うたびに上書きされてしまいます。これをDockerコンテナの中に閉じ込めることで、プロジェクトごとにFirebase CLIを実行するアカウント情報を固定化することが狙いです。

楽になる分、Docker自体の学習コストが必要というツッコミが入るかもしれませんが、Circle CIなどで自動テスト・デプロイを行おうと思うと必然的に必要になってくるので、どこかで支払うコストだと割り切っても良いのではないでしょうか。


Dockerのセットアップ

それではいよいよDockerのセットアップを行っていきましょう。まずはDockerfileをディレクトリを作成し、そこに配置します。

$ mkdir -p docker/node

$ touch docker/node/Dockerfile


Dockerfile

FROM node:8-alpine

WORKDIR /app

# install Firebase CLI Tools
RUN npm install -g firebase-tools

# settings for runtime emulator
ENV HOST 0.0.0.0
EXPOSE 5000

# settings for Firebase login
EXPOSE 9005


ポイントは下記の通りです。



  • node:8-alpine


    • OSはDockerに適した軽量イメージのalpine Linux

    • Cloud FunctionsはデフォルトではNode v6が実行環境となりますが、設定ファイルで指定することでv8も選択できます(設定方法は後述)




  • RUN npm install -g firebase-tools


    • Firebase CLIはローカル環境ではなく、コンテナ内でインストール




  • ENV HOST 0.0.0.0, EXPOSE 5000


    • ローカル環境(コンテナ内)での関数のテスト実行に使われる、エミュレーターがポート5000を使うため




  • EXPORT 9005


    • Firebase CLIを使うために、はじめ



それでは、Dockerコンテナを起動して、Firebase CLIのバージョンを確認してみましょう。

$ docker run -it -p 9005:9005 pannpers/firebase-demo /bin/sh

$ firebase -V
6.0.1

以上でNode.jsとFirebase CLIのインストールは完了です。

続いて、Firebase CLIを有効にするためにfirebase loginコマンドを実行します。

$ firebase login

? Allow Firebase to collect anonymous CLI usage and error reporting information? Yes

Visit this URL on any device to log in:
https://accounts.google.com/o/oauth2/auth?client_id=563584335869-fgrhgmd47bqnekij5i8b5pr03ho849e6.apps.googleusercontent.com&scope=email%20openid%20https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fcloudplatformprojects.readonly%20https%3A%2F%2Fwww.googleapis.com%2Fauth%2Ffirebase%20https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fcloud-platform&response_type=code&state=679873081&redirect_uri=http%3A%2F%2Flocalhost%3A9005

Waiting for authentication...

Docker環境だと、自動でブラウザのタブが開かないため、上記のURLをコピーしてホストマシンのブラウザでURLを開きます。Googleアカウントでログインを終えると、コンテナ内で下記のメッセージが表示されるはずです。

✔  Success! Logged in as pepperoni9@gmail.com

下記のコマンドを実行して、Firebaseプロジェクトが表示されれば成功です。また、firebase loginコマンドでの認証情報は~/.config/configstore/firebase-tools.jsonに保存されます。

$ firebase list

┌───────────────────────┬───────────────────────┬─────────────┐
│ Name │ Project ID / Instance │ Permissions │
├───────────────────────┼───────────────────────┼─────────────┤
│ firebase-demo │ fir-demo-5db2a │ Owner │
└───────────────────────┴───────────────────────┴─────────────┘

$ ls -l ~/.config/configstore/firebase-tools.json
-rw------- 1 node node 2240 Dec 10 03:30 /home/node/.config/configstore/firebase-tools.json

ただし、上記の認証情報はコンテナが再作成されると削除されてしまうため、docker commitコマンドで変更情報を記録します。いったんexitコマンドでコンテナを抜けて、ホストマシンの方で下記コマンドを実行してください。

$ docker container ps -l

CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
1136f3c57896 pannpers/firebase-demo "/bin/sh" 29 minutes ago Up 15 minutes 5000/tcp, 0.0.0.0:9005->9005/tcp unruffled_newton

$ docker commit --message "Add firebase login" 1136f3c57896 pannpers/firebase-demo
sha256:50eded15c07cbf0274c5b2c44d2835b7851f1d83f2058548de33b3dc1d50f6d0

最後に、docker runコマンド実行により、コンテナが再作成されても認証情報が残っていることを確認しましょう。

$ docker run -it -p 9005:9005 pannpers/firebase-demo /bin/sh

$ firebase list

これでDockerコンテナ内のFirebase CLIを使うための準備が整いました!!

Dockerまわりの設定については下記のエントリーがとても参考になりました。




プロジェクトでCloud Functionsを有効にする

docker run -it -p 9005:9005 -v $PWD:/app pannpers/firebase-demo /bin/sh

ここで1つ-v $PWD:/appというオプションを追加しています。このオプションにより、コンテナで作成したディレクトリやファイルがプロジェクトのルートディレクトリに反映されるようになります。

$ firebase init functions

######## #### ######## ######## ######## ### ###### ########
## ## ## ## ## ## ## ## ## ## ##
###### ## ######## ###### ######## ######### ###### ######
## ## ## ## ## ## ## ## ## ## ##
## #### ## ## ######## ######## ## ## ###### ########

You're about to initialize a Firebase project in this directory:

/app

Before we get started, keep in mind:

* You are currently outside your home directory

=== Project Setup

First, let's associate this project directory with a Firebase project.
You can create multiple project aliases by running firebase use --add,
but for now we'll just set up a default project.

? Select a default Firebase project for this directory: fir-demo-5db2a (firebase-demo)
i Using project fir-demo-5db2a (firebase-demo)

=== Functions Setup

A functions directory will be created in your project with a Node.js
package pre-configured. Functions can be deployed with firebase deploy.

? What language would you like to use to write Cloud Functions? TypeScript
? Do you want to use TSLint to catch probable bugs and enforce style? Yes
✔ Wrote functions/package.json
✔ Wrote functions/tslint.json
✔ Wrote functions/tsconfig.json
✔ Wrote functions/src/index.ts
✔ Wrote functions/.gitignore

? Do you want to install dependencies with npm now? Yes

### 省略 ###

✔ Firebase initialization complete!


デプロイ

今の段階で関数をデプロイすることはできるのですが、その前にNode.jsランタイムのバージョン指定と関数のリージョンを設定しましょう。


関数のデプロイとランタイム オプションを管理する  |  Firebase



Node.jsランタイムのバージョンをv8に指定

Cloud Functionsで利用できるNode.jsランタイムはv8が最新となっています。現時点でベータ版となっていますが、自分が今まで使っている範囲では特に問題無く使えています。バージョンを指定するには、初期化中にfunctions/ディレクトリに作成されたpackage.jsonファイルにenginesフィールドを追加します。


package.json

  "engines": {"node": "8"}



関数がデプロイされるリージョンを指定

次に、Cloud Functionsをデプロイするリージョンを設定します。

下記の公式ドキュメントに従って、ユーザーに最も近いリージョンを選択します。今回はasia-northeast1(東京)リージョンを設定します。


Cloud Functions はリージョナルです。つまり、Cloud Functions を実行するインフラストラクチャは特定のリージョンに配置され、そのリージョン内のすべてのゾーンで冗長的に利用できるように Google によって管理されます。

Cloud Functions を実行するリージョンを選択するときは、レイテンシと可用性を第一に考慮してください。一般的には、Cloud Function のユーザーに最も近いリージョンを選択しますが、アプリで使用されている他のプロダクトやサービスのロケーションも考慮する必要があります。使用するサービスが複数のリージョンにまたがっていると、アプリのレイテンシだけでなく、料金にも影響します。

Cloud Functions のロケーション  |  Firebase


Cloud Functionsの初化時に自動生成されたfunctions/src/index.tsをエディタで開き、コメントアウトされている関数を下記のように修正し、functions.region(REGION)を追加しましょう。

import * as functions from 'firebase-functions';

const REGION = 'asia-northeast1';

export const helloWorld = functions
.region(REGION)
.https.onRequest((request, response) => {
response.send("Hello from Firebase!");
});

なお、Cloud Functionsのリージョンを指定した場合は、クライアントアプリ側でもリージョンを指定する必要があるので、下記のドキュメントに従って修正してください。


HTTP とクライアント呼び出し可能関数



関数をデプロイ

それではここで一度、関数をデプロイしてみましょう!

Dockerコンテナ内で下記のコマンドを実行してください。

# move Cloud Functions directory

$ cd functions

$ npm run deploy

> functions@ deploy /app/functions
> firebase deploy --only functions

=== Deploying to 'fir-demo-5db2a'...

i deploying functions
Running command: npm --prefix "$RESOURCE_DIR" run lint

> functions@ lint /app/functions
> tslint --project tsconfig.json

Running command: npm --prefix "$RESOURCE_DIR" run build

> functions@ build /app/functions
> tsc

✔ functions: Finished running predeploy script.
i functions: ensuring necessary APIs are enabled...
✔ functions: all necessary APIs are enabled
i functions: preparing functions directory for uploading...
i functions: packaged functions (47.17 KB) for uploading
✔ functions: functions folder uploaded successfully
i functions: creating Node.js 8 function helloWorld(asia-northeast1)...
✔ functions[helloWorld(asia-northeast1)]: Successful create operation.
Function URL (helloWorld): https://asia-northeast1-fir-demo-5db2a.cloudfunctions.net/helloWorld

✔ Deploy complete!

Project Console: https://console.firebase.google.com/project/fir-demo-5db2a/overview

上記のコマンド結果として出力されるFunction URL (helloWorld): https://asia-northeast1-fir-demo-5db2a.cloudfunctions.net/helloWorldにブラウザなどでアクセスすると、下記のように関数がデプロイされていることが確認できます。

image.png

ちなみに、関数のタイムアウトやメモリの割当て設定に関しては、関数のデプロイとランタイム オプションを管理する  |  Firebaseを参照してください。


ローカル環境で関数をテストする

関数のデプロイは非常に時間がかかるため、開発中はローカル環境でエミュレーターを起動し、関数をテストします。詳しくは下記のドキュメントを参照してください。


ローカルでのファンクションの実行  |  Firebase


Dockerコンテナ内でエミュレーターを動作させるには少し細工が必要です。

まず、エミュレーターがポートの5000を使うため、docker runのオプションとして-p 5000:5000を追加します。この設定によりfirebase functions:shellコマンドが使えるようになりました。(ポート番号は必要に応じて変更してください)

次にpackage.jsonを開き、npmスクリプトのserveコマンドの引数に-p 5000 -o 0.0.0.0を追加します。このオプションを追加することで、ホストマシンから、Dockerコンテナ内のエミュレーターでホストしているHTTPS関数を呼び出すことができます。


package.json

{

"scripts": {
"serve": "npm run build && firebase serve --only functions -p 5000 -o 0.0.0.0"
}
}

実際に実行すると下記のようになります。

# launch Docker container

$ docker run -it -p 5000:5000 -v $PWD:/app pannpers/firebase-demo /bin/sh

# move Cloud Functions directory
$ cd functions

# execute Cloud Functions shell
$ npm run shell

firebase > helloWorld()
Sent request to function.
firebase > info: User function triggered, starting execution

RESPONSE RECEIVED FROM FUNCTION: 200, "Hello from Firebase!"
info: Execution took 7 ms, user function completed successfully

# execute local server for HTTPS functions
$ npm run serve

✔ functions: helloWorld: http://0.0.0.0:5000/fir-demo-5db2a/us-central1/helloWorld
info: User function triggered, starting execution
info: Execution took 17 ms, user function completed successfully

最後のserveコマンドでホストされているURL http://0.0.0.0:5000/fir-demo-5db2a/us-central1/helloWorld には、上述の通りホストマシンのブラウザなどから実行できます。

ちなみに、Dockerコンテナではデフォルトではrootユーザーでログインしてしまうため、docker run -u nodeユーザーのようにroot以外のユーザーを指定してコマンドを実行することが推奨されているのですが、そうするとエミュレーターの実行時に下記のエラーが出てしまいました。(firebase deployなどは問題なく実行可能です)

もし解決策が分かる方がいればコメント頂けると嬉しいです・・!!m(_ _)m

Error: An unexpected error has occurred.

npm ERR! code ELIFECYCLE
npm ERR! errno 2
npm ERR! functions@ serve: `npm run build && firebase serve --only functions`
npm ERR! Exit status 2
npm ERR!
npm ERR! Failed at the functions@ serve script.
npm ERR! This is probably not a problem with npm. There is likely additional logging output above.

npm ERR! A complete log of this run can be found in:
npm ERR! /home/node/.npm/_logs/2018-12-10T06_47_02_433Z-debug.log


ユーザーの認証処理が必要な場合

ドキュメントにも下記の記述がありますが、Firebase Authenticationのユーザー認証を関数内で行う場合は、サービスアカウント情報をエミュレーターから読み込めるようにしなければなりません。


管理者の認証情報を設定する(オプション) |  Firebase

Firebase API(Authentication や FCM など)、Google API(Cloud Translation や Cloud Speech など)といった他のすべての API では、このセクションで説明する設定手順を実行する必要があります。これは、Functions シェルと firebase serve のどちらを使用している場合でも当てはまります。


公式の手順に従い、サービスアカウントのJSONファイルをダウンロードしましょう。

image.png

次に、ファイルのパスをDockerコンテナ内の環境変数GOOGLE_APPLICATION_CREDENTIALSにセットします。docker runのオプションとして-eを指定すれば良いのですが、オプションが長くなってきたので、docker-compose.ymlにこれまでのオプションも含めて設定をまとめることにします。(このへんはお好みに合わせて、エイリアスを設定するなり、shellスクリプトを用意して頂いて大丈夫です)


docker-compose.yml

version: '3'

services:
dev-server:
build: ./docker/node
image: pannpers/firebase-demo
container_name: firebase-demo
tty: true
environment:
- GOOGLE_APPLICATION_CREDENTIALS=firebase-adminsdk.json
volumes:
- $PWD:/app
ports:
- "5000:5000"
- "9005:9005"

GCPのコンソールからダウンロードしたサービスアカウントのJSONファイルを上記で環境変数にセットしたディレクトリへ配置します。(このJSONファイルはセキュアに保存しなければならないので、.gitignoreに追加しておきましょう)

$ mkdir functions/credentials

$ mv ~/Downloads/fir-demo-5db2a-8ee278b54b99.json functions/credentials/firebase-adminsdk.json

これでFirebase Admin SDKを使ったadmin.auth().getUserByEmail(email)のようなコマンドをローカル環境でテストできるようになります。


おまけ

最後に、下記の公式ドキュメントはCloud Functions上でのパフォーマンスを向上させるためのベストプラクティスや注意事項が紹介されているため、はじめに読む必要はないですが一度目を通されることをおすすめします。




まとめ

Cloud Functionsはそのサービスの特性もあり、ドキュメントを今、自分が必要なところだけを適切な順番で読むというのが、少し難しい印象がありました。『開発スピードを最大まで高める』という事はFirebaseの大きな採用理由の1つだと思うので、消耗すべきではないところで躓かないために、このエントリーが誰かの助けになれば幸いです。

また、これで完成ではないと思っているので、みなさんからのご意見お待ちしております!!

それを踏まえて、このエントリーの内容をブラッシュアップさせ、本当のベストプラクティスに近づけていけたらなぁと思っています!