この記事はQiitaの「Firebase Advent Calendar 2019」の2日目の記事になります。
1日目はSuguruOokiさんの「Firebaseとスタートアップが考える料金のバランスと使い方の話」という記事でした。

今回は実用的なFirebaseのDeploy Scriptを作るというタイトルで、FirebaseのデプロイについてTipsを話しつつ、普段僕が携わっている開発の現場でも実際に使っているFirebaseのデプロイ用のスクリプトの紹介をしてみます。

FirebaseのDeploy

本題の前に、少しFirebaseのDeployについて基本的な事、Tips的なことをお話します。
Firebaseでは、Cloud FunctionsやHosting、FirestoreのrulesやindexesをCLIを通じてデプロイする機能が提供されています。 firebase-toolsを使って、次のように実行することで指定したプロジェクトにデプロイが可能です。

# firebase-toolsのインストール
$ npm install -g firebase-tools
# firebaseにログイン
$ firebase login 

# プロジェクトを選択する
$ firebase use development

# デプロイする
$ firebase deploy

事前にfirebase init を実行し、Firebaseプロジェクトの初期設定を済ませ、firebase use --add でFirebaseコンソールして作成したプロジェクトを紐付けておく必要があります。 また、デプロイ時、Cloud Functionsだけデプロイしたい、Cloud Functionsの特定の関数のみデプロイしたい、Hostingだけデプロイしたいといったことも可能です。

$ firebase deploy --only functions

$ firebase deploy --only functions:foobar

$ firebase deploy --only hosting

--only の指定の仕方については公式のドキュメントが参考になると思います。

また、デプロイするにはfirebase loginを実行し予めプロジェクトの権限を持つアカウントにログインしたうえで実行する必要がありますが、
別途tokenを取得し、それをデプロイのコマンドで指定してあげるとログインせずとも実行することができます。

$ firebase login:ci
# token: <XXXXXXXXXXXXXXXXXXXXXXX>

# --tokenを使ってtokenを指定する場合
$ firebase deploy --token <XXXXXXXXXXXXXXXXXXXXXXX>

# 環境変数に格納して自動的に使われるようにする場合
$ FIREBASE_TOKEN=<XXXXXXXXXXXXXXXXXXXXXXX>
$ firebase deploy

# tokenを失効させる場合
$ firebase logout --token <XXXXXXXXXXXXXXXXXXXXXXX>

これはCI環境やDockerにてデプロイ処理を行う場合や、
複数のGoogleアカウントで開発しているプロジェクトを跨いで開発作業するときに重宝します。
CI等ではダイアログがでても対処できないため、予めtokenを発行し、
それをCIの環境変数として渡してあげれば良いです。(渡す時は値が漏れないよう考慮しましょう)

余談:プロジェクト分けと権限

余談ですが、開発環境と本番環境でFirebaseのプロジェクトは分けておくことを強くお薦めします。
また、「開発者が手元から本番環境に誤ってデプロイしてしまった!」というミスを防ぐためには、

  • 開発者には本番のFirebaseのプロジェクトの権限をViewerのみにする。(※1)
  • 本番環境に対してEditor以上の権限を持ち、デプロイが可能なGoogleアカウントを準備する
  • そのアカウントでデプロイを行うようにする

といった対策を取るとよいです。開発者の手元から本番環境にデプロイができなくなるのは不便ではありますが、事故が起きる可能性をぐっと減らせます。

実際に自分が関わっているプロジェクトだと、上記の方法に加え、

  • 本番環境にデプロイする用のDockerを準備する
  • 本番環境にデプロイ可能なアカウントのtokenを取得し、それを使ってDocker内でデプロイできるようにする

といった形で本番環境へのデプロイを閉じ込め、手元で本番環境にデプロイするアカウントに切り替えるといった操作すら抑制できるように整えています。

※1: GCP側のIAMでより細かく権限管理をし、Firebaseのデプロイを封じるのであれば

  • runtimeconfig.configs.create
  • runtimeconfig.configs.delete
  • runtimeconfig.configs.update
  • runtimeconfig.variables.create
  • runtimeconfig.variables.delete
  • runtimeconfig.variables.update

を権限から外せば良いです。

何故スクリプトを用意するのか

(すごい当たり前の話になりますが…) 何故用意するのかというと、デプロイ用のスクリプトを用意することで、

  • デプロイに関連する一連の操作を自動化できる
  • 人的ミスを減らせる
  • 手元の環境でもCI環境でも同様に実行できる

