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

この記事は[Firebase #2 Advent Calendar 2018](https://qiita.com/advent-calendar/2018/firebase-2)の10日目の記事です。

いきなり脱線して恐縮ですが、本日12月10日は[Firebase Japan User Group（FJUG）](https://firebase-community.connpass.com/)が運営する、Firebase Meetupが開催されました！！第8回目の今回は記念すべきコミュニティ成立1周年ということで、豪華なドラフトビールや特大ケーキも用意され、最高に盛り上がりました！！ヽ(^。^)ノ
自分もスタッフとしてFirebaseの盛り上がりを肌で感じさせて頂きました！
[Firebase Meetup \#8 @FiNC Technologies \- connpass](https://firebase-community.connpass.com/event/109678/)

![firebase-meetup-2.jpg](https://qiita-image-store.s3.amazonaws.com/0/44400/e89c3f5b-6b22-4d9e-2b61-9d86b972b753.jpeg)


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

Firebaseでアプリ開発を行う際に、ほとんどのアプリで必要になるであろうサービスの1つが、このCloud Functionsでしょう。
ちょっとしたREST APIを用意したり、ユーザーへPush通知を送ったり、負荷の高い画像や音声ファイルを処理したり・・と、幅広いユースケースで便利に使うことができます！

> [Cloud Functions で可能な処理  \|  Firebase](https://firebase.google.com/docs/functions/use-cases?hl=ja#execute_intensive_tasks_in_the_cloud_instead_of_in_your_app)

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

ちなみに、内容の半分くらいは公式ドキュメントを読めば分かることなのですが、Cloud Functionsをプロジェクトに導入し始めのときは、『ちょっとした処理を関数化するだけで良いので[スタートガイド](https://firebase.google.com/docs/functions/get-started?hl=ja)だけで充分だった』ということも多いのではないでしょうか？自分の場合は特に↓↓このへんのページを『もっと早い段階で目を通しておけば良かった・・・』と思ったので、そういった意味でもこれからFirebaseを始める人たちの助けになれば良いなと思っています。
<img width="226" alt="Cloud Functions-1.png" src="https://qiita-image-store.s3.amazonaws.com/0/44400/b8eac06d-21f5-9957-8335-f79017e58bf5.png">



# Firebaseプロジェクトを作成
[Firebaseコンソール](https://console.firebase.google.com/u/0/?hl=ja)からプロジェクトの作成を行います。（今回はデモ用に`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](https://github.com/creationix/nvm)が推奨されていますが、これを使うのであれば、環境をまったく汚さないDockerの方が良いと思います。また、後述しますが、ローカル環境でのテストのために`GOOGLE_APPLICATION_CREDENTIALS`という環境変数に**プロジェクトによって異なる値**をセットする必要があります。これもやろうと思ったら、nvmだと別のツールが必要です。Dockerであればその限りではありません。

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


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


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

```sh
$ mkdir -p docker/node

$ touch docker/node/Dockerfile
```

```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`コマンドを実行します。

```sh
$ 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`に保存されます。

```sh
$ 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`コマンドでコンテナを抜けて、ホストマシンの方で下記コマンドを実行してください。

```sh
$ 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`コマンド実行により、コンテナが再作成されても認証情報が残っていることを確認しましょう。

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

$ firebase list
```

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

Dockerまわりの設定については下記のエントリーがとても参考になりました。
> - [Cloud Functions for Firebaseを触ってみる  \+C, \+V  Medium](https://medium.com/nabeen/cloud-functions-for-firebase%E3%82%92%E8%A7%A6%E3%81%A3%E3%81%A6%E3%81%BF%E3%82%8B-e5b1ad9d72a6)
> - [Digital Graveyards, Firebase tools & Docker  Luis G\. Valle  Medium](https://medium.com/@lgvalle/digital-graveyards-firebase-tools-docker-f3dc3b527a53)




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

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

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

```sh
$ 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](https://firebase.google.com/docs/functions/manage-functions?hl=ja)

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

```json:package.json
  "engines": {"node": "8"}
```

## 関数がデプロイされるリージョンを指定
次に、Cloud Functionsをデプロイするリージョンを設定します。
下記の公式ドキュメントに従って、ユーザーに最も近いリージョンを選択します。今回は`asia-northeast1`（東京）リージョンを設定します。

> Cloud Functions はリージョナルです。つまり、Cloud Functions を実行するインフラストラクチャは特定のリージョンに配置され、そのリージョン内のすべてのゾーンで冗長的に利用できるように Google によって管理されます。
Cloud Functions を実行するリージョンを選択するときは、レイテンシと可用性を第一に考慮してください。一般的には、Cloud Function のユーザーに最も近いリージョンを選択しますが、アプリで使用されている他のプロダクトやサービスのロケーションも考慮する必要があります。使用するサービスが複数のリージョンにまたがっていると、アプリのレイテンシだけでなく、料金にも影響します。
> [Cloud Functions のロケーション  \|  Firebase](https://firebase.google.com/docs/functions/locations?hl=ja)

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

```typescript
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 とクライアント呼び出し可能関数](https://firebase.google.com/docs/functions/locations?hl=ja#http_and_client_callable_functions)


## 関数をデプロイ
それではここで一度、関数をデプロイしてみましょう！
Dockerコンテナ内で下記のコマンドを実行してください。

```sh
# 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](https://qiita-image-store.s3.amazonaws.com/0/44400/da45883b-80c6-a412-0dc8-50d08912bab5.png)

ちなみに、関数のタイムアウトやメモリの割当て設定に関しては、[関数のデプロイとランタイム オプションを管理する  \|  Firebase](https://firebase.google.com/docs/functions/manage-functions?hl=ja#set_runtime_options)を参照してください。



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

> [ローカルでのファンクションの実行  \|  Firebase](https://firebase.google.com/docs/functions/local-emulator?hl=ja)

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

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

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

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

```sh
# 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**

```sh
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](https://firebase.google.com/docs/functions/local-emulator?hl=ja#set_up_admin_credentials_optional)
Firebase API（Authentication や FCM など）、Google API（Cloud Translation や Cloud Speech など）といった他のすべての API では、このセクションで説明する設定手順を実行する必要があります。これは、Functions シェルと firebase serve のどちらを使用している場合でも当てはまります。

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

![image.png](https://qiita-image-store.s3.amazonaws.com/0/44400/a121ae65-b28b-950c-2342-5220651228e2.png)

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

```yml: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`に追加しておきましょう）

```sh
$ mkdir functions/credentials

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

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


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

> - [ヒントとアドバイス  \|  Firebase](https://firebase.google.com/docs/functions/tips?hl=ja)
> - [ネットワークの最適化  \|  Firebase](https://firebase.google.com/docs/functions/networking?hl=ja)
> - [同期、非同期、Promise  \|  Firebase](https://firebase.google.com/docs/functions/terminate-functions?hl=ja)


# まとめ
Cloud Functionsはそのサービスの特性もあり、ドキュメントを**今、自分が必要なところだけを適切な順番で読む**というのが、少し難しい印象がありました。『開発スピードを最大まで高める』という事はFirebaseの大きな採用理由の1つだと思うので、消耗すべきではないところで躓かないために、このエントリーが誰かの助けになれば幸いです。
また、これで完成ではないと思っているので、みなさんからのご意見お待ちしております！！
それを踏まえて、このエントリーの内容をブラッシュアップさせ、本当のベストプラクティスに近づけていけたらなぁと思っています！