- ソフトウェアは急速に進化してきたが、OSの環境変数システムは今なお数十年前の構造を維持している
- 環境変数はグローバルな文字列辞書の形をしており、名前空間も型もない単純な構造を持つ
- Linuxでは環境変数は
execve システムコールを通じて親プロセスから子プロセスへ渡される
- Bash、glibc、Python などはそれぞれ環境変数をハッシュマップ、配列、辞書ラッパーの形で管理している
- POSIX 標準は名前に大文字のみを要求しておらず、実際には小文字名の使用が推奨されるなど柔軟なルールを持つ
環境変数とは何か
- プログラミング言語は急速に発展してきたにもかかわらず、OSが提供するプロセス実行基盤、特に環境変数の部分はほとんど変化していない
- アプリケーション実行時に別ファイルや IPC なしでランタイムパラメータを渡そうとすると、環境変数ベースのインターフェースに頼らざるを得ないのが現実である
- 環境変数は名前空間も型もない、フラットな文字列辞書として機能する
環境変数の作成と受け渡しの構造
- 環境変数はプロセス間で値を受け渡す伝統的な方法であり、親プロセスが子プロセスを実行するときに一緒に渡される
- つまり、親プロセスから子プロセスへ継承される構造である
- Linux では
execve システムコールが実行ファイル、引数、そして環境変数配列(envp)を引数に取る
- 実行コマンドの例:
ls -lah の場合
- filename:
/usr/bin/ls
- argv:
['ls', '-lah']
- envp:
['PATH=...','USER=...']
- 親プロセスは子プロセスに既存の環境をそのまま渡すことも、まったく新しい環境を構成することもできる
- ほぼすべてのツール(Bash、Python の
subprocess.run、C ライブラリ execl など)は環境変数をそのまま引き渡す
- 例外として、
login のような一部のツールは新しい環境を構成する
環境変数の保存場所と内部処理
- カーネルはプログラム開始時に環境変数をnull-terminated string 形式でスタック上に保存する
- このデータはプログラムが直接変更しにくく、通常はプログラム内部でコピーして独自の構造で管理する
- 各言語およびシェルの環境変数保存方式
- Bash: スタック構造のハッシュマップ(辞書)として管理
- 関数呼び出しごとにローカルスコープのマップを生成
export された変数だけが子プロセスに渡される
local で宣言された変数も export を通じて子プロセスに渡せる
- 例:
export PATH によってローカルな変更を子に反映できるが、グローバルには影響しない
- glibc(C ライブラリ): 動的配列構造の
environ を putenv, getenv を通じて管理
- 配列構造のため、検索・変更ともに線形時間計算量を持つ
- したがって高い性能が求められるデータ保存用途には不向きである
- Python: 内部では
os.environ として辞書のように提供されるが、実態としては C ライブラリの environ 配列と連動している
os.environ の値を変更すると os.putenv が呼ばれ、C ライブラリ側にも反映される
- 逆方向は同期されないため、一方向性が存在する
環境変数のフォーマットと許容範囲
- Linux カーネルと glibc は環境変数のフォーマットに非常に寛容である
- 同じ名前が重複して複数の値を持つことが可能
= がなくても登録可能で、絵文字など特殊文字にも制限がない
- 利用可能なサイズ制限
- 個別変数: 128 KiB(通常の x64 環境)
- 全体合計: 2 MiB(コマンドライン引数と共有)
- 環境変数はスタック空間の 1/4 を超えないよう制限されている
環境変数の特異点とエッジケース
- Bash は奇妙な環境変数(重複、
= のない項目など)の場合、重複名を削除し異常な項目を無視する
- 変数名に空白があると Bash では名前参照できないが、それでも子プロセスへ渡すことは可能
- 例: Nushell、Python などは空白名の変数を作成できる
- Bash はこうした項目を別のハッシュマップ(
invalid_env)に保存して管理する
標準(standard)環境変数フォーマットと命名規則
- POSIX 標準では、名前に等号(
=)さえ含まれていなければ変数として認められる
- 公式推奨: 名前には大文字、数字、アンダースコアのみを許可(先頭は数字不可)
- 小文字変数はアプリケーション専用の名前空間用途
- 標準ツールは大文字のみを使うが、小文字変数の使用も許可されている
- 実際には開発者たちは主に ALL_UPPERCASE 方式で命名している
- 推奨ルール: 変数名には正規表現
^[A-Z_][A-Z0-9_]*$ を使い、値には UTF-8 を使う
- 例外や互換性が心配な場合は POSIX の Portable Character Set(ASCII)の使用が推奨される
結論
- 環境変数は今なお古いが不可欠なインターフェースであり、OSとアプリケーションの境界面として機能している
- 構造的な限界があるにもかかわらず、Bash、C、Python などはこれをそれぞれ異なる方法でラップして活用している
- 現代的なシステムでは、明確な名前空間と型体系を持つ設定管理方式がますます必要になっている
2件のコメント
一見すると重要性が薄れているように見えたが、Dockerとクラウドの登場によって、再び避けて通れないものになった。
Hacker Newsのコメント
SRE/Sysadmin/DevOps/Whatever として働いている。ブログでは環境変数の標準化についてそれほど難しい話はしていなかったが、代替手段も同じようにかなりもどかしさを生むこと、特に secrets が絡むとより深刻になることを指摘したい。
Application が Hashicorp Vault/OpenBao/Secrets Manager のような特定のシークレットストア(vault)にアクセスする構造は、すぐに深刻なベンダーロックインを招き、置き換えがライブラリレベルにまで波及して非常に難しくなる。
Vault の可用性が極めて重要になり、アップグレードやメンテナンスが必要になったときに運用チームが大きく苦しむ。
Config ファイルで秘密情報を渡す場合も、その秘密をどう格納するかで悩ましい。config ファイルはたいてい公開パスにあるからだ。
結局のところ、「特権システムがアプリに渡す前にテンプレートで置換する」か、「config ファイル全体をシークレットストアに保存してアプリに渡す」かのどちらかに依存することになる。
テンプレート処理はミスをしやすく、config ファイル全体をシークレットストアへ移す場合も、誰かが誤ってアップロードするリスクがありストレスになる。
最近のほとんどのシステムはコンテナ上で動いているが、インフラをきちんと統制している会社でなければ、config ファイルはいつも妙な場所に置かれていて、マウントの過程がさらにわかりにくくなり、ミスも頻発する。
JSON/YAML/TOML などどのフォーマットを使っても独特のバグが出るのが日常茶飯事で、たとえば YAML の Norway 問題のようなものがある。
Kubernetes Secrets API で秘密を受け取る方式も見たことがあるが、これもまた強いベンダーロックインの問題に突き当たる。
特に operator のようなシステムを設計しているのでなければ、この方法は積極的には勧めない。
Subprocess を通じて環境変数を設定する過程で生じる問題も見てきたが、最近のチームはこれよりメッセージバスベースのシステムを使うことで、より堅牢で独立したスケーリングが可能だと思っている。
うちのチームでは、軽量で汎用的な Secrets ライブラリを作り、その裏側に AWS Secrets Manager のようなベンダーごとのバックエンドだけをプラグイン方式で付けて使ったことがある。
ローカルキャッシュ設定やパラメータごとのキャッシュバイパスオプションが用意できたので、実際のベンダー依存ロジックはバックエンドにだけ残り、ライブラリやアプリケーションはベンダー非依存のまま保てた。
Vault に移行するときも、バックエンドを1つ追加して設定を変えただけで、特に問題なく適用できた。
Kubernetes secret API がなぜベンダーロックインの問題に見えるのか気になる。
もしかして Kubernetes デプロイ以外の目的で deployment yaml を使おうとしていたのだろうか。
多くのアプリでは secret をコンテナにマウントしたあと、環境変数や json ファイルの形でアプリに注入すれば、環境に依存せず読み書きできる。
etcd バックエンドの暗号化も KMS で構成できると理解している。
Kubernetes Secrets API を通じて秘密を受け取るのがなぜロックインなのか、正直よくわからない。
基本的に K8s secrets は暗号化されて保存されないので、(0) K8s 自体を使い、(1) コントロールプレーンで暗号化を設定し、(2) CSI ドライバのような追加ソリューションを必ず使ってこそ意味があると思う。
そして Secret Store CSI Driver は Conjur などさまざまなバックエンドをサポートしているので、むしろロックインとは逆だ。
こうした理由から、今でも config は env vars と dotenv を中心に使っている。
環境変数ベースの設定構造はあまりにも単純で、シークレットマネージャのようなさまざまなツールとも相性がよい。
ここ数年は YAML ベースの sOps にも少しずつ関心を持っている。
YAML はアプリ設定の構造表現が本当に直感的で、sops で一部だけ暗号化して管理しやすい。
ただし GPG キー管理は面倒な面があるが、Vault や OpenBao のようなもので解決はできる。
ただ、その過程でまたベンダーロックインが生まれるという問題があり、OpenBao のほうがまだ少しはましなように思う。
コマンド実行結果として環境変数を受け取ることもできるので、テンプレート処理なしでもベンダーロックインなく扱える。
もう1つ面白い事実として、
setenv()は POSIX では根本的に壊れているので、ライブラリコードでは絶対に使うべきではないと思う。アプリコードで使わざるを得ない場合でも最後の手段であり、必ずスレッドを作る前にしか使ってはいけない。
getenv()は環境変数の元ポインタをそのまま返すので、setenv()が変数を上書きするときに何の保護もない。十分に注意すべきだ。
環境変数をきちんと設定するなら
execve()でセットするのが正しいと思う。exec()の前後でのみ環境変数として情報を渡す場合にだけ、この方式が適している。ライブラリコードでなぜ setenv を使う必要があるのか理解できない。
Solaris はこの問題を解決したが、Linux はいまだに同じ方式に固執している。
NetBSD には
getenv_r()という安全な代替手段が以前からあり、最近 FreeBSD でもこれを導入した。macOS もおそらくそう遠くないうちに続くだろう。
すでに glibc や POSIX に入れようとする試みはあったが、却下されていた。
さまざまなプラットフォームに広まれば、いつか正式にも受け入れられるのではと期待している。
NetBSD getenv_r ドキュメント
FreeBSD コミット
環境変数は秘密情報の受け渡しにしばしば使われるが、私はそれがあまり良い慣行だとは思わない。
Linux では、同じユーザーで実行されるすべてのプロセスが互いの環境変数をのぞき見できる。
脅威モデルをどう設定するにせよ、特に開発者のシステムでは同じユーザーで動いているプロセスが非常に多いため、気がかりだ。
この問題は、LLM エージェントのようにコンテナの外で複数のプロセスが動き回るとさらに深刻になる。
また環境変数は通常、そのまま子プロセスにも引き継がれるため、たった1つのプロセスだけが秘密情報を必要とする場合でも、無差別に露出しがちだ。
systemd は環境変数を DBUS 経由ですべてのシステムクライアントに見せており、公式ドキュメントにも環境変数に秘密を入れるなという警告がある。
これが本当なら、root 専用ユニットに設定した環境変数が一般ユーザーにも見える可能性があるということで、多くのシステム管理者にとってかなり衝撃的だと思う。
結局のところ、シークレットマネージャが一時ファイル共有方式で secret を渡す構造(例: 1Password の op cli、flask、terraform など)だけが、環境変数や平文ファイルの露出から逃れられる唯一の解決策だと考えている。
systemd の credentials システムがこの構造だ。ただし対応はまだ乏しい。
環境変数や平文ファイルなしで秘密を渡す良い方法を知っている人がいれば共有してほしい。
ちなみに 1Password op client の場合、セッションごとに自分の承認が必要なので、CLI セッションで使うぶんには安全だと感じる。悪意あるプロセスが op バイナリを呼び出したとしても、別途承認が必要になる。
そして残る問題は、その秘密を実際に必要なプロセスへどう渡すかで、結局また振り出しに戻る感じがする。
systemd 環境変数公式ドキュメントへのリンク
2012年ごろから、環境変数も通常メモリと同程度には安全になっている。
関連コミット履歴
他プロセスの環境変数を読むには ptrace 権限が必須であり、すでに ptrace で読めるなら実際にはあらゆる秘密情報を読めるのだから、意味のない心配だという主張だ。
コマンドライン情報(cmdline)は別の話だが、環境変数はこの方法ではもう簡単には漏れない。
ほとんどの OS のセキュリティモデルでは、1人のユーザーとして実行されるということは、そのユーザーのあらゆる権限を全面的に渡したのと同じ意味になる。
例外として FreeBSD の capsicum、Linux の landlock、SELinux、AppArmor、Windows の integrity label のような追加セキュリティ機能はあるが、たいてい限界がはっきりしている。
結局のところ、自分は自分のプロセスを kill したり停止したりデバッグしたりする自由があり、ptrace/process_vm_readv/ReadProcessMemory などによって、自分が所有するプロセスの secret にはいつでもアクセスできる。
完全に異なるセキュリティモデル(完全な capability ベース OS)も存在はするが、大多数はこのモデルに従っているので、その限界と責任を認識すべきだ。
環境変数や平文ファイルを使わずに秘密を渡す良い方法として、
memfd_secretが思い浮かぶ。memfd_secret man page
言語やフレームワークごとの対応が多くないので、特に Rust や、Go でも可能なら FFI 経由で試してみる価値がある。
PHP 側に直接ラップして入れることも考えたが、php-fpm まで改修したくはなくてやめたことがある。
実際には、プロセスマネージャがあらかじめ secret のファイルディスクリプタを開いて子プロセスへ渡し、メモリなどに露出させず使えるなら、それが最も安全だろう。
古典的な Unix のセキュリティモデルは多少改善されつつ今でも広く使われているが、安価なコンピューティング環境や最近の環境では限界がはっきりしている。
秘密を他のプロセスから隠す必要があるなら、そもそも別ユーザーに分離して動かすのが定石だ。
あるいは最初からリモートアクセス方式にする手もあるが、やはり欠点と複雑さが伴う。
最近では、コンテナプラットフォームでは config や secret を環境変数で渡すことが推奨される傾向がある。
コンテナ内では、他のプロセスが環境変数をのぞけないよう設計されている。
子プロセスに環境変数が継承されるのも設計上の意図であり、環境を構成する側が secret の値を持つ主体で、その環境自体も直接セットする構造だからだ。
心配されている問題の多くは大きな問題には見えず、必要なら具体的に議論する用意がある。
多くのコメントは秘密管理とその問題点ばかりに焦点を当てているが、環境変数の利点も一度は考えてみる価値がある。
環境変数は Unix プロセスを構造的につなぐ「無期限スコープの動的拡張変数バインディング」だ。
単純なテキストファイルと直接比べるのではなく、環境変数の存在理由が、子プロセスへ安全に情報を渡すためのコンテキスト伝達にあることを思い出すべきだ。
入れ子のシェルや複雑なプログラムのサブプロセスなど、プロセス構造が複雑になるほど環境変数の役割は光ると思う。
Varlock は本当に便利なので勧めたい。
プロジェクトで必要な環境変数について、必須/任意の別、データ型、どこから取得するかまで明確に定義でき、管理しやすい。
Varlock 公式サイト
実務経験から、環境変数がどれほど複雑になりうるかの例を挙げると、以前の会社で特定の ENV 変数がどこで設定されているのかをデバッグしようとして完全に迷子になったことがある。
最初は .bashrc かどこか単純な1か所で設定されていると思っていたが、会社全体、地域、事業部、チーム、個人など、少なくとも10層のレイヤーを経て環境変数が設定されていた。
結局、bash のデバッグフラグを有効にしてようやく、どこで設定されているのかを1つずつ追跡できた。
他の言語も対応しているかはわからないが、Node.js は環境変数へのアクセスと変更を正確に追跡するコマンドラインフラグを最近追加した。
Node.js --trace-env ドキュメント
数多くの API を通じて値が設定・変更されうるので、複雑なデバッグでは非常に有用そうだ。
「名前空間1つで十分ではないか」と言いたくなるケースだ。
私はかなり前に環境変数の使用をやめた。
今はコンパイラのそばに dmd.conf ファイルを置き、コンパイラがそのファイルを直接読むように運用している。
環境変数の問題で最も深刻なのは、その暗黙的で不透明な性質だ。
*nix の世界では、多くのアプリが環境変数に依存する傾向がある。
明示的で透明な設定方法(設定ファイル、remote サービス、コマンドライン引数)が追加でサポートされていたとしても、環境変数対応がこの世界の伝統だ。
結局、環境変数もグローバルなハッシュマップであり、子プロセスのために clone され拡張される構造で、1979年には合理的な設計だったのかもしれないが、今ではむしろ害になることが多い。
たとえば Kubernetes はデフォルトで「service link」環境変数によってコンテナ環境を汚染する。
アプリが想定した環境変数と default env var が衝突すると、デバッグは極めて難しくなる。
Kubernetes 公式ドキュメント参照
このほかにも、/bin、/usr/bin、/lib、/usr/lib のように古い枠組みを無批判に維持する慣行が本当に多いと感じる。
参考: legacy ディレクトリ維持に関する Ubuntu Q&A
vi における hjkl は 40年前の dumb terminal に由来しており、しかも販売台数の少ない端末だった。
(Nokia N9 よりも少なかった。)
Linux で環境変数を設定するたびに不安になる。
公式に動くやり方がディストリビューションごとに少しずつ違い、オンラインの手順どおりにやっても再起動したりターミナルを閉じたりすると全部消えてしまう。
Windows のように、全体で使える簡単な GUI の環境変数エディタがあればいいのにと思う。
Windows は変更反映のためにターミナルを開き直さなければならない面倒さはあるが、それ以外は常にうまく動く。
環境変数はセッションが変われば当然維持されないので、毎セッション再実行される場所(ログイン/ターミナルなど)に書いておくのが正しい。
ログイン時には .bash_profile が、子セッションでは .bashrc が実行される構造だ。
.bash_profile から .bashrc を source しておき、大半の設定は .bashrc に置けば管理しやすい。
Bash ではなく zsh/fish など別のシェルを使うなら、そのシェルに合わせる必要がある。
Linux には、あらゆるターミナルに公式かつ統一的に適用される環境変数 GUI は存在しない。
複雑なパースをする GUI を作ることもできるだろうが、普通のテキストエディタで書き換えるほうが簡単だ。
主に Linux を使う身としては、Windows の挙動のほうがむしろ不便に感じる。
あまりにも多くのアプリが環境変数を汚染するので、何かが動かないと結局
$SOFTWAREが妙なフォルダから実行されていた、のような混乱がよく起きる。systemd を使っているなら、
/etc/environmentや/etc/environment.d/にKEY=VALUEを書く方式も可能だ。実際、この方法のための GUI も作れそうだ。
ただし環境変数は実行中のプロセスには注入できず、反映には再起動が必要なので限界はある。
systemd 公式ドキュメント参照
xkcd Standards 漫画
Linux には環境変数の設定方法がすでに14種類も競合している状況で、「1つに統一しよう」と言えば、翌日には15番目の標準ができることを愉快に示している。
私がいちばん好きな環境変数トリビアは、誰もが当然環境変数だと思っている PS1 などが、実際には環境変数ではなく shell 変数だということだ。
PS1 は
envコマンドでも見られない。