💭

開発環境での環境変数管理について考えてること

2023/12/09に公開

ローカルでの開発環境を作るとき、環境変数の管理について色々考えているけど言語化したことがなかったのでまとめます。

前提

このエントリは

  • ローカルマシンでの開発環境
  • よくある Web の構成で RDB や Redis 等のミドルウェアが存在し、ローカルでは docker, docker compose を使って建てて接続する
  • アプリケーションサーバーがある
    • コンテナに載っていてもホストマシンで直接動かしていても良いが、どちらかというと後者を想定している

のような開発環境を想定しています。

環境変数、 .env に書くか .envrc に書くか

環境変数をどこに書こうか、といったときには主要な選択肢として

  • .env に書いてツールに渡す
  • .envrc (direnv によって読む) に書いてホストマシンに環境変数を設定する

の 2 つがあります。

direnv を知らない人向けに説明しておくと direnv は .envrc ファイルに環境変数の設定を書いておくと置かれているディレクトリに移動したときに有効化、外れたときに無効化してくれるツールです。

https://github.com/direnv/direnv

それぞれの利点をあげると

  • .env に書いてツールに渡す
    • ツールや言語・フレームワークが受け付けてくれることが多いので使いまわしやすい
      • docker の env_file だったり
      • Next.js は勝手に読んでくれたり
      • 任意の言語で dotenv モジュールが提供されており、使うことで読み込むことが可能だったり
  • .envrc に書いてホストマシンに環境変数を設定する
    • ローカルで動かす前提のシェルスクリプトだったり、Makefile や npm scripts においておくようなワンライナー、ちょっとしたコマンドで環境変数が必要なときにホストマシンに設定されているとハードコーディングされた同一の値が分散しない
    • dotenv モジュールと違ってホストマシンに実際に環境変数を設定することができる
      • 実行時に読み込むのは本番...etc の環境と違った動きなのであまり望ましくない

といった形でそれぞれ良い点があります。

また、.env の

ツールや言語・フレームワークが受け付けてくれることが多いので使いまわしやすい

は direnv も例外ではなく

.envrc
#!/usr/bin/env bash

dotenv ./.env

を書いておくことでホストマシンに .env に書かれた環境変数を読み込ませることができます。

ですので、それぞれの環境変数を .env に書くにせよ .envrc に書くにせよ、.envrc を設置して direnv でホストマシンにも読み込ませられる状態にしておくと

ローカルで動かす前提のシェルスクリプトだったり、Makefile や npm scripts においておくようなワンライナー、ちょっとしたコマンドで環境変数が必要なときにホストマシンに設定されていると手軽に利用できる

dotenv モジュールと違ってホストマシンに実際に環境変数を設定することができる

のメリットを享受できて良いです。

環境変数は .env に書いて .envrc で読み込むべし

.envrc の場合、中身はシェルスクリプトで

#!/usr/bin/env bash

export DB_NAME="my-app"

このような形で環境変数を定義します。
.env とは記法が異なるので直接 dotenv を期待するツールに渡すことはできません。

ですので、コンテナ内部向けの環境変数や direnv が自動で読まれない IDE 向けだったりすると .env の方が使い勝手が良かったりすることがあります。

例として MySQL のコンテナに設定する環境変数が .env で記述されていると

compose.yaml
version: 3.8

services:
  my-app-mysql:
    image: mysql:8.0
    # ...
    env_file: ./.env

このように env_file に渡すだけで済む内容が .envrc にかかれていると

compose.yaml
version: 3.8

services:
  my-app-mysql:
    image: mysql:8.0
    # ...
    environment: MYSQL_ROOT_PASSWORD=$MYSQL_ROOT_PASSWORD
      MYSQL_USER=$MYSQL_USER
      MYSQL_PASSWORD=$MYSQL_PASSWORD
      MYSQL_DATABASE=$MYSQL_DATABASE

のようにコンテナ内の環境変数にホストマシンの環境変数をリマップして記述する必要が出てきます。

と言っても値自体は DRY になっていますしデータベース用のコンテナに不要な大量の環境変数を流し込むのもそれはそれで嫌なので

  • アプリケーションサーバーをコンテナに乗せており、.env で管理されている大量の環境変数を渡したいなら .env で管理させて env_file で渡せることが望ましい
  • アプリケーションサーバー以外のミドルウェアを建てるコンテナ(MySQL 等)であればホストマシンの環境変数を明示的に渡すのが望ましい
    • ホストマシンに設定されてさえいれば良いので .env にかかれていても .envrc にかかれていても良い

と思います。

いずれにせよ環境変数の宣言自体は .env でされているほうが選択肢が広い(アプリケーションサーバーをコンテナに乗せていなくても気軽にのせ替えたりできる)ので

  • 環境変数は基本的に .env に書く
  • .env で書かれた環境変数を direnv を使ってホストマシンに読み込む

