webアプリ開発における環境変数まわりのベストプラクティス
追記
文中で .env
に依存させないというプラクティスを紹介しました。僕は基本的にはこれでよいと思っていますが、フレームワークによっては.env
などと深く結合して利便性を提供しているものもあります。この場合は無理して.env
から脱却せず、うまいこと利用するのもありだなと最近感じています。ただし、フレームワークが.env
と深く結合していない場合は、dotenv
などのライブラリを導入するよりも、起動時に環境変数として注入する方式のほうがよいと感じています。
nodejsを例に解説します。nodejsでは環境変数はprocess.env.環境変数名
でとりだせます。また、開発環境・テスト環境・本番環境をそれぞれNODE_ENV
という環境変数にdevelopment
test
production
と入れる文化があります。
アプリケーションコードに自分が今いる環境(開発|ステージング|本番)を意識させない
これはつまり、コード内で環境識別変数(今回で言うところのNODE_ENV)によってif分岐を作らないという意味です。各環境にどのような設定が入るかはアプリケーションコード外にその種類分作成しましょう!
bad
if(開発環境){
const logger = new Logger({
level: 'debug'
});
} else if (ステージング環境){
const logger = new Logger({
level: 'info
});
} else if (本番環境){
const logger = new Logger({
level: 'error'
});
}
good
LOG_LEVEL=debug
LOG_LEVEL=info
LOG_LEVEL=error
const logger = new Logger({
level: process.env.LOG_LEVEL
});
残念ながら使っているフレームワークやDBクライアントライブラリによっては、開発環境・テスト環境・本番環境の設定を強制するものもあります。その場合はすべての環境に同じ環境変数をいれることで対応しましょう。
bad
module.exports = {
development: {
username: '開発用のusername',
password: '開発用のpassword',
database: '開発用のdatabase',
host: '開発用のhost',
},
test: {
username: 'testのusername',
password: 'testのpassword',
database: 'testのdatabase',
host: 'testのhost',
},
production: {
username: '本番のusername',
password: '本番のpassword',
database: '本番のdatabase',
host: '本番のhost',
},
};
good
DB_USER_NAME=dev
DB_PASSWORD=password
DB_NAME=development
DB_HOST=localhost...
DB_USER_NAME=stg
DB_PASSWORD=secret
DB_NAME=staging
DB_HOST=...
DB_USER_NAME=prod
DB_PASSWORD=super_secret
DB_NAME=prod
DB_HOST=...
module.exports = {
development: {
username: process.env.DB_USER_NAME,
password: process.env.DB_PASSWORD,
database: process.env.DB_NAME,
host: process.env.DB_HOST,
},
test: {
username: process.env.DB_USER_NAME,
password: process.env.DB_PASSWORD,
database: process.env.DB_NAME,
host: process.env.DB_HOST,
},
production: {
username: process.env.DB_USER_NAME,
password: process.env.DB_PASSWORD,
database: process.env.DB_NAME,
host: process.env.DB_HOST,
},
};
.env
に依存させない)
アプリに自分から環境変数を取りに行かせない(アプリ自体を.env
ファイルを使っている方や、デフォルトで.env
ファイルの環境を読み込むフレームワークなど多いかと思います。.env
ファイル自体は使ってもよいのですが、アプリ側から.env
ファイルを自動で読み込む設定をoffにします。フレームワークの設定だったり、dotenv系のライブラリだったりするかもしれません。それらをなくしてください。
なぜこのようなことをするかというと、アプリ自体に環境変数を能動的に取得してほしくないからです。これは「アプリケーションコードに自分が今いる環境(開発|ステージング|本番)を意識させない」と似ていますが、アプリ側にどの.env
に依存するのか、依存しないのかの判断をさせないための手段です。アプリ側には素直に渡された環境変数のみに依存してほしいのです。こうすることで、アプリがどの環境に依存すべきかの判断ロジックや条件分岐ももたずすみます。
アプリ自体はピュアに保っておき、もし.env
系のファイルに依存する必要があるならば、何かしらのライブラリ等を利用して外部から注入してあげます。これは後の「 開発環境では.env
の設定を外から注入する」セクションで説明します。
アプリ自体をピュアに保っていると、本番環境の環境変数を設定しやすいというメリットもあります。
bad
// .envから環境変数を読み込み
require('dotenv').config();
console.log(process.env.DB_HOST);
good
// 実際の環境変数を読み込み
console.log(process.env.DB_HOST);
.env
の設定を外から注入する
開発環境ではアプリ自体の.env
ファイルへの依存を削除しましたが、.env
自体は手軽にローカルで環境をいじるのに使いやすいですね。開発環境は.env
ファイルを使用して、本番環境ではそのままの環境変数を使いたいです。どうすればいいのでしょうか?
僕の場合は、アプリサーバーの起動スクリプトを分けています。nodejsの場合、package.json
という設定ファイルのscripts
という項目にコマンドを指定してあげればnpm run コマンド名
でコマンドを実行できます。ローカルで実行したいときはnpm run local-start
で.env
を読み込んでサーバーを実行します。本番環境で実行したいときはnpm run start
をそのまま実行します。
{
"name": "my app",
"version": "3.6.0",
"description": "My webpack project",
"main": "index.js",
"scripts": {
// その環境の環境変数をそのまま利用
"start": "nest start",
"build": "nest build",
"test": "jest",
// env-cmdは.envを読み込んで環境変数に設定してくれるnodejsのライブラリ
"local-start": "env-cmd -f .env npm run start",
"local-build": "env-cmd -f .env npm run build",
"local-test": "env-cmd -f .env.testing npm run test",
},
}
.env
はgitに載せない
念の為こちらも書いておきます。.env
は性質上gitにのるとやばい情報を扱うことがおおいので、.gitignore
できちんと指定しておきましょう。
基本.env
は機密性の高い項目にはダミーデータしか置かないほうがいいですね。そのために、開発中は機密性の高い設定値を要求する外部サービスは使わずその代わりにローカルでdockerを立ててモックするとよいと思います(AWS_SECRET_KEYとかを設定しない代わりにawsが提供するlocalstackイメージを使うなど)。どうしても外部サービスを使いたい場合は、.env
が.gitignore
にあることを確認して、ローカルの.env
だけ書き換えます。後述する.env.local
にはダミーデータのみおきましょう!
bad
SECRET_KEY=実際の値
good
SECRET_KEY=ダミーの値
開発用の環境変数は共有したいけど.env
はgitにのせないほうがいい、どうしたらいいんだと思っている人もいるかも知れません。以下のようにすると良いと思います。
-
.env.local
ファイルを作成する。こちらはgitで管理する。 - 新しい人が入ってくるたびに、
.env.local
を.env
としてコピーする。 - 環境変数新しいやつ使いたいなとおもったら、
.env
と.env.local
両方を更新する。
アプリ内で必須の環境変数がなければサーバーを止める
nodejsだとprocess.env.環境変数名
の型はstring
かundefined
(多言語で言うところのnull)になります。これはそもそも環境変数自体が設定されているかわからないためundefined
になりえるのです。しかしこれだと困ることがあります。アプリや内部で利用しているサービス上必須の設定値がundefined
だと困るのです。必須の設定値が未設定の場合、アプリサーバー自体を起動させないほうが安全です。
bad
const paymentService = new PaymentService({
// process.env.SECRET_KEYだけだとundefinedになりうるのでコンパイルエラーになる。
// そこでprocess.env.SECRET_KEYが未設定だとデフォルトで空文字をいれる
secret: process.env.SECRET_KEY ?? ''
})
good
if(!process.env.SECRET_KEY){
console.error('環境変数SECRET_KEYが設定されていません!');
process.exit(); // サーバーとめる
}
const paymentService = new PaymentService({
secret: process.env.SECRET_KEY
})
process.env
といった環境変数アクセス用の変数を直接使わずに、使いやすいようにラップする
'環境変数を扱う場合次の2種類の設定項目があると思います。
- 必須な設定項目
- オプショナルな設定項目(設定されなければデフォルトの値が使われる)
「必須な設定項目」は前章「アプリ内で必須の環境変数がなければサーバーを止める」で説明したように、なければサーバーを止める処理をします。つまり以下のような処理がコード上に散らばります。
if(!process.env.SECRET_KEY){
console.error('環境変数SECRET_KEYが設定されていません!');
process.exit(); // サーバーとめる
}
const paymentService = new PaymentService({
secret: process.env.SECRET_KEY
})
「オプショナルな設定項目」はデフォルトの値を指定する必要があります。つまり以下のような処理がコード上に散らばります。また、stringをIntに変える処理も加えるときもあり、コードが複雑になりがちです。また、同じ設定項目に対してデフォルト値は基本同じです。同じコードがいろんなところに散らばりますね...。
let port: number = 5000; // デフォルト値
if(process.env.PAYMENT_PORT){
// jsはややこしいことにうまくintに変更できなければNaNというnumber型の変数を返す。
const parsed = Number.parseInt(process.env.PAYMENT_PORT);
// NaNでなければ代入。
if(!Number.isNaN(parsed)){
port = parsed
}
}
const paymentService = new PaymentService({
port: port
})
上記のように、環境変数にアクセスする際のボイラープレートがコードのあちこちに散らばってしまっています。どうせなら、1つにまとめて、ボイラープレートもなくしていきたいところです。そこで、直接環境変数にアクセスせずに、ラッパーを作り、そこで前処理をしてから使うようにします。
設定
const convertIntOrDefault = (
raw: string | undefined,
defaultValue: number,
): number => {
if (!raw) return defaultValue;
const parsed = Number.parseInt(raw, 10);
if (Number.isNaN(parsed)) return defaultValue;
return parsed;
};
if (!process.env.HOST_DOMAIN) {
console.error('HOST_DOMAIN environmental variable is missing!');
process.exit();
}
export const appConfig = {
app: {
// [必須] サービスのドメイン名
hostDomain: process.env.HOST_DOMAIN,
// listenするポート番号
port: convertIntOrDefault(process.env.PORT, 3000),
},
}
使うとき
import {appConfig} from './config';
console.log(appConfig.app.hostDomain);
フレームワークに設定を扱う機能がある場合、そこに登録してもよさそうですね。
以上webアプリ開発における環境変数のベストプラクティスでした~
おまけ
NestJSようの環境変数まわりの記事も書きました!
Discussion
うーん、新しい環境が必要なら、development.yaml, test.yaml, production.yamlがあるところに、staging.yamlを追加します、という方式(大抵のFW/ライブラリ/ミドルウェアが採用している方式)がいい気がします。
どの環境でも同じ環境変数を参照する仕組みにする場合、結局の所、staging.envとかstaging_setenv.shが必要になりませんか?
コメントありがとうございます!
はい、そうですね、僕もそう思います。ちょっとわかりにくい表現をしてしまいました。ここで伝えたかったことは、アプリケーションコード自体に自分がいまどの環境にいるのかを意識させないということでした。これは言い換えればコードの中に環境識別情報を変数(今回で言うところのNODE_ENV)でif分岐させないという意味です。
例えば以下のようなコードですね。こうなると環境が増えるたびにコード内にNODE_ENVをもとにした分岐が増えていきます。
それよりも、その環境による差分を、
.env.development
や.env.production
やenv.staging
に入れてしまってアプリケーションコードは素直にその環境変数を受け入れるようにしようという意味でした。
記事の方をもう少し表現変えたいと思います^^
内容変更
変更箇所1
表現の変更
アプリの環境(例えばNODE_ENV)によって設定を分岐しない
->
アプリケーションコードに自分が今いる環境(開発|ステージング|本番)を意識させない
削除
よくDBとかで開発環境・テスト環境・本番環境ごとに設定を分岐させることがあります。The Twelve-Factor Appでも言及されているように、環境はその3つだけではなく、開発環境のなかでも設定値のバリエーションが生まれたりします。いっそのこと開発環境・テスト環境・本番環境と区別せずに、設定の値だけ環境変数を増やすほうがこのバリエーションに対応できます。ただし、環境を扱う環境変数(NODE_ENV)で分岐させたほうが素直な場合も極稀にあります(フレームワークが
NODE_ENV
環境によって挙動を変えるときとか)。追記
これはつまり、コード内で環境識別変数(今回で言うところのNODE_ENV)によってif分岐を作らないという意味です。各環境にどのような設定が入るかはアプリケーションコード外にその種類分作成しましょう!
変更箇所2
表現の変更
アプリ自体を
.env
に依存させない->
アプリに自分から環境変数を取りに行かせない。
削除
.env
を読み込む設定の場合、開発環境ではいいのですが、本番環境だと.env
を作成管理しなければいけません。できれば、本番環境はその環境の環境変数をそのまま使うほうが扱いやすいです。各本番環境ごとに、それぞれの環境変数を設定するだけで済みます。追記
なぜこのようなことをするかというと、アプリ自体に環境変数を能動的に取得してほしくないからです。これは「アプリケーションコードに自分が今いる環境(開発|ステージング|本番)を意識させない」と似ていますが、アプリ側にどの
.env
に依存するのか、依存しないのかの判断をさせないための防御手段です。アプリ側には素直に渡された環境変数のみに依存してほしいのです。アプリ自体はピュアに保っておき、どの
.env
に依存するのか、依存しないのかのバリエーションは外部が注入してあげます。アプリ自体をピュアに保っていると、本番環境の環境変数を設定しやすいというメリットもあります。
とても参考になるいい記事でした。ありがとうございます。
一点だけ誤字を見つけたので共有します。
誤
基本.envは
気密性
の高い項目にはダミーデータしか置かないほうがいいですね。正
基本.envは
機密性
の高い項目にはダミーデータしか置かないほうがいいですね。まさに
APP_ENV
やらNODE_ENV
を量産して地獄をみた経験があるので首がもげました。「あえて知らせないほうがうまくいく」ことがあるのは人間界でもコンピューター界でも一緒ですねぇ...