といった効果が期待できます。
特に1,2つ目が重要で、一連の流れを人が順番にコマンドを打って実行するのは作業負担がかかる上、人的ミスも起こりやすくなります。
例えば、下記のような例です。

# TypeScriptで書いたCloud Functionsのソースコードをビルドする、等
$ npm build 

# デプロイする環境先(project)を選択する
$ firebase use development

# デプロイを実行する
$ firebase deploy

ビルドを行い、デプロイ先を選んで成果物をビルドする、という流れなのですが、

  • なにかの手違いでビルドをし忘れ、以前の成果物を誤ってデプロイしてしまう
  • 何かの表紙で firebase use で本番環境を選択していた後に、 firebase use development を実行して切り替えずに開発環境のコードをデプロイしてしまった
  • firebase deploy のコマンド実行を忘れてしまった

といった事が起こりえます。 複数人体制でダブルチェックしたり、指差し確認で事故の可能性を減らすのも大事ですが限界もありますし、
決まった作業を繰り返し行うのであれば、一連の流れを一纏めにするのが良いでしょう。

スクリプトを作成する

ということで本題に入ります。 以下の要望を満たすようなFirebaseのデプロイスクリプトを作成します。

  • 事前にビルドができる
  • デプロイ先の環境を指定できる(firebase useでの切り替え)
  • --token によりfirebase loginc:ci で得られたトークン指定ができる
  • --only 指定ができる
  • --skip-configを指定することで、firebase functions:config:setを省略できる
  • --dry-runを指定することで、実際のデプロイ処理は行わず、どのように処理が行われるのかリハーサルができる
  • Cloud Functionsのデプロイの場合に関数の削除等の確認メッセージを省略できる--force指定ができる

また、今回はTypeScriptでスクリプトを作成してみます。 普段であればShellScriptで書くことが多いのですが、Firebaseをデプロイする環境下だとnodeが使える環境下であることがほとんどなので使ってみようと思います。

準備

今回TypeScriptでスクリプトを書くにあたり、以下のモジュールを導入する必要があります。

  • typescript
  • ts-node

ts-nodeを使うことで、.tsファイルをトランスパイルして生成したjsファイルを実行する、というった手間を省くことができます。

まずは以下のようにしてts-nodetypescriptをインストールします。 また、firebase-toolsもプロジェクトにインストールすることにします。

$ yarn add -D typescript
$ yarn add -D ts-node
$ yarn add -D firebase-tools

(npmを使っても構いません。)

必要があれば、.gitignorenode_modulesを追加しておきましょう。

スクリプトを書く

deploy.tsを、package.jsonのあるディレクトリ、もしくはそこからscriptsディレクトリを作った上で作成します。 先にソースコードを示すと次のようになります。

import { execSync } from 'child_process'

const environments = ['development', 'production'] as const
type Environment = typeof environments[number]

enum ConfigKey {
  someAPIKey = 'some.api_key',
  someSecret = 'some.secret'
}

interface Config {
  key: ConfigKey
  value: string
}

type Configs = { [key in Environment]: Config[] }
const configs: Configs = {
  development: [
    { key: ConfigKey.someAPIKey, value: 'xxxyyyzzzdev' },
    { key: ConfigKey.someSecret, value: 'aaabbbcccdev' }
  ],
  production: [
    { key: ConfigKey.someAPIKey, value: 'xxxyyyzzz' },
    { key: ConfigKey.someSecret, value: 'aaabbbccc' }
  ]
}

const print = (message: string) => {
  console.log(`⚙️  ${message}`)
}

const executeCommand = (command: string, dryrun: boolean) => {
  if (!dryrun) {
    execSync(command, { maxBuffer: 1024 * 1024, stdio: 'inherit' })
  } else {
    print(`🛠  Run: ${command}`)
  }
}

const joinArguments = (args: (string | undefined)[]) => {
  return args.filter(arg => arg).join(' ')
}