の形を取っておくのが使い勝手が良いと思います。

環境変数に依存する環境変数は .env でも定義できる

.envrc だとシェルスクリプトなので別の環境変数に依存する環境変数を定義することができます。

例えば prisma で DATABASE_URL を求められたときには

.envrc
#!/usr/bin/env bash

export DB_USERNAME="user"
export DB_PASSWORD="password"
export DB_HOST="localhost"
export DB_PORT=3306
export DB_NAME="my-app"
export DATABASE_URL="mysql://$DB_USERNAME:$DB_PASSWORD@$DB_HOST:$DB_PORT/$DB_NAME"

こう書いてあげると DB_PASSWORD 等の情報を DRY に保ったまま DATABASE_URL を用意できます。

しかし、このエントリを書きながら調べていたらどうやら .env でも別の環境変数の依存は解決してくれるらしく

.env
DB_USERNAME="user"
DB_PASSWORD="password"
DB_HOST="localhost"
DB_PORT=3306
DB_NAME="my-app"
DATABASE_URL="mysql://$DB_USERNAME:$DB_PASSWORD@$DB_HOST:$DB_PORT"

を書いて direnv で読み込むと

$ direnv allow
$ echo $DATABASE_URL
mysql://user:password@localhost:3306/my-app

この通り問題なく結合されることが分かりました。

.env ファイルを分割していて別の .env にかかれている環境変数や、元々ホストマシンに設定されている環境変数に依存して設定するようなケースでは解決できないようなので例外ケースは存在しますが、他の環境変数に依存する環境変数を設定したい場合でも基本的には他と同様に .env を使ってあげるのが良いと思います。

環境変数は .env.app と .env.local と .env.secret に分ける

.env に書くのが良いよ!という話をしてきましたが、ローカルで利用したい環境変数をすべて単一の .env に書いてしまうのは望ましくないでしょう。

ローカルで扱う環境変数には大きく分けて

  • アプリケーションで参照する環境変数
  • コンテナ内の環境変数
  • compose.yaml やスクリプト等で参照するホストマシン向けの環境変数

の 3 種類が存在します。
特にアプリケーション向けの環境変数と、ホストマシン向けの環境変数は性質がかなり違います。

  • アプリケーション向けの環境変数の関心は当然アプリケーションだが、ホストマシン向けの環境変数の関心はどちらかというとビルドシステム・ローカル開発環境にある
  • アプリケーション向けの環境変数は他の環境でも要求される

ですので、同じファイルにはなるべく置きたくないです。
別ファイルになっているとアプリケーション用 .env をみればアプリケーションを動かすのに必要な環境変数がすべてわかるという利点もあります。

また、コミット対象にできるか(秘匿情報ではないか?)という点もファイル分割の観点としてあります。

新しい開発者のオンボーディングコストや環境変数が追加されたときの追従のコストを考えると環境変数の中でも秘匿情報でない環境変数はコミット対象にして共有しておき、秘匿情報の環境変数だけ sample をコピーしてもらうような形にしておきたいです。
CSRF 用の Secret 値だったり DB のパスワードだったりといった秘匿情報もローカル環境向けならコミット対象にしてしまって問題ないと思うのでローカルでもコミットしたくない環境変数はごく一部ですし。

そこで

  • .env.app
    • アプリケーションで直接利用する環境変数
  • .env.local
    • ホストマシン向けの環境変数
    • DOCKER_BUILDKIT=1 等のツール設定やMYSQL_CONTAINER_NAME="app-mysql" のような compose.yaml, ローカルのスクリプトで利用する環境変数
  • .env.secret
    • 秘匿情報を設置する環境変数

の 3 ファイルに分離しておくのが良いかなと思っています。

ちなみに

コンテナ内の環境変数

に対応するファイルがありませんが、これは以下の理由からです。

  • アプリケーションサーバー用のコンテナであれば .env.app を env_file に渡せば良い
  • ミドルウェア用のコンテナであればホストマシンの環境変数をリマップしてあげれば良い
    • そもそもアプリ側から見ない値であればハードコーディングで良いし、アプリ側でも見る(接続情報等)値ならファイル分けると DRY にならない

分割したファイルは .envrc でまとめて読み込みます。

#!/usr/bin/env bash

dotenv ./.env.app
dotenv ./.env.local

# 秘匿設定読み込み
if [ -f "./.env.secret" ]; then
  dotenv ./.env.secret
fi

これで $ direnv allow を叩けば .env を分割した状態でホストマシンに環境変数を設定することができました。

厳密には .env.secret は .env.local.secret と .env.app.secret に分けられるはずですが、ローカル環境ではそこまで secret がファットにならないことを想定しているので自分はあえて分離しないです。分けるのも全然アリだとは思います。

.envrc には .env.secret のバリデーションを書いておく

