はじめに
Linux サーバ上でシェルスクリプトを運用していると、こんな経験をすることがある。
$ source env.txt
$ echo $FM_SRC_ROOT
/var/www/html # ← 確かに見える
$ bash migration.sh
[NG] FM_SRC_ROOT が未設定です。先に env.txt を source してください
echo で見えているのに、子プロセスで消えている。
この症状、原因は source コマンドでも export でも bash でもなく、Windows の改行コード(CRLF) だった。
本記事では、CRLF が source をどう壊すかの仕組みと、nohup・cron・バックグラウンドジョブすべてで安全に動く実践パターンを紹介する。
症状の全体像
状況はこうだった。
- 環境変数をまとめた設定ファイル
env.txtを Windows の VSCode で編集していた - Linux サーバ上で
source env.txtしてからスクリプトを実行する運用だった - ある日、
bash migration.shを実行するとスクリプト内の変数チェックで弾かれた
エラー全文:
migration.sh: line 43: FM_SRC_ROOT: FM_SRC_ROOT が未設定です。
先に env.txt.prd または env.txt.stg を source してください
最初は「source し忘れ」を疑った。しかし source env.txt した直後でも再現する。
echo $FM_SRC_ROOT は値を返す。env | grep FM_SRC_ROOT にも出てくる。
なのに bash migration.sh の子プロセスでは消えている。
Part 1: 原因 — CRLF が bash を騙す仕組み
CRLF とは
テキストファイルの行末には「改行コード」が入っている。
| OS | 改行コード | 表記 |
|---|---|---|
| Linux / macOS | LF のみ | \n |
| Windows | CR + LF | \r\n |
Windows の VSCode や メモ帳で保存したファイルは、デフォルトで各行の末尾に \r(CR、キャリッジリターン)が入る。Linux で通常どおりに表示すると \r は不可視だが、bash はちゃんと読んでいる。
bash が source するとき何が起きるか
source env.txt は env.txt の中身をそのまま現在のシェルで評価する。行末に \r がある場合、以下のような行が:
export FM_SRC_ROOT="/var/www/html"
bash には実際にはこう見える:
export FM_SRC_ROOT="/var/www/html"\r
結果として、FM_SRC_ROOT の値は /var/www/html ではなく /var/www/html + \r(CR 文字)になる。
なぜ echo では見えるのか
CR(\r)はターミナルに「カーソルを行頭に戻せ」と指示する制御文字だ。
$ echo $FM_SRC_ROOT
/var/www/html # ← \r で行頭に戻ってから表示が終わる → 見た目は正常
値の末尾に \r があっても、echo の出力では CR がカーソルを行頭に戻すだけなので内容が表示される。値が存在しているように見えてしまう。
確認するには [ ] で囲む:
$ echo "[${FM_SRC_ROOT}]"
[/var/www/html # ← \r で行頭に戻り ] が最初に表示される
]/var/www/html # 実際にはこう見える(行頭に ] が上書きされる)
] が行頭に飛んでくる、あるいは表示が崩れたら CRLF 混入の証拠だ。
なぜ子プロセスで消えるのか
\r が混入した変数でも export 自体は成功することが多い。しかし問題は 使われるとき に起きる。
スクリプト内でよく使われるパターン:
: "${FM_SRC_ROOT:?FM_SRC_ROOT が未設定です}"
この :? 演算子は変数が「空文字列か未設定」のときにエラーを出す。値が /var/www/html\r の場合は空ではないので通過する。
しかし、次のようなパスチェックで失敗する:
[ -d "${FM_SRC_ROOT}" ] # /var/www/html\r というパスは存在しない
また \r の混入した変数値を別変数と比較すると:
if [ "${FM_SRC_ROOT}" = "/var/www/html" ]; then # 一致しない(\r が余分)
export そのものは通っているが、変数の値が壊れているため正常に使えない。
bash の実装やバージョンによっては、\r を含む変数名の export が無効になり、子プロセスへの継承自体が失敗するケースもある。いずれにせよ「echo で見える・子プロセスで使えない」という症状として現れる。
Part 2: 診断コマンド
Step 1: ファイルの CRLF を確認
cat -A env.txt | head -10
^M$ が行末に見えたら CRLF(^M が CR、$ が LF の位置)。
export FM_SRC_ROOT="/var/www/html"^M$ # CRLF あり ← これが問題
export FM_SRC_ROOT="/var/www/html"$ # LF のみ ← 正常
file コマンドでも確認できる:
file env.txt
# CRLF → env.txt: ASCII text, with CRLF line terminators
# LF → env.txt: ASCII text
Step 2: source 後に変数値を確認
source env.txt
printf '[%s]\n' "${FM_SRC_ROOT}"
正常なら [/var/www/html] と表示される。
CRLF 混入なら ] が行頭に飛ぶか表示が崩れる。
echo ではなく printf '%s\n' を使うと、\r を含む値でも改行前に内容が確認できる:
printf 'FM_SRC_ROOT=[%s]\n' "${FM_SRC_ROOT}" | cat -A
# FM_SRC_ROOT=[/var/www/html^M]$ ← ^M(CR)が見える
Step 3: 子プロセスへの継承を確認
source env.txt
bash -c 'printf "child: [%s]\n" "${FM_SRC_ROOT}" | cat -A'
親で \r 込みの値が export されていれば、子プロセスでも同じ壊れた値が見える。
Part 3: 修正 — sed をかませた source
基本パターン
# NG: 生 source(CRLF がそのまま混入)
source env.txt
# OK: \r を除去してから source
source <(sed 's/\r//' env.txt)
<(コマンド) は「プロセス置換」と呼ばれる bash の機能で、コマンドの出力を一時的なファイルディスクリプタとして扱う。sed 's/\r//' で全行の \r を除去してから source に渡すことで、CRLF の影響を受けない。
除去確認
source <(sed 's/\r//' env.txt)
printf 'FM_SRC_ROOT=[%s]\n' "${FM_SRC_ROOT}" | cat -A
# FM_SRC_ROOT=[/var/www/html]$ ← ^M が消えた
nohup・cron・バックグラウンドでも使える
source <(sed ...) は通常の source と同様にシェル組み込みコマンドなので、以下すべての呼び出し形式で有効だ:
# 対話シェルから source 後に実行
source <(sed 's/\r//' env.txt)
bash migration.sh
# nohup で長時間実行
source <(sed 's/\r//' env.txt)
nohup bash migration.sh > migration.log 2>&1 &
# cron(ただし外部 source には別の問題がある → Part 4 参照)
Part 4: 実践パターン — スクリプト内部で自動ロード
「外部 source 必須」運用の問題点
source env.txt && bash migration.sh という二段階の運用には落とし穴がある。
-
cron で
sourceが実行されない
cron は非対話シェルで起動するため.bashrcや.bash_profileを読まない。source env.txtを cron のコマンドに含めても、source自体がシェル組み込みなので動作しないケースがある -
nohup から呼び出す wrapper スクリプトで継承が切れる
親スクリプトが子スクリプトをbash child.shで呼ぶと、export されていない変数は引き継がれない(export があっても\r混入で実用上機能しないことがある) - 手順が複雑でヒューマンエラーが起きやすい
解決策:スクリプト内部に env ロードを組み込む
スクリプトの冒頭で「必要な変数が未設定のときだけ env ファイルを探してロードする」パターンに変える。nohup・cron・バックグラウンドジョブ・直接実行、どの呼び出し方でも動く。
#!/bin/bash
# =============================================================
# 環境変数自動ロード
# FM_SRC_ROOT が未設定の場合のみ実行。
# source <(sed 's/\r//') で Windows CRLF も同時に除去する。
# =============================================================
if [ -z "${FM_SRC_ROOT:-}" ]; then
_SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)"
_ENV_LOADED=0
# 引数で env ファイルを明示指定した場合
if [ -n "$1" ]; then
_env_file="$1"
# 相対パスは SCRIPT_DIR 基準に変換
[[ "${_env_file}" != /* ]] && _env_file="${_SCRIPT_DIR}/${_env_file}"
if [ -f "${_env_file}" ]; then
source <(sed 's/\r//' "${_env_file}")
_ENV_LOADED=1
else
echo "[NG] 環境変数ファイルが見つかりません: ${_env_file}" >&2
exit 1
fi
else
# 引数なし → スクリプトと同じディレクトリから自動検出(prd → stg の順)
for _suffix in prd stg; do
_env_file="${_SCRIPT_DIR}/env.txt.${_suffix}"
if [ -f "${_env_file}" ]; then
source <(sed 's/\r//' "${_env_file}")
_ENV_LOADED=1
break
fi
done
fi
if [ "${_ENV_LOADED}" -eq 0 ]; then
echo "[NG] 環境変数ファイルが見つかりません" >&2
echo " env.txt.prd または env.txt.stg を配置してください" >&2
exit 1
fi
fi
# 変数チェック(ここまで来れば必ず設定済み)
: "${FM_SRC_ROOT:?FM_SRC_ROOT が未設定です}"
ポイント解説
① [ -z "${FM_SRC_ROOT:-}" ] で二重ロードを防ぐ
このガードにより、親スクリプトが既に source 済みの場合は env ファイルの読み込みをスキップする。親が export した値をそのまま引き継ぐ。
② BASH_SOURCE[0] でスクリプトの実際の場所を取得
$0 では nohup や source 経由での呼び出し時にパスが変わることがある。BASH_SOURCE[0] を使うとスクリプトの実ファイルパスが取れる。フォールバックとして :-$0 も指定している。
③ source <(sed 's/\r//') で CRLF を除去しつつロード
ファイルを直接 source するのではなく、sed でフィルタリングしてから渡す。Windows で編集された env ファイルを LF に変換せずそのまま使える。
④ 引数指定と自動検出の両立
# 引数指定(明示的・推奨)
bash migration.sh env.txt.prd
# 引数なし(スクリプトと同じディレクトリの env ファイルを自動検出)
bash migration.sh
nohup bash migration.sh > migration.log 2>&1 &
# cron
0 3 * * * cd /path/to/script && bash migration.sh >> /var/log/migration.log 2>&1
どの形式でも env ファイルをロードして実行できる。
Part 5: cron での認証エラーも同じ根本原因
cron 経由でスクリプトを実行すると、gcloud コマンドが command not found になるケースがある。これも CRLF とは別の「非対話シェルでの PATH 継承切れ」問題だが、構造は同じだ。
症状
/bin/bash: gcloud: command not found
exit=127
cron のシェルは非対話モードで起動するため、.bashrc や .bash_profile が読み込まれない。これらのファイルに書かれた PATH 設定(例:Cloud SDK のパス追加)が有効にならない。
解決策:env ファイルに PATH を明示
env ファイルに PATH を書いておき、スクリプト内部の自動ロードで引き込む:
# env.txt.prd に追加
export PATH="/opt/google-cloud-sdk/bin:${PATH}"
# スクリプト内部の自動ロード(CRLF 除去込み)で PATH も一緒にロードされる
source <(sed 's/\r//' "${_env_file}")
env ファイルに export PATH=... を書いておくことで、gcloud の PATH 設定も cron・nohup・バックグラウンドジョブすべてで有効になる。
サービスアカウント認証も env ファイルで管理
対話的な gcloud auth login は cron から使えない。サービスアカウントキーを使う場合:
# env.txt.prd に追加
export GOOGLE_APPLICATION_CREDENTIALS="/path/to/service-account-key.json"
これも source <(sed 's/\r//') でロードすれば cron から問題なく動く。
Part 6: まとめ
症状・原因・対処の対応表
| 症状 | 原因 | 対処 |
|---|---|---|
echo $VAR で見える・子プロセスで消える |
CRLF で \r が値末尾に混入 |
source <(sed 's/\r//' file) |
| パスが存在しない・条件分岐がおかしい | 変数値に \r が付いている |
上に同じ |
source && bash script でも変数未設定 |
\r で export が実質無効 |
上に同じ |
| cron から実行すると変数が全部未設定 | 非対話シェルは .bashrc を読まない |
env ファイルをスクリプト内でロード |
cron から gcloud: not found
|
PATH が非対話シェルに引き継がれない | env ファイルに export PATH=...
|
CRLF を除去する他の手段
| 方法 | コマンド | 用途 |
|---|---|---|
| source 時に除去 | source <(sed 's/\r//' file) |
ファイルを変換せず使う |
| ファイル自体を変換 | sed -i 's/\r//' file |
Linux 側で一度だけ変換 |
| dos2unix コマンド | dos2unix file |
専用ツール(要インストール) |
| エディタ設定 | VSCode: 右下の改行コードを LF に変更 |
Windows 側で根本解決 |
ベストプラクティス
-
env ファイルの変換は行わず
source <(sed ...)で対応する
ファイル自体を変換すると、次回 Windows で上書きしたときにまた CRLF に戻る。スクリプト側で吸収するほうが継続的に安全。 -
スクリプトに env ロードを内包する
source+bash script.shの二段階運用は手順が増えてヒューマンエラーを招く。スクリプト自身に自動ロードを持たせれば呼び出し方を問わない。 -
cat -Aで CRLF を定期確認する
Windows と Linux の共同作業では CRLF 混入がいつ起きてもおかしくない。CI でfile *.sh | grep CRLFを走らせると早期発見できる。
おわりに
echo で見えているのに子プロセスで消える——この症状は、原因を知らないと何時間でも迷子になる。\r という 1 バイトが source の動作を壊し、無害に見える echo の出力が診断の邪魔をする。
最終的にたどり着いたのは source <(sed 's/\r//') という一行の改修だったが、それ以上に重要だったのは「スクリプト自身が env ファイルをロードする」設計への転換だった。nohup・cron・バックグラウンドジョブ、どこから呼ばれても同じように動く——そのための投資としては安い変更だった。
Windows と Linux を行き来する環境で似た症状に遭遇したとき、この記事が最初の手がかりになれば幸いだ。