const main = () => {
  if (process.argv.length <= 2) {
    console.log('Usage: yarn deploy [environment] [options...]')
    return 1
  }

  if (process.argv.includes('--help')) {
    console.log('Usage: yarn deploy [environment] [options...]')
    return 0
  }

  const environment = process.argv[2] as Environment
  if (!environments.includes(environment)) {
    console.log(`You must choose environment ${environments.join(' or ')}.`)
    return 1
  }

  let token: string | undefined
  if (process.argv.indexOf('--token') > 0) {
    token = `--token ${process.argv[process.argv.indexOf('--token') + 1]}`
  }

  let only: string | undefined
  if (process.argv.indexOf('--only') > 0) {
    only = `--only ${process.argv[process.argv.indexOf('--only') + 1]}`
  }

  const skipConfig = process.argv.includes('--skip-config')
  const force = process.argv.includes('--force')
  const dryrun = process.argv.includes('--dry-run')

  print('Start deploying features to Firebase')
  dryrun && print('Enabled Dry run mode.')

  print(`Select environment: ${environment}`)
  executeCommand(
    `yarn firebase use ${joinArguments([environment, token])}`,
    dryrun
  )

  print('Build `src/`')
  executeCommand(`yarn build`, dryrun)

  if (!skipConfig) {
    print('Set firebase config values')
    configs[environment].forEach(config => {
      executeCommand(
        `firebase functions:config:set ${config.key}=${
          config.value
        } ${joinArguments([token])}`,
        dryrun
      )
    })
  }

  print(`Deploy features. ${only || ''} force: ${force}`)
  executeCommand(
    `yarn firebase deploy ${joinArguments([
      only,
      force ? '--force' : '',
      token
    ])}`,
    dryrun
  )

  console.log('All done 🎉')
  return 0
}

process.exit(main())

解説

このソースコードで何をしているのか紐解いていきます。

環境の定義

まずはデプロイ時に指定する環境の定義をします

const environments = ['development', 'production'] as const
type Environment = typeof environments[number]

これらは、.firebasercの設定を見て、keyの値と一致させるようにします。 予めdevelopmentproductionといった名前で.firebasercに登録しておくと良いと思います。

// .firebaserc
{
  "projects": {
    "development": "foo-project-development",
    "production": "foo-project-production"
  }
}

configの定義

enum ConfigKey {
  someAPIKey = 'some.api_key',
  someSecret = 'some.secret'
}

interface Config {
  key: ConfigKey
  value: string
}

type Configs = { [key in Environment]: Config[] }
const configs: Configs = {
  development: [
    { key: ConfigKey.someAPIKey, value: 'xxxyyyzzzdev' },
    { key: ConfigKey.someSecret, value: 'aaabbbcccdev' }
  ],
  production: [
    { key: ConfigKey.someAPIKey, value: 'xxxyyyzzz' },
    { key: ConfigKey.someSecret, value: 'aaabbbccc' }
  ]
}

今回は例を出すために直接tsファイルに値を書いていますが、実際の運用では

  • .envファイルなどから値を読み出し、valueを与える
  • スクリプト実行前に環境変数を設定し、process.envから取得しvalueを与える

といった形で、値を直接スクリプトに書かなくて済むようにすると良いです。 後者のやり方だと、上記の例は下記のようになります

const configs: Config[] = [
  { key: ConfigKey.someAPIKey, value: process.env.SOME_API_KEY },
  { key: ConfigKey.someSecret, value: process.env.SOME_SECRET }
]

また、その場合は環境変数を設定し忘れるとvalueがundefinedになる可能性があるので、値のチェックも追記するとより安全になります。

if (!process.env.SOME_API_KEY) {
  console.log('SOME_API_KEY is not set')
  process.exit(1)
  return
}

スクリプト実行での引数の確認

process.argv を見て、それぞれ見ていきます。

  if (process.argv.length <= 2) {
    console.log('Usage: yarn deploy [environment] [options...]')
    return 1
  }

  const environment = process.argv[2] as Environment
  if (!environments.includes(environment)) {
    console.log(`You must choose environment ${environments.join(' or ')}.`)
    return 1
  }

  let token: string | undefined
  if (process.argv.indexOf('--token') > 0) {
    token = `--token ${process.argv[process.argv.indexOf('--token') + 1]}`
  }

  let only: string | undefined
  if (process.argv.indexOf('--only') > 0) {
    only = `--only ${process.argv[process.argv.indexOf('--only') + 1]}`
  }

  const skipConfig = process.argv.includes('--skip-config')
  const force = process.argv.includes('--force')
  const dryrun = process.argv.includes('--dry-run')

序盤では、引数が足りているか、引数の最初で環境が指定されているかを確認し、無効な場合はそこでプログラムを終了するようにしています。 (main関数として最終的にコード(0or1)を返し、process.exitに渡しています。) ちなみにprocess.argvは添字が2以降に、引数が入ってくるので注意が必要です。

これらについては厳密に確認はしていないので、 --onlyのあとにはonlyで指定する文字列が来る前提、みたいな感じで確認をしています。 (本来はもっと厳密に確認すべきですがそうなると--onlyのあとに引数があるかどうかや文字列のバリデーションなりしないといけなくなるのでちょっと大変になります)

コマンドの実行とdry-run

スクリプト中で、通常のターミナルと同様にコマンドを実行したい場合は、デフォルトで備わっているchild_processというモジュールの関数を使います。 今回はそれの中のexecSync関数を使用し、オプションを指定しつつ、第一引数に実行したいコマンドを渡してあげるようにします。

execSync(command, { maxBuffer: 1024 * 1024, stdio: 'inherit' })

これによって、コマンドを実行しつつ、その結果を逐次stdoutに吐き出して出力することができます。 これによってTypeScript上でコマンドを実行してもShellScript等と同様にログの出力が可能になります。

更に、今回はdry-run実行を可能にするために、コマンド実行するための関数を次のように定義し、これを介してスクリプト中でのコマンド実行を行うようにします。

const executeCommand = (command: string, dryrun: boolean) => {
  if (!dryrun) {
    execSync(command, { maxBuffer: 1024 * 1024, stdio: 'inherit' })
  } else {
    print(`🛠  Run: ${command}`)
  }
}

// usage
executeCommand('echo "Hello, Firease"', dryrun)

これにより、dryrunがtrueの場合は単に標準出力をするだけにし、falseの場合はコマンド実行を行い、その結果の出力を標準出力に流すようにできます。

一連の流れを実行する

ここまで定義ができたら、

  • firebase useの実行
  • ソースコードのビルド
  • firebase functions:config:setの実行
  • firebase deployの実行

の処理を順に実行できるように記述します。

  print('Start deploying features to Firebase')
  dryrun && print('Enabled Dry run mode.')

  print(`Select environment: ${environment}`)
  executeCommand(
    `yarn firebase use ${joinArguments([environment, token])}`,
    dryrun
  )

  print('Build `src/`')
  executeCommand(`yarn build`, dryrun)

  if (!skipConfig) {
    print('Set firebase config values')
    configs[environment].forEach(config => {
      executeCommand(
        `firebase functions:config:set ${config.key}=${
          config.value
        } ${joinArguments([token])}`,
        dryrun
      )
    })
  }

  print(`Deploy features. ${only || ''} force: ${force}`)
  executeCommand(
    `yarn firebase deploy ${joinArguments([
      only,
      force ? '--force' : '',
      token
    ])}`,
    dryrun
  )

  console.log('All done 🎉')
  return 0

最後まで処理が成功したら「All done 🎉」を表示してデプロイが完了します

スクリプトを実行する

実行するために、package.jsonのscriptにいくつか追記します。

{
  "scripts": {
    "build": "yarn tsc && cp package.json dist/ && cp yarn.lock dist/",
    "deploy": "yarn ts-node scripts/deploy.ts",
    "deploy:development": "yarn deploy development",
    "deploy:production": "yarn deploy production"
  }
}

build に関しては、ts→jsファイルにトランスパイルしたファイルを吐き出す先はプロジェクトによって設定が異なる可能性があるので、 tsconfig.jsonなどを確認してください。 deployに関しては上記のようにベースとなるdeployコマンドを用意したあと、deploy:development,deploy:productionを定義してあげるとコマンドの入力が楽になるかと思います。

これで、 yarn deploy:development と実行すれば、開発環境用のFirebaseプロジェクトにデプロイができます。
このコマンドのあとに--only functions等を必要に応じて付ければ、デプロイの方法を細かく指定できます。

$ yarn deploy:development
$ yarn deploy:development --only functions:foobar --skip-config
$ yarn deploy:development --dry-run
$ SOME_API_KEY=xxxxx yarn deploy:production

まとめ

今回はFirebaseのデプロイに関しての説明、スクリプトの紹介をしました。 少し変えている部分もありますが、 実際に開発に携わっているプロジェクトでもこのようなスクリプトを準備して運用しています。 開発初期の段階から準備しておくと、とても心強いものとなるでしょう。