.env.secret は .env.secret.sample が更新されているときに追従できておらず問題が起きることがしばしばあります。

.envrc にはスクリプトが書けるので .env.secret のバリデーションを書いてあげると、環境変数の追従遅れに気づけて良いです。

.envrc
#!/usr/bin/env bash

dotenv ./.env.app
dotenv ./.env.local

# 秘匿設定読み込み
if [ -f "./.env.secret" ]; then
  dotenv ./.env.secret
else
  echo "ERROR: .env.secret が必要です。.env.secret.sample をコピーして作成してください" >&2
  exit 1
fi

# sample ファイルから必要な環境変数の設定が漏れていないかチェック
required_envs=$(cat .env.secret.sample | grep -v -E '^#' | cut -d '=' -f 1)

for required_env in $required_envs; do
  if [ -z "${!required_env}" ]; then
    echo "ERROR: 環境変数の ${required_env} が未設定です。.env.secret に追加が漏れていないか確認してください" >&2
    exit 1
  fi
done

雑ですがこんな感じで書いておいてあげると

$ direnv allow
direnv: loading ~/path/to/.envrc
ERROR: 環境変数の ADDED が未設定です。.env.secret に追加が漏れていないか確認してください
direnv: error exit status 1

direnv allow したときに気づけて親切です。

設定の例

概ね管理方法として気にしているところを整理できたので具体的な設定を書いてみます。
アプリケーションサーバーの開発サーバーはホストマシンで直接動かして、MySQL を docker compose で建てているとします。

まずはアプリケーションから利用する環境変数

.env.app
DB_USERNAME="my-app"
DB_PASSWORD="my-app"
DB_NAME="my-app"
DB_HOST="127.0.0.1"
DB_PORT=36000
DATABASE_URL="mysql://$DB_USERNAME:$DB_PASSWORD@$DB_HOST:$DB_PORT/$DB_NAME"
ENV="local" # local | staging | production

次にホストマシン向けの環境変数です。

.env.local
MYSQL_CONTAINER_NAME="my-app-mysql"
DOCKER_BUILDKIT=1
MYSQL_ROOT_PASSWORD="password"

docker compose 時に buildkit を使って欲しいので DOCKER_BUILDKIT=1 の設定と、コンテナ名をスクリプトから参照したいので MySQL のコンテナ名を環境変数に置いています。

次に秘匿情報の環境変数。

.env.secret.sample
# app
HOGE_SASS_SECRET=

# local
GITHUB_PAT=

利用している SaaS の Secret 値と GitHub の PAT が必要な想定です。

.envrc で読み込ませます。

.envrc
#!/usr/bin/env bash

dotenv ./.env.app
dotenv ./.env.local

# 秘匿設定読み込み
if [ -f "./.env.secret" ]; then
  dotenv ./.env.secret
else
  echo "ERROR: .env.secret が必要です。.env.secret.sample をコピーして作成してください" >&2
  exit 1
fi

# sample ファイルから必要な環境変数の設定が漏れていないかチェック
required_envs=$(cat .env.secret.sample | grep -v -E '^#' | cut -d '=' -f 1)

for required_env in $required_envs; do
  if [ -z "${!required_env}" ]; then
    echo "ERROR: 環境変数の ${required_env} が未設定です。.env.secret に追加が漏れていないか確認してください" >&2
    exit 1
  fi
done

この状態で $ direnv allow すれば環境変数が設定されます。

ミドルウェアを起動する docker compose 用の compose.yaml を書いていきます。

compose.yaml
version: "3.9"

services:
  my-app-mysql:
    container_name: ${MYSQL_CONTAINER_NAME}
    image: mysql:8.0
    restart: always
    environment:
      - DB_USERNAME=${DB_USERNAME}
      - DB_PASSWORD=${DB_PASSWORD}
      - DB_NAME=${DB_NAME}
      - MYSQL_ROOT_PASSWORD=${DB_ROOT_PASSWORD}
    ports:
      - "${DB_HOST}:${DB_PORT}:3306"
    volumes:
      - my-app-mysql-data:/var/lib/mysql
      - ./mysql/logs:/var/log/mysql
      - ./mysql/my.cnf:/etc/mysql/conf.d/my.cnf:ro
      - ./mysql/initdb.d:/docker-entrypoint-initdb.d:ro
    healthcheck:
      test:
        [
          "CMD-SHELL",
          "mysql -e 'select version()' -hlocalhost -u${DB_USERNAME} -p${DB_PASSWORD} ${DB_NAME}",
        ]
      interval: 2s
      timeout: 5s
      retries: 15

volumes:
  my-app-mysql-data:
mysql/initdb.d/user_and_db.sh
#!/usr/bin/env bash

set -eux

function run_query() {
  local -r QUERY=$1
  mysql -hlocalhost -uroot -p$DB_ROOT_PASSWORD -e "$QUERY"
}

