FirebaseのWebプロジェクトでデプロイを少しづつ良くしていこうの会

この記事は[Goodpatch Advent Calendar 2020 - Qiita](https://qiita.com/advent-calendar/2020/goodpatch)の17日目の記事でやんす:wink:
わたくし @yahharo は主にSaaSサービスのバックエンドやAWSのインフラを整備したり、GCP/FirebaseでのFunctionsやSecurityRulesなどバックエンド寄りの開発をしています :muscle_tone2: 

## これは何？
ここ1年以上FirebaseでのWebプロジェクトを開発・運用を経験する中で、様々な改善を行ってきました。
その中でも「デプロイ」に限ってやってきたことなどを段階的に紹介できたらと思います。
だいぶニッチな内容になりますが、Firebaseを使った運用側の知見はまだまだ少ないように感じるので、どなたかの参考になれば幸いです:hugging: 

### 今回の構成例
簡略化するために、例えば以下のような場合の構成を例として考えてみます。

* Firebase Hosting（SPAとして）
* Cloud Firestore （DBとして）
* Cloud Functions （APIとして）

![go-go-yahharo-structure.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/25926/5c3ec9f3-e1b6-28d3-9b0c-7107c9359cc9.png)

ディレクトリ構成はこんな感じでpackageはそれぞれで管理しているようなWebアプリケーションです。

```
├── firebase.json
├── .firebaserc
├── firestore 
│   ├── package.json
│   ├── firestore.indexes.json
│   └── firestore.rules
├── functions
│   ├── package.json
│   ├── lib
│       └── index.js
└── hosting
    ├── package.json
    └── dist
        └── index.html
```
* ここではFirestoreのデプロイとは、Firestore Security Rules, Indexのデプロイを指します。）
* 途中このフォルダにないCloudBuildやWebpackの話が出てきますが、一般的な内容とほとんど同じなので細かい説明は省きます。
* 仮にこのプロジェクトを「Go！Go！やっはー！」と名付け、`go-go-yahharo-dev` としてFirebaseプロジェクトを作成しました。

```shell
$ firebase projects:create go-go-yahharo-dev
```

さぁデプロイしてみましょう！

## 全部デプロイ！（普通のデプロイ）
公式ドキュメントにもあるとおり、普通にデプロイするならこのコマンドですよね。

![deploy-1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/25926/7556b3c6-3955-e3df-6844-4bd075598b57.png)


```shell
$ firebase deploy
```
これでデプロイは確かにできますね。
しかし、これではHostingもFunctionsもFirestore Security Rulesも全てデプロイされてしまいます。

## リソース別にデプロイするぞ
開発していて、全部出したい！というケースはあまりないと思うので、リソース別にデプロイできるようにしたいと思います。
![deploy-2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/25926/31e0e4ec-adcf-5798-7713-45e9e582e8ad.png)

`firebase deploy` コマンドは`--only ***` をつけることで対象を絞ることができるので、それぞれの `package.json` でコマンド `npm run deploy`で動くように定義したいと思います。

```functions/package.json
{
  "scripts": {
    "deploy": "firebase deploy --only functions"
  }
}
```
```firestore/package.json
{
  "scripts": {
    "deploy": "firebase deploy --only firestore"
  }
}
```
```hosting/package.json
{
  "scripts": {
    "deploy": "firebase deploy --only hosting"
  }
}
```

これで、例えばフロントエンドのちょっとした修正だけデプロイしたい場合でも

```shell
$ cd hosting && npm run deploy
```
とすればHostingに対してだけデプロイされ、Functions, Firestore Security Rulesはデプロイされないようになりました :tada: 

## 開発環境と本番環境のデプロイを分けたいぞ

![deploy-3.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/25926/05233737-186c-d353-494e-39933e6e4cfe.png)

通常、  `firebase deploy` コマンドで指定されるFirebaseプロジェクトは `.firebaserc` での `default`のあるプロジェクトになります。

```".firebaserc"
{
  "projects": {
    "default": "go-go-yahharo-dev"
  }
}
```
 `firebase deploy --project=PROJECT_ID_or_ALIAS` とすれば、デプロイするプロジェクトを分けることができます。

### エイリアスの作成
`go-go-yahharo-production` のプロジェクトを作成し初期設定した後、`.firebaserc` に参照しやすくするためにエイリアスとして足しておきましょう

```".firebaserc"
{
  "projects": {
    "default": "go-go-yahharo-dev",
    "production": "go-go-yahharo-production"
  }
}
```
* エイリアスについては [Firebase CLI リファレンス](https://firebase.google.com/docs/cli?hl=ja#project_aliases) を参照ください

### デプロイコマンド、環境変数の追加
そして本番環境用のコマンドをそれぞれ用意しておきます。（例としてHostingだけ）
その際に、エラー通知など、開発と本番で挙動を変えたいこともあると思うので、アプリケーション側でも環境を判定できるように`YAHHARO_ENV=production` のようにアプリの環境名を指定しておきましょう。

```hosting/package.json
{
  "scripts": {
    "deploy": "YAHHARO_ENV=dev firebase deploy --only hosting",
    "deploy:production": "YAHHARO_ENV=production firebase deploy --only hosting --project=production"
  }
}
```

これで、本番環境専用のデプロイコマンドができました！ :tada: 

```shell
$ cd firestore && npm run deploy:production
```
と思ったら・・・:sob:

## ￼フロントエンドでのFirebaseの初期化時の環境切り替え
[複数のプロジェクトを構成する  |  Firebase](https://firebase.google.com/docs/projects/multiprojects?hl=ja#web) にも記載があるように、`firebase.initializeApp(options)` で設定する `projectId` などの値を切り替えなくてはなりません。
単純にやれば、

```hosting/src/initialize.js
const firebaseDevConfig = {
  projectId: 'go-go-yahharo-dev',
  apiKey: '************',
  authDomain: 'go-go-yahharo-dev.web.app',
  // appIdなどもあるけど省略
};
const firebaseProductionConfig = {
  projectId: 'go-go-yahharo-production',
  apiKey: '************',
  authDomain: 'go-go-yahharo.app',
  // appIdなどもあるけど省略
};
const YAHHARO_ENV = process.env.YAHHARO_ENV;

// build時の環境変数を元に適用するオプションを切り替え
const firebaseOption =
  YAHHARO_ENV === 'production' ? firebaseProductionConfig : firebaseDevConfig;

firebase.initializeApp(firebaseOption);
```
のように渡すようにした環境変数で判定して切り替えれば良いのかもしれません。

ただ、buildされて公開された時、このファイルがユーザーから見えてしまい開発環境のURLなどがわかってしまうのはあまりよろしくありません :scream: 
（漏洩したところで色々な設定で制御すれば大きな問題にはならないと思いますが、ここでの説明は省きます。）

### んじゃbuild前に設定ファイルを書き換えればいいじゃん :bulb: 
なんとかしてbuildされるパッケージに該当の環境のファイルだけが含まれるようにすれば良いので、Hosting周りで環境を判定する時に必ず設定が記載されたjsファイルをコピーするようにしました :muscle_tone2: 
（iniファイルとかにしても良かったのですが、`firebase apps:sdkconfig ` で出力されたファイルをそのまま使えるようにしたかったのです）

要は

1. 環境変数を判定してbuild前に設定ファイルを特定の場所にコピー
2. アプリケーションではその場所からファイルをimportしておく
（コピー先は `.gitignore` に追加しておく）

とすれば、他の環境の設定が入ることはありません

例えば以下のように環境別にファイルを用意し、それぞれの設定を書いておきます。

```
├── environments
│   ├── dev
│   │   └── firebaseConfig.js
│   └── production
│       └── firebaseConfig.js
└── src
    ├── configs
    │   └── .gitkeep
    │   └── firebaseConfig.js (git ignored)
    └── initialize.js
```

で、以下のコピー関数と

```hosting/environments/copyConfig.js
const fs = require('fs-extra');
const path = require('path');
const TARGET_FILE_PATH = 'hosting/src/configs/firebaseConfig.js';

module.exports = {
  copyConfig: (env, orverwrite) => {
    if (!env | (env === '')) {
      env = 'dev';
    }
    env = path.normalize(env.replace(/[\.|\/]/gi, ''));
    const sourceFilePath = `environments/${env}/firebaseConfig.js`;
    console.log(
      `Copy config files for [${env}] overwrite[${orverwrite.toString()}] from[${sourceFilePath}] to[${TARGET_FILE_PATH}]`
    );
    fs.copy(sourceFilePath, TARGET_FILE_PATH, {
      overwrite: orverwrite,
      errorOnExist: false,
    });
  },
};
```

`node`　コマンドなどで実行可能なCLIを作っておきます。

```hosting/environments/copyConfigCli.js
const { copyConfig } = require('./copyConfig');
copyConfig(process.env.YAHHARO_ENV, false);
```

#### 実行例：ローカルの実行やデプロイの時：Webpackでbuildする前に

```hosting/webpack.config.js
// 途中色々省略
const EventHooksWebpackPlugin = require('event-hooks-webpack-plugin');
const CopyWebpackPlugin = require('copy-webpack-plugin');
const { copyConfig } = require('./environments/copyConfig');

const YAHHARO_ENV = !process.env.YAHHARO_ENV
  ? 'dev'
  : process.env.YAHHARO_ENV;

const config = {
  plugins: [
    new EventHooksWebpackPlugin({
      environment: () => {
        copyConfig(YAHHARO_ENV, true);
      },
    }),
  ],
};
```

#### 実行例：Cloud Buildの前に実行できるように

```cloudbuild.yaml
steps:
  ## for frontend
  - name: 'node:12.20.0'
    id: 'hosting-install'
    args: ['npm', 'install']
    dir: hosting
    waitFor: ['-']
  - name: 'node:12.20.0'
    id: 'hosting-copy_config'
    waitFor: ['hosting-install']
    dir: hosting
    args: ['node', './environments/copyConfigCli.js']
  - name: 'node:12.20.0'
    id: 'hosting-lint'
    waitFor: ['hosting-copy_config']
    dir: hosting
    args: ['npm', 'run', 'lint']
```

こんな感じでやったら環境別にデプロイできました！:tada::tada:

## さらに開発者が増えたのでそれぞれ個人の環境を作成するよ！ :muscle_tone2::muscle_tone2::muscle_tone2:
![deploy-4.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/25926/beb11c65-e7eb-8765-444f-cb1c77c84e19.png)

チームで開発していくと、どうしてもFirestoreのRuleやIndexなども１つの開発環境では並行して進めるのが難しい場合があります。
そこで、環境を以下のように定義して、開発者個人環境を整備することにしました。

* 本番環境： `production`
* 共通開発環境：`dev`
* 開発者環境：`each-developer`

例えば `masa` さんが個人環境を作成して `go-go-yahharo-dev-masa`
 というプロジェクトを作成したとしましょう。

### 開発者環境と個々のproject_idの紐付け
それぞれのShellの `~/.zshrc` などに `YAHHARO_DEVELOPER_ENV`の環境設定をしておきます。

```zsh
$ echo 'export YAHHARO_DEVELOPER_ENV="go-go-yahharo-dev-masa"' >> ~/.zshrc
```

### 個々の環境
環境ファイルは開発者によって異なるので ignoreしておきます。

```
environments
├── copyConfig.js
├── copyConfigCli.js
├── dev
│   └── firebaseConfig.js
├── each-developer
│   └── .gitkeep
│   └── firebaseConfig.js (git ignored)
└── production
    └── firebaseConfig.js
```

### ローカルでの開発
環境を分けたのでserveも個人環境でできたりします。

```hosting/package.json
{
  "scripts": {
    "serve:developer": "YAHHARO_ENV=each-developer npm run build && npm run firebase serve --only hosting --project=$YAHHARO_DEVELOPER_ENV"
  }
}
```

### デプロイコマンド
ここで、 `each-developer` の `--project` は先ほどの環境変数を設定します

```firestore/package.json
{
  "scripts": {
    "deploy": "YAHHARO_ENV=dev firebase deploy --only firestore",
    "deploy:production": "YAHHARO_ENV=production firebase deploy --only firestore --project=production",
    "deploy:each-developer": "YAHHARO_ENV=each-developer firebase deploy --only firestore --project=$YAHHARO_DEVELOPER_ENV"
  }
}
```

これで

```shell
$ cd firestore && npm run deploy:each-developer
```
で自分の環境にデプロイできるようになりました！ :tada::tada::tada:


## 本番環境と開発環境のデプロイコマンド同じで大丈夫？ :thinking: 
そんな中、問題が発生しました :scream_cat::scream_cat::scream_cat:
開発中に個人環境にSecurity Rulesなどを適用していたエンジニアが、誤って開発中のRuleを本番にデプロイしてしまいました・・・

幸いすぐにエラーが頻発したため気がつくことができ、戻しましたが、そもそも、いくつかの問題がありました。

* `deploy:production` `deploy:each-developer` はコマンドのヒストリーなどから実行した場合に間違えやすい
* 本番へのデプロイはローカルからの `firebase deploy`ではなくCloudBuildなどの同一環境から行うべき
* 本番と開発環境のデプロイ権限を分けるべき

などなど

すぐにできる対応として `deploy:production` `deploy:each-developer` のコマンドを実行時に区別できるようにしました。

![deploy-5.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/25926/4550ebef-2a7e-fe53-c1da-81a033419bec.png)

### 本番環境へのデプロイについてのみ、確認を促す
こんな感じの確認が出るようにしました。

```shell
$ npm run deploy:production
$ /bin/bash deploy.sh --project=production
CONFIRM: deploy to production: (y/N)
```
ポイントは **開発環境へのデプロイでは確認を出さない** という点です。

#### 確認用の関数を作る

```deployFunctions.sh
########################
# deploy functions
########################

## Check argument
function checkProject() {
    for ARGUMENT in "$@"; do
        KEY=$(echo $ARGUMENT | cut -f1 -d=)
        VALUE=$(echo $ARGUMENT | cut -f2 -d=)
        case "$KEY" in
        --project) project=${VALUE} ;;
        *) ;;
        esac
    done

    if [ ! $project ]; then
        echo "Need set project '--project=YOUR_PROJECT_ID'"
        exit 1
    fi
}

## confirmation
function confirmationDeploy() {
    DO_IT="N"
    NEED_CONFIRM_ENV=('production')

    ### Ask at prompt
    if [[ ${NEED_CONFIRM_ENV[@]} =~ ${project} ]]; then
        read -p $'\e[31mCONFIRM\e[0m'': deploy to '$'\e[33m'$project$'\e[0m: (y/N)' DO_IT
    else
        DO_IT="y"
    fi
}

## Execute only when answer is 'y'
function executeDeploy() {
    if [ "$DO_IT" = "y" ]; then
        echo -e "\033[1m\033[32mGo deploy to '$project'\033[0m!"
        deploy
        exit 0
    else
        echo -e $'\e[36mDeploy canceled.\e[0m'
    fi
}
```

#### それぞれのリソースにてデプロイシェルを作成
```firestore/deploy.sh
#!/bin/bash

## Deploy command
deploy(){
  npm run firebase deploy --only firestore --project=$project
}

source ../deployFunctions.sh

checkProject $@
confirmationDeploy
executeDeploy
```

この設定を追加して以降はデプロイのミスはなくなりました:tada::tada::tada::tada:
これでもう怖くない:muscle_tone2::muscle_tone2::muscle_tone2::muscle_tone2:  

---

## まだまだあります :hugging: 
と、ここまでデプロイの話を書きましたが、まだまだ足りないことがたくさんあります。
以下はここに書ききれないことを、記載しておきます。

### ビルドをローカルから行わないでCloudBuildに任せる
* 環境の差異や、たまたまローカルのコードに混入してた場合のリスクを減らせる
* デプロイの権限を集約できる

### Hosting, Functionsのリリースが遅い
* ビルド済みのリソースを以下のAPIなどを使えば迅速に適用できる
	* [Hosting REST API を使用してサイトにデプロイする  |  Firebase](https://firebase.google.com/docs/hosting/api-deploy?hl=ja)
	* [Google Cloud Functions API からのデプロイ  |  Google Cloud Functions に関するドキュメント](https://cloud.google.com/functions/docs/deploying/api)

### リリース時に内部のメンバーに自動的に告知したい
* 万が一のデプロイミスや、周知しきれていないリリースなどがあった場合でも、確実に内部のメンバーに通知し、リスクを減らしたい
	* GCPの監査ログを設定すれば変更された場合にロギングされるので、それをPub/SubのSinkに噛ませてSlack通知を設定するなど
	* [Cloud Audit Logs  |  Cloud Logging  |  Google Cloud](https://cloud.google.com/logging/docs/audit/?hl=ja)
	* [ログのエクスポートの概要  |  Cloud Logging  |  Google Cloud](https://cloud.google.com/logging/docs/export)

などなどなど！

---

## まとめ :ok_hand_tone2:
* Firebaseのデプロイはリソース別、環境別に設定できる
* 運用に合わせて少しづつ変えていこう
* 俺たちの理想のデプロイはまだ始まったばかりだ・・・

ということで、Firebase超絶良いので使っていきまっしょい！

ちなみに今回作成した画像はすべて [Strap](https://product.strap.app/) を用いて作成しました。
全部で15分くらいで作れたので情報の整理やちょっとした作図にもおすすめです :thumbsup_tone2: 
トライアル是非試してみてください！（大事！）
わっしょい！

明日の [Goodpatch Advent Calendar 2020 - Qiita](https://qiita.com/advent-calendar/2020/goodpatch) は @enpipi がいいこと書いてくれるそうです :wink: