メインコンテンツまでスキップ

シェルスクリプトと環境変数 — export とプロセス継承

「環境変数を設定したのにアプリに届かない」。シェルスクリプトでよく踏む問題です。

原因はたいてい 1 つで、変数を設定しただけで、子プロセスへ渡す設定をしていないことです。この違いを押さえると、設定が届かない問題の切り分けが速くなります。

この章で学ぶこと

  • シェル変数と環境変数の違い
  • export が何をしているか
  • env コマンドの正しい使い方
  • チルダ展開が何に展開されるか
  • スクリプトの実行方法の使い分け
前提知識

シェルの基本 のコマンド実行を前提にします。

シェル変数と環境変数

シェルで NAME=value と書くと変数が作られます。ただしこれはそのシェルの中だけの変数です。

APP_LOG_LEVEL=INFO # シェル変数。このシェルからしか見えない
export APP_LOG_LEVEL # 環境変数に昇格。子プロセスへ引き継がれる
シェル変数環境変数
作り方NAME=valueexport NAME=value
見える範囲そのシェルのみそのシェルとすべての子プロセス
一覧setenv または printenv

実際に確かめる

親子 2 つのスクリプトで挙動を確認します。

# child.sh
#!/usr/bin/env bash
echo "child sees: [${APP_LOG_LEVEL:-unset}]"
# parent.sh
#!/usr/bin/env bash
APP_LOG_LEVEL=INFO
./child.sh # → child sees: [unset]
export APP_LOG_LEVEL
./child.sh # → child sees: [INFO]

実行すると次のようになります。

child sees: [unset]
child sees: [INFO]

**export する前は子プロセスにまったく届きません。**親のシェルの中では echo $APP_LOG_LEVELINFO が出るので、設定できているように見えてしまいます。この見え方のずれが混乱の元です。

export が行うのは「この変数を、子プロセスを起動するときに渡す環境に含める」という指定です。値のコピーが子へ渡るので、子で書き換えても親には戻りません。コピーになるのは、子が別のプロセスとして作られるためです (プロセスとスレッド)。

書き方の注意

APP_LOG_LEVEL=INFO # 正しい
export APP_LOG_LEVEL=INFO # 正しい。代入と export を同時に行う
export APP_LOG_LEVEL # 正しい。既存の変数を環境変数へ昇格させる

$APP_LOG_LEVEL=INFO # 誤り。$ は「値を取り出す」記号
APP_LOG_LEVEL = INFO # 誤り。= の前後に空白を入れられない

$値を参照するときに付けます。代入するときは付けません。$APP_LOG_LEVEL=INFO は「APP_LOG_LEVEL の現在の値」をコマンド名として実行しようとするので、意図した動作になりません。

= の前後に空白を入れられないのも、シェル特有の制約です。空白があると APP_LOG_LEVEL というコマンドに =INFO を引数として渡す形に解釈されます。

env コマンド

env は名前から「環境変数を設定するコマンド」と思われがちですが、実際の役割は 2 つです。

env # 環境変数の一覧を表示する
env NAME=value command # 一時的に環境変数を足してコマンドを実行する
# この実行のときだけ APP_LOG_LEVEL を DEBUG にする
env APP_LOG_LEVEL=DEBUG ./serve

変数名だけを渡しても意味がありません。

env APP_LOG_LEVEL
# → env: APP_LOG_LEVEL: No such file or directory

env は「NAME=value の形をしていない引数」を実行するコマンド名として扱います。APP_LOG_LEVEL という名前のプログラムを探しに行き、見つからずに終わります。変数を環境変数にしたいなら export を使います。

チルダ展開

~ はホームディレクトリに展開されます。

echo ~/srv/worker
# → /Users/yourname/srv/worker

**/srv/worker にはなりません。**先頭にホームディレクトリのパスが付きます。

書き方展開先
~/srv/worker$HOME/srv/worker (例: /Users/name/srv/worker)
/srv/worker/srv/worker (ルート直下)

システムのディレクトリを指したいのに ~ を付けてしまうと、存在しないパスになります。cd が失敗しても、後続の処理はそのまま走るので気づきにくい形の不具合になります。

**スクリプトがどこで実行されるか分からない場合は、絶対パスを使います。**相対パスは実行時のカレントディレクトリに依存するので、cron や CI から呼ばれると意図した場所を指しません。

スクリプトの実行方法

同じスクリプトでも、呼び方によって動きが変わります。

書き方何が起きるか変数やカレントディレクトリの変更
./script.sh新しいプロセスで実行する呼び出し元に残らない
bash script.sh同上 (実行権限が無くても動く)残らない
. script.sh / source script.sh現在のシェルの中で実行する残る

. (ドット) と ./ は見た目が似ていますが別物です。

./setup.sh # 子プロセスで実行。setup.sh 内の export は親に残らない
. setup.sh # 現在のシェルで実行。export した変数がそのまま使える

環境変数を現在のシェルに読み込みたいときは source を使います。.env を読み込む用途がこれです。逆に、独立したプログラムとして動かしたいなら ./ です。

この 2 つを取り違えると、動くはずのものが動かなくなります。./serve と書くべきところを . serve にすると、バイナリを現在のシェルがスクリプトとみなして読み込もうとし、失敗します。

シバン

スクリプトの 1 行目に書く #! で始まる行をシバンと呼び、どのインタプリタで実行するかを指定します。

#!/usr/bin/env bash # PATH から bash を探して実行する
#!/bin/bash # このパスの bash で実行する

/usr/bin/env を経由する形が広く使われます。bash の場所が環境によって違っても (/bin/bash/usr/local/bin/bash か)、PATH から探してくれるためです。