run_query "
CREATE USER IF NOT EXISTS '$DB_USERNAME'@'%' IDENTIFIED BY '$DB_PASSWORD';
GRANT ALL PRIVILEGES ON *.* TO '$DB_USERNAME'@'%' WITH GRANT OPTION;
FLUSH PRIVILEGES;
"

run_query "
CREATE DATABASE IF NOT EXISTS $DB_NAME;
"

これで docker compose up --build すれば環境変数の接続情報を元にミドルウェアを建てることができます。
完全に DRY になっていて、例えばユーザー名を変えたい!となったときには DB_USERNAME だけ書き換えてコンテナと開発サーバーを立て直せば対応は終わりです。

また、MySQL のコンテナ名をホストマシンの環境変数 MYSQL_CONTAINER_NAME から設定しているので、例えばローカルの MySQL で任意のクエリを実行できるスクリプトを用意しようと思ったら

mysql_query.sh
set -eux

query=$1

docker exec $MYSQL_CONTAINER_NAME \
  mysql -hlocalhost -u$DB_USERNAME -p$DB_PASSWORD -e "$query"

このようにアプリケーションの環境変数、ホストマシン用の環境変数を使って書くことができます。

MYSQL_CONTAINER_NAME のように compose.yaml 内や別のローカルのスクリプトで共通化したい値はハードコーディングされてることがよくある印象ですが、ホストマシンの環境変数を使えば共通化できるのでオススメです。

その他気にしていること・考えていること

1password cli を使った秘匿情報の取得

今回は秘匿情報とそうでない環境変数でファイルを分離しましたが、関心事が別なわけではないのでできれば同じファイルで管理できると嬉しいです。

1password cli を使うと

.env.app.template
HOGE_SAAS_SECRET='op://Private/XXX/secret'

のような形式のテンプレートファイルから .env ファイルを生成することができます。

$ op inject --in-file .env.app.template --out-file .env.app

このやり方だと部分的に共有できる環境変数をコミット対象にしなくても新しく参加した人が手軽に .env.app を用意できますし、新しい環境変数の追加にもシームレスに対応できます。

1password をチームで使っている場合にはファイルを分けるより良い選択肢かもしれません。

参考:
https://cockscomb.hatenablog.com/entry/dotenv-managed-by-1password

秘匿情報でない環境ごとの値はそもそも環境変数に置かない

自分は秘匿情報をコミットせずにアプリケーションにわたす手段としての環境変数は積極的に使いますが、環境ごとに挙動を変える手段としての環境変数はあまり使わないようにしています。

環境変数、設定変更が漏れていたり違う名前を参照してたりと言った単純なミスを踏みがちなので型システムの内側においてあげたほうが、バグを防ぐ意味でも開発体験の意味でも嬉しいからです。

なので秘匿情報じゃない環境変数は、例えば TypeScript なら

// lib/config.ts
import z from "zod"

const envSchema = z.union([
  z.literal("local"),
  z.literal("staging"),
  z.literal("production"),
])

type Env = z.infer<typeof envSchema>

const env = envSchema.parse(process.env["ENV"]) // 環境変数からどの環境で動いてるかを受け取る

const defineConfig = <C extends Record<string, unknown>>(configMap: {
  [K in Env]: C
}) => configMap[env]

// auth/config.ts
type AuthConfig = {
  authUrl: string
}

const authConfig = defineConfig<AuthConfig>({
  local: {
    // 環境ごとの値は環境変数ではなくアプリケーション内で記述する
    authUrl: "https://localhost:2000/login",
  },
  staging: {
    authUrl: "https://staging.example.com/login",
  },
  production: {
    authUrl: "https://production.example.com/login",
  },
})

// 利用側
authConfig.authUrl

どの環境かを表現する環境変数だけ分岐用に用意して、それぞれの環境でどういう値を使うかは言語側で書いてあげています。
こうすると設定の参照は変更にも強いし、誤った名前で参照しようとすることもないし、環境変数がファットになって見通しが悪くなるのも防げて良きです。

まとめ

ローカルでの開発環境を構築するときに、環境変数の管理周りで考えていることをまとめてみました。

環境変数は .env を関心事・秘匿情報かどうかでファイルを分けて記述し、direnv で読み込んであげると

  • ローカル向けのスクリプトだったり、initdb.d にあるような初期設定ファイルが増えるたびに色々なところにハードコーディングされた値が分散することを防げる = DRY になる
  • DRY になるので変更に強く、見通しが良い
    • 変更忘れはない?どこに書かれてる値が正なの?といった迷いがなくなる
  • 新しいメンバーのオンボーディングや環境変数が追加されたときに追従するコストが下がる

といった良い点があります。

ローカルでの環境変数の管理の方法について少しでも参考になれば幸いです。

Discussion