この記事は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-node
、typescript
をインストールします。
また、firebase-tools
もプロジェクトにインストールすることにします。
$ yarn add -D typescript
$ yarn add -D ts-node
$ yarn add -D firebase-tools
(npm
を使っても構いません。)
必要があれば、.gitignore
にnode_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の値と一致させるようにします。
予めdevelopment``production
といった名前で.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のデプロイに関しての説明、スクリプトの紹介をしました。 少し変えている部分もありますが、 実際に開発に携わっているプロジェクトでもこのようなスクリプトを準備して運用しています。 開発初期の段階から準備しておくと、とても心強いものとなるでしょう。