シバンが効くのは ./script.sh のように直接実行したときです。bash script.sh と明示的に指定した場合は、シバンの内容に関係なくそのインタプリタで動きます。

安全に書くための定型

スクリプトの冒頭に置いておく設定があります。

#!/usr/bin/env bash
set -euo pipefail
オプション効果
-eコマンドが失敗したらそこで終了する
-u未定義の変数を参照したらエラーにする
-o pipefailパイプの途中が失敗したら全体を失敗とする

既定のシェルは、コマンドが失敗しても次の行へ進みます。cd に失敗したまま rm を実行する、といった事故が起きます。set -e はそれを止めます。

-u は変数名の打ち間違いを検出します。既定では未定義の変数は空文字として扱われるので、rm -rf "$TAGET_DIR/" のような打ち間違いが rm -rf / になりえます。失敗をどう分類して扱うかはエラーハンドリングで扱います。

よくある誤解

NAME=value と書けば環境変数になる」 — そのシェルの中だけの変数です。子プロセスへ渡すには export が要ります。

$NAME=value で代入できる」 — できません。$ は値を参照する記号です。代入時には付けません。

. script.sh./script.sh は同じ」 — 違います。前者は現在のシェルで実行し、後者は新しいプロセスで実行します。

「子プロセスで環境変数を変えれば親にも反映される」 — されません。渡るのはコピーで、方向は親から子への一方向です。

確認問題

問 1. サーバーの起動スクリプトでログレベルを設定したのに、アプリは既定のレベルで動いています。原因の切り分け手順を答えてください。
#!/usr/bin/env bash
APP_LOG_LEVEL=INFO
/srv/worker/serve

答え: export が無いので、APP_LOG_LEVEL はシェル変数のままで子プロセスに届いていません。

切り分けは 2 段階で行います。

1. スクリプトの中では見えているかを確認する

APP_LOG_LEVEL=INFO
echo "shell var: $APP_LOG_LEVEL" # → INFO と出る
env | grep APP_LOG_LEVEL # → 何も出ない

echo では見えるのに env の一覧に出てこないなら、シェル変数どまりです。この差が「設定したのに届かない」の正体です。

2. 子プロセスに渡す

export APP_LOG_LEVEL=INFO
/srv/worker/serve

export は「この変数を、子プロセスを起動するときの環境に含める」という指定です。これが無い変数は、そのシェルの中だけで完結します。

なお、この 1 回の実行にだけ効かせたいなら次の書き方もできます。

APP_LOG_LEVEL=INFO /srv/worker/serve # コマンドの直前に置く
env APP_LOG_LEVEL=INFO /srv/worker/serve

どちらも NAME=value の形になっている点が重要です。env APP_LOG_LEVEL のように変数名だけを渡すと、env はそれをコマンド名として探しに行きます。

問 2. .env に書いた環境変数を現在のシェルに読み込みたいです。どう実行しますか。

答え: source (または .) を使います。

# 正しい: 現在のシェルで実行するので export した変数が残る
source .env
. .env

# 誤り: 子プロセスで実行するので、終わると同時に消える
./.env
bash .env

./.env のように子プロセスで実行すると、その中で export しても効果があるのはその子プロセスの中だけです。プロセスが終われば環境ごと消えます。

環境変数の継承は親から子への一方向なので、子で設定したものを親へ戻す方法はありません。だから現在のシェルの中で実行する source が要ります。

なお .envKEY=value だけが並んでいて export が付いていない場合、source してもシェル変数どまりで環境変数にはなりません。この場合は次のようにします。

set -a # 以降の代入を自動的に export する
source .env
set +a # 元に戻す
問 3. cron から実行したスクリプトが「コマンドが見つかりません」で失敗します。手元では動きます。原因として何が考えられますか。

答え: cron の環境変数 (とくに PATH) が、対話シェルと違うためです。

対話的にログインしたシェルは .bashrc.zshrc を読み込むので、PATH に多くのディレクトリが入っています。cron はそれらを読み込まないので、PATH は最小限です。

確認と対処の順序は次のとおりです。

# 1. cron から見える環境を実際に出力させる
* * * * * env > /tmp/cron-env.txt

# 2. スクリプト側で絶対パスを使う
/usr/local/bin/node /opt/app/script.js

# 3. または PATH をスクリプト内で明示する
export PATH=/usr/local/bin:/usr/bin:/bin

同じ理由で、**カレントディレクトリも期待どおりとは限りません。**cron の実行開始位置はホームディレクトリなどになるので、相対パスで書いたファイル参照が外れます。

原則は 2 つです。**実行環境が分からない場所から呼ばれるスクリプトでは、パスを絶対で書き、必要な環境変数を自分で設定する。**手元で動くことは、別の環境で動く根拠になりません。

まとめ

  • NAME=valueシェル変数で、そのシェルの中だけです。子プロセスへ渡すには export が要ります
  • 環境変数の継承は親から子への一方向です。子で変えても親には戻りません
  • env NAME は変数を環境変数にしません。NAME=value の形でない引数はコマンド名として扱われます
  • 代入に $ は付けません。= の前後に空白も入れられません
  • ~ はホームディレクトリに展開されます。/srv などシステムのディレクトリは絶対パスで書きます
  • ./script.sh は新しいプロセス、. script.sh は現在のシェルで実行します
  • 実行場所が分からないスクリプトでは、絶対パスと明示的な環境設定を使います
  • set -euo pipefail を冒頭に置くと、失敗や打ち間違いを早く止められます
関連リファレンス

次に読む