🍏

webアプリ開発における環境変数まわりのベストプラクティス

2022/03/09に公開
5

追記
文中で .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

.env.development
LOG_LEVEL=debug
.env.staging
LOG_LEVEL=info
.env.production
LOG_LEVEL=error
アプリケーションコード
const logger = new Logger({
  level: process.env.LOG_LEVEL
});

残念ながら使っているフレームワークやDBクライアントライブラリによっては、開発環境・テスト環境・本番環境の設定を強制するものもあります。その場合はすべての環境に同じ環境変数をいれることで対応しましょう。

bad

sequelizeConfig.js
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

.env.development
DB_USER_NAME=dev
DB_PASSWORD=password
DB_NAME=development
DB_HOST=localhost...
.env.staging
DB_USER_NAME=stg
DB_PASSWORD=secret
DB_NAME=staging
DB_HOST=...
.env.production
DB_USER_NAME=prod
DB_PASSWORD=super_secret
DB_NAME=prod
DB_HOST=...
sequelizeConfig.js
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をそのまま実行します。

package.json
{
  "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

.env
SECRET_KEY=実際の値

good

.env
SECRET_KEY=ダミーの値 

開発用の環境変数は共有したいけど.envはgitにのせないほうがいい、どうしたらいいんだと思っている人もいるかも知れません。以下のようにすると良いと思います。

  1. .env.localファイルを作成する。こちらはgitで管理する。
  2. 新しい人が入ってくるたびに、.env.local.envとしてコピーする。
  3. 環境変数新しいやつ使いたいなとおもったら、.env.env.local両方を更新する。

アプリ内で必須の環境変数がなければサーバーを止める

nodejsだとprocess.env.環境変数名の型はstringundefined(多言語で言うところの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つにまとめて、ボイラープレートもなくしていきたいところです。そこで、直接環境変数にアクセスせずに、ラッパーを作り、そこで前処理をしてから使うようにします。

設定

config.ts
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),
  },
}

使うとき

main.ts
import {appConfig} from './config';

console.log(appConfig.app.hostDomain);

フレームワークに設定を扱う機能がある場合、そこに登録してもよさそうですね。

以上webアプリ開発における環境変数のベストプラクティスでした~

おまけ

NestJSようの環境変数まわりの記事も書きました!
https://zenn.dev/dove/articles/2990f0e1eba07e

Discussion

Error401Error401

環境はその3つだけではなく、開発環境のなかでも設定値のバリエーションが生まれたりします。いっそのこと開発環境・テスト環境・本番環境と区別せずに、設定の値だけ環境変数を増やすほうがこのバリエーションに対応できます。

うーん、新しい環境が必要なら、development.yaml, test.yaml, production.yamlがあるところに、staging.yamlを追加します、という方式(大抵のFW/ライブラリ/ミドルウェアが採用している方式)がいい気がします。

どの環境でも同じ環境変数を参照する仕組みにする場合、結局の所、staging.envとかstaging_setenv.shが必要になりませんか?

ハトすけハトすけ

コメントありがとうございます!

うーん、新しい環境が必要なら、development.yaml, test.yaml, production.yamlがあるところに、staging.yamlを追加します、という方式(大抵のFW/ライブラリ/ミドルウェアが採用している方式)がいい気がします。

はい、そうですね、僕もそう思います。ちょっとわかりにくい表現をしてしまいました。ここで伝えたかったことは、アプリケーションコード自体に自分がいまどの環境にいるのかを意識させないということでした。これは言い換えればコードの中に環境識別情報を変数(今回で言うところのNODE_ENV)でif分岐させないという意味です。

例えば以下のようなコードですね。こうなると環境が増えるたびにコード内にNODE_ENVをもとにした分岐が増えていきます。

アプリケーションコード
if(開発環境){
   const logger = new Logger({
    level: 'debug'
  });
} else if (ステージング環境){
   const logger = new Logger({
    level: 'debug'
  });
} else if (本番環境){
   const logger = new Logger({
    level: 'debug'
  });
}

それよりも、その環境による差分を、.env.development.env.productionenv.stagingに入れてしまって

.env.development
LOG_LEVEL=debug
.env.production
LOG_LEVEL=error
.env.stg
LOG_LEVEL=info

アプリケーションコードは素直にその環境変数を受け入れるようにしようという意味でした。

アプリケーションコード
   const logger = new Logger({
    level: process.env.LOG_LEVEL
  });

記事の方をもう少し表現変えたいと思います^^

ハトすけハトすけ

内容変更

変更箇所1

表現の変更

アプリの環境(例えばNODE_ENV)によって設定を分岐しない
->
アプリケーションコードに自分が今いる環境(開発|ステージング|本番)を意識させない

削除

よくDBとかで開発環境・テスト環境・本番環境ごとに設定を分岐させることがあります。The Twelve-Factor Appでも言及されているように、環境はその3つだけではなく、開発環境のなかでも設定値のバリエーションが生まれたりします。いっそのこと開発環境・テスト環境・本番環境と区別せずに、設定の値だけ環境変数を増やすほうがこのバリエーションに対応できます。ただし、環境を扱う環境変数(NODE_ENV)で分岐させたほうが素直な場合も極稀にあります(フレームワークがNODE_ENV環境によって挙動を変えるときとか)。

追記

これはつまり、コード内で環境識別変数(今回で言うところのNODE_ENV)によってif分岐を作らないという意味です。各環境にどのような設定が入るかはアプリケーションコード外にその種類分作成しましょう!

変更箇所2

表現の変更

アプリ自体を.envに依存させない
->
アプリに自分から環境変数を取りに行かせない。

削除

.envを読み込む設定の場合、開発環境ではいいのですが、本番環境だと.envを作成管理しなければいけません。できれば、本番環境はその環境の環境変数をそのまま使うほうが扱いやすいです。各本番環境ごとに、それぞれの環境変数を設定するだけで済みます。

追記

なぜこのようなことをするかというと、アプリ自体に環境変数を能動的に取得してほしくないからです。これは「アプリケーションコードに自分が今いる環境(開発|ステージング|本番)を意識させない」と似ていますが、アプリ側にどの.envに依存するのか、依存しないのかの判断をさせないための防御手段です。アプリ側には素直に渡された環境変数のみに依存してほしいのです。
アプリ自体はピュアに保っておき、どの.envに依存するのか、依存しないのかのバリエーションは外部が注入してあげます。
アプリ自体をピュアに保っていると、本番環境の環境変数を設定しやすいというメリットもあります。

イカフライイカフライ

とても参考になるいい記事でした。ありがとうございます。
一点だけ誤字を見つけたので共有します。


基本.envは気密性の高い項目にはダミーデータしか置かないほうがいいですね。


基本.envは機密性の高い項目にはダミーデータしか置かないほうがいいですね。

Shota TamuraShota Tamura

まさにAPP_ENVやらNODE_ENVを量産して地獄をみた経験があるので首がもげました。「あえて知らせないほうがうまくいく」ことがあるのは人間界でもコンピューター界でも一緒ですねぇ...