📚 連載:Ansibleが理解できない理由はLinuxにあった【Shell編】第9回:なぜ変数が意図通りに展開されないのか(Quoting & Expansion)
※Shellの仕組みからAnsibleを理解するシリーズです。
① なぜShellを理解しないとAnsibleは使えないのか
② なぜAnsibleで出力が取得できないのか
③ なぜ結果が消えるのか
④ なぜgrepで見つからないのか
⑤ なぜ正規表現で壊れるのか
⑥ なぜ環境が違うのか
⑦ なぜ条件分岐が失敗するのか
⑧ なぜループがうまく動かないのか
⑨ なぜ途中で処理が止まるのか
⑩ なぜ変数が意図通りに展開されないのか
⑪ なぜAnsibleの挙動が読めるようになるのか
🗺️ 初めての方・シリーズの全体像を知りたい方はこちら
本記事は、OSの仕組みからAnsible設計までを繋ぐ連載シリーズの一部です。
「どこから読み始めればいいか」あるいは、「OS/Shell/Ansible編の関係性」 について把握されたい場合は、以下の統合ガイドで整理しています。
📑 【Shell編】全体のまとめはこちら
→ Ansibleが理解できない理由はLinuxにあった【Shell編】まとめ
📋 目次
- 前回の振り返り(第8回)
- 逆引き辞典との連動
- 問題定義:変数が「空」になる、または「二重展開」で壊れる現象
- なぜ展開されないのか:二重評価のシーケンス
- 【実機検証】変数の再評価が引き起こす「SQL構文エラー」の再現
- デバッグ技術:Ansibleが「送出した直後」の姿を正確に捉える
- 解決策:適切な変数展開と引用符の自動処理
- まとめ:意図通りの値を届けるための設計指針
- 次回予告
- 連載一覧:Ansibleが理解できない理由はLinuxにあった【Shell編】
※本記事の位置とシリーズ全体の関係を先に確認します。
シリーズ全体構造(学習 × 問題解決)
本シリーズは
「理解(各記事)」と「問題解決(逆引き辞典)」を組み合わせて
スキルを身につける構成になっています。
この図は、どこから学び、どこに進めばよいかを示した“ロードマップ”です。
📍 現在の位置
現在はこの図の「Shell編の第9回」になります。
📍 はじめに:この記事のスタンス
前回の第8回では、リモートノードで動作するShellプロセスのエラー伝播ルール(set -e 等)が、Ansibleのタスク実行継続に及ぼす影響について学びました。
実行プロセスの制御が可能になった次に課題となるのが、「送出した変数の値が、実行時に意図せず変質する」 という事象です。
-
Ansibleで定義した変数が、Shellコマンド内では「未定義(空)」として扱われる。
-
変数の中に引用符(
'等)が含まれている場合、第4回の「引用符で囲む」という基本ルールを適用しても構文エラーが発生する。 -
OSの環境変数を参照した際、期待とは異なるタイミングで評価が行われる。
これらは設定上の単純なミスではなく、「Ansible(Jinja2)とリモートShellによる、変数評価タイミングの乖離」 に起因します。
本記事では、変数が「いつ、どのレイヤーで」確定するのか、その評価シーケンスを整理します。Ansibleから値を意図通りにターゲットノードへ到達させるための技術的な考え方について解説します。
1. 前回の振り返り(第8回)
前回は、Shellプロセスの終了ステータスがAnsibleへ伝達される仕組みを学習しました。
今回は、そのプロセス内における「変数」というデータの受け渡しに注目します。どの段階でデータが変質しうるのか、その「内部挙動」に焦点を当てて検証を進めます。
2. 逆引き辞典との連動
具体的なエラー名から原因を特定したい場合は、以下の逆引き辞典を活用してください。
【保存版】Ansibleよくあるエラー一覧と原因まとめ(Shell編)
- まず「逆引き辞典」で該当ステップを特定する
- 次に「本編(各連載記事)」で仕組みを理解する
このサイクルが、OS編から続くトラブル解決の最短ルートです。
3. 問題定義:変数が「空」になる、または「二重展開」で壊れる現象
以前の回で「引用符で囲むことでリテラルとして扱う」手法を学びましたが、変数そのものを動的に扱う場合は、以下の2つの技術的課題が発生します。
-
評価主体の混同
「Ansibleで定義した変数を、Shell変数の形式($var)で参照できる」という誤解です。Ansible変数は実行前に制御ノード側で展開されるため、実行時のターゲットノード上のShell変数とは管理体系が異なります。 -
引用符の干渉による構造破壊
Ansible変数の値自体に、Shellが解釈可能な記号(引用符など)が含まれている場合、単純な外側への引用符付与では実行時コマンドの整合性が保てません。
【技術的検証:SQLの条件指定における不整合】
ここでは、実機検証で使用する「不適切なクォート処理」の構成を定義します。
vars:
# 変数の値自体にダブルクォート(")が含まれている状態
search_filter: 'name="admin_user"'
tasks:
- name: 引用符の付与のみでは不十分な例
# 外側のダブルクォートと、展開される変数内のダブルクォートが重複する記述
ansible.builtin.shell: mysql -D test_db -e "SELECT * FROM users WHERE {{ search_filter }}"
- 記述上の注意点
このPlaybookの記述は、静的なテキストとしては"SELECT ... WHERE name="admin_user""と展開されます。しかし、実行時には 「外側の引用符」と「変数内の引用符」が同一行内で衝突 し、OSのShell解釈においてコマンド構造が破壊されます。
次の章では、この文字列がターゲットノードへ渡った後、どのように変質してエラーに至るのか、そのプロセスを詳細に分析します。
4. なぜ展開されないのか:二重評価のシーケンス
Ansibleからコマンドを実行する際、変数は「二段階の評価」を経て実行されます。このバトンタッチの過程で生じる「解釈のズレ」を、SQL文を省略せずに追ってみましょう。
※図では流れを簡略化しています。実際の文字列の変化は後述します
💡 変数展開のメカニズム:評価のバトンタッチ
🔍 ステップ別の詳細解説
Step A:Ansible側での「構築」
ここでは、Jinja2テンプレートエンジンが、単なる 「文字列の置き換え」 を行います。
-
記述:
shell: mysql -e "SELECT * FROM users WHERE {{ search_filter }}" -
変数:
search_filter = 'name="admin_user"' -
結果:
mysql -e "SELECT * FROM users WHERE name="admin_user""
ここがポイント:
Ansibleはこの時点でSQLの構文や引用符の整合性はチェックしません。{{ search_filter }}の位置に、変数の内容であるname="admin_user"を機械的に埋め込んだ文字列を生成し、そのままターゲットノードへ送信します。
Step B:リモートShell側での「解釈(衝突)」
受信した文字列を、リモートのShellが「実行可能なコマンド」として解析(パース)します。今回のテスト結果で起きた 「引用符の剥離(Quote Removal)」 のプロセスがここです。
-
受信した文字列:
mysql -e "SELECT * FROM users WHERE name="admin_user"" -
Shellの誤認プロセス:
- 最初にある
"を「引数の開始」と認識します。 -
SELECT * FROM users WHERE name=まで読み進めたところで、変数内に含まれていた"に到達します。 - Shellはこれを「開始の
"を閉じるペア」だと解釈し、ここで一旦クォートの範囲を終了させます。 - その結果、本来「値」であるはずの
admin_userが、クォートの外側に露出してしまいます。
💡 実際に起きた「引数の分断と変質」
Shellによる解釈が終わった段階で、実際に mysql プロセスへ渡される引数は以下のように変質します。
-
構築時(期待):
"SELECT * FROM users WHERE name="admin_user"" -
実行時(現実):
SELECT * FROM users WHERE name=admin_user
最終結果: Shellが「自分のための囲み」だと誤認した引用符をすべて取り除いた(剥離した)結果、MySQLにはクォートのない name=admin_user が渡されました。MySQLは admin_user を文字列ではなく「カラム名(列名)」として探そうとし、構文エラーで終了します。
5. 【実機検証】変数の再評価が引き起こす「SQL構文エラー」の再現
第3セクションで提示した「ダブルクォートを含む変数」を用い、実際にターゲットノードで mysql コマンドを実行します。Ansible側の ignore_errors を活用し、リモートのMySQL側から返される生の応答を直接観測します。
① 検証準備
実行するPlaybook(test_quoting_fail.yml)
第3セクションのタスクをそのまま再現し、その実行結果を register で詳細に取得します。
- name: 第9回:実機検証(第3・4セクションの失敗ケース完全再現)
hosts: test_servers
become: yes
vars:
# 第3セクションの定義そのまま
search_filter: 'name="admin_user"'
tasks:
- name: "検証:囲むだけでは不十分な例"
ansible.builtin.shell: mysql -D test_db -e "SELECT * FROM users WHERE {{ search_filter }}"
ignore_errors: yes
register: mysql_result
- name: 実行結果の分析
ansible.builtin.debug:
msg:
- "1. Ansibleが構築したコマンドライン: {{ mysql_result.cmd }}"
- "2. 終了ステータス (rc): {{ mysql_result.rc }}"
実行コマンド
ansible-playbook -i inventory.ini test_quoting_fail.yml -K
② 検証結果
(コントローラーノード側:Ansibleの出力)
(ansible) [ansible@localhost workspace]$ ansible-playbook -i inventory.ini test_quoting_fail.yml -K
BECOME password:
PLAY [第9回:実機検証(第3・4セクションの失敗ケース完全再現)] ******************************************************************************************************************************
TASK [Gathering Facts] **********************************************************************************************************************************************************************
ok: [192.168.1.21]
TASK [検証:囲むだけでは不十分な例] *********************************************************************************************************************************************************
fatal: [192.168.1.21]: FAILED! => {"changed": true, "cmd": "mysql -D test_db -e \"SELECT * FROM users WHERE name=\"admin_user\"\"", "delta": "0:00:00.032996", "end": "2026-05-11 11:02:59.973583", "msg": "non-zero return code", "rc": 1, "start": "2026-05-11 11:02:59.940587", "stderr": "--------------\nSELECT * FROM users WHERE name=admin_user\n--------------\n\nERROR 1054 (42S22) at line 1: Unknown column 'admin_user' in 'where clause'", "stderr_lines": ["--------------", "SELECT * FROM users WHERE name=admin_user", "--------------", "", "ERROR 1054 (42S22) at line 1: Unknown column 'admin_user' in 'where clause'"], "stdout": "", "stdout_lines": []}
...ignoring
TASK [実行結果の分析] ***********************************************************************************************************************************************************************
ok: [192.168.1.21] => {
"msg": [
"1. Ansibleが構築したコマンドライン: mysql -D test_db -e \"SELECT * FROM users WHERE name=\"admin_user\"\"",
"2. 終了ステータス (rc): 1"
]
}
PLAY RECAP *********************************************************************************************************
ログの読み解きと考察:なぜ「正しく構築」されたのに失敗するのか
Ansibleの実行ログを確認すると、タスク自体は changed となっていますが、最終的に rc: 1(異常終了)で失敗していることがわかります。ここから、Ansible側の処理とリモートノード側の解釈の乖離を分析します。
-
Ansible側の「構築」プロセス
ログを見ると、以下の通り出力されています。
mysql -D test_db -e "SELECT * FROM users WHERE name="admin_user""
Ansible(Jinja2)は、定義された変数'name="admin_user"'をそのまま埋め込んでおり、この時点ではSQL文としての体裁を保っています。 -
リモートShell側の「再解釈」プロセス
次に、エラー内容(stderr)を確認します。実際にMySQLへ渡されたクエリは以下の通り変質しています。
SELECT * FROM users WHERE name=admin_user
これは、リモートノードのShellがコマンドを実行する際、-eの直後にある"と、変数内に含まれるname="の"を「ペア」として認識したために発生します。この「引用符の剥離(Quote Removal)」の結果、admin_userを囲んでいたダブルクォートが消滅した状態でMySQLプロセスへ渡されました。
テスト結果のまとめ
今回の実機検証により、以下の技術的挙動が確認されました。
-
構文エラーの発生: 引用符が剥がれたことで、MySQLは
admin_userを文字列(値)ではなく、存在しない「カラム名(列名)」として解釈し、Unknown column 'admin_user'エラーを返しました。 -
不十分なエスケープ処理: 単純に変数をダブルクォートで囲むだけでは、変数自体に含まれる引用符と干渉し、実行時にコマンド構造が破壊されるリスクがあることが実証されました。
-
二段階評価の影響: Ansibleによる「変数の展開」が正常であっても、その後の「Shellによる解釈」の段階で意図しない挙動に繋がるため、単一のクォート処理では不十分であることが結論付けられます。
6. デバッグ技術:Ansibleが「送出した直後」の姿を正確に捉える
第5セクションで確認したエラーの原因を、自分自身で調査・特定するためには、「Ansibleが値を埋め込み、リモートに送り出した直後の生コマンド」を正確に把握するスキルが不可欠です。
🔍 -vvv オプションによる詳細ログの出力
Ansibleの実行時に -vvv フラグを付与することで、ネットワーク越しに転送される生の実行命令を確認できます。
ansible-playbook test_quoting_fail.yml -vvv
📋 実行ログ(JSON)の正確な読み解き方
詳細ログの中から、対象タスクの実行結果に含まれる "cmd": 項目を探します。ここが、リモートShellへ引き渡された「最終形態」です。
今回の検証結果に基づき、ログから何を読み取るべきかを整理します。
-
JSONエスケープの考慮
ログ上の"cmd"に表示される内容は、JSON形式としてエスケープされた表現です。
例:"cmd": "mysql -e \"SELECT ...\""
バックスラッシュ(\)でエスケープされた\"は、実際のShell上では単なる"として扱われます。ログ上の見え方と実際のコマンドを混同しないよう注意が必要です。 -
「不完全な展開」の判別
もし"cmd"の中に以下のような記述が残っていた場合、それは第4セクションで解説した 「Step A(Ansible側での展開)」が正常に行われていない ことを示します。
・$search_filterが残っている: Ansible変数ではなく、Shell変数として記述してしまっている。
・{{ search_filter }}が残っている: テンプレートの記述ミスにより、Jinja2が置換をスキップしている。 -
「引用符の整合性」の確認
今回の検証でrc: 1となったケースのように、Jinja2による置換自体は完了していても、「Shellがどこまでを一塊の引数と見なすか」のパースに失敗していることがあります。
ログの"cmd"内で、外側のクォートと変数内のクォートがどのように配置されているかを確認することで、第4セクションで図解した「引用符の衝突(剥離)」が起きているポイントを特定できます。
結論:ログから「評価の境界線」を見極める
デバッグの際、単に「エラーが出た」で終わらせず、この "cmd" の内容を精査することで、問題が 「Ansibleの置換ミス(Step A)」 なのか、それとも 「Shellによる再評価の失敗(Step B)」 なのかを明確に切り分けることが可能になります。
7. 解決策:適切な変数展開と引用符の自動処理
実機検証で確認した「引用符の衝突」や「変数の未展開」を防ぎ、意図した通りにコマンドを実行するための重要な原則を整理します。
1. | quote フィルタによる自動エスケープ
第3・4セクションで確認した通り、変数の外側を単純に引用符で囲むだけでは、変数内のデータに引用符が含まれていた場合にコマンド構造が崩壊します。この問題を根本的に解決するのが | quote フィルタです。
-
解決策の記述:
ansible.builtin.shell: mysql -D test_db -e {{ search_filter | quote }} -
効果:
Ansible(Jinja2)が、ターゲットOSのShell仕様に合わせて、変数内の引用符や特殊文字を自動的にエスケープします。 -
変換の挙動:
例えば、search_filterの値がname="admin_user"の場合、Shellが「値」として正しく認識できる形式(例:'name="admin_user"'など、Shellが解釈を誤らない形式)へ動的に変換して送出します。
これにより、第4セクションで解説した「Step B:Shellによる再評価」の段階を、データの整合性を保ったまま通過させることが可能になります。
2. Ansible変数と環境変数の参照区別
Ansibleの vars で定義した値は、リモートOS側の環境変数($VAR_NAME)として自動的に登録されるわけではありません。この参照方法の混同は、コマンド実行失敗の典型的な原因となります。
-
技術的な事実:
・Ansible変数の値をリモートのコマンドラインに反映させるためには、必ずJinja2のデリミタ(二重中括弧{{ }})を使用して、「Ansible側で文字列として書き込む」 必要があります。 -
実装の原則:
・Ansible変数を参照する場合:{{ my_var }}を使用する。
・リモートOS側の環境変数を参照する場合:$MY_ENV_VARや${MY_ENV_VAR}を使用する(この場合、Ansible側での| quote処理は不要ですが、Shell側の評価ルールに従います)。
まとめ:安全な変数展開のためのチェックリスト
今回の検証と解決策から導き出される、実務上のガイドラインは以下の通りです。
-
動的な値を含む場合は
| quoteを付与する:
引用符の有無を問わず、外部から入力される可能性のある変数には一律で付与することが推奨されます。 -
Jinja2による「書き込み」を意識する:
コマンドライン上で変数が期待通りに展開されない場合は、第6セクションで示した-vvvオプションによる"cmd"の確認を行い、Ansibleによる書き込み(Step A)が正しく行われているかを確認してください。
8. まとめ:意図通りの値を届けるための設計指針
Ansibleを介したShellコマンドの実行において、変数を安全かつ確実に扱うための要点を整理します。
【変数展開と引用符処理のチェックリスト】
-
評価主体の明確化
・Ansible側の値を使用する場合:{{ var }}を使用し、Ansibleに文字列を構築させる。
・リモートノード側の環境変数を使用する場合:$VARを使用し、リモートShellに評価させる。 -
| quoteフィルタの原則適用
ユーザー入力やデータベースからの取得値など、特殊文字や引用符が含まれうる変数をShellに渡す際は、必ず| quoteフィルタを適用する。これにより、実行環境(POSIX Shell等)に応じた適切なエスケープが自動化される。 -
二段階評価のプロセスを意識する
「Ansibleが展開した後の文字列」を「ターゲットのShellがさらに再評価する」というバトンタッチのプロセスを常に前提として設計する。
障害発生時の切り分け指針
実行結果が意図に反する場合、以下の切り口で調査を行います。
-
「変数が空になっている」場合
・Jinja2の記述漏れにより、Ansible変数が展開されず、リモート側で未定義の環境変数を参照しようとしていないかを確認する。 -
「構文エラー(破壊)が発生している」場合
・-vvvオプションで"cmd"ログを確認する。Jinja2展開後の文字列をそのままリモートShellで実行し、引用符の衝突や分割が起きていないかを検証する。
9. 次回予告
今回の検証では、変数の「値」を損なわずにリモートへ届けるための手法を整理しました。
しかし、引用符によって適切に処理されたデータであっても、実行時に意図しない箇所で分割されるという問題が残っています。
「1つのファイル名を指定したつもりが、含まれるスペースによって2つの引数に分断され、実行に失敗する」
この事象を引き起こす、Shellの基本的な仕様である
- 「単語分割(Word Splitting)」
のメカニズムについて、次回は詳しく解説します。
次回:
【Shell編】第10回:引数が分割されるメカニズムと制御方法
スペースを含むデータを、一塊の「データ」として扱うためのShell内部の分割ルールと、IFS(内部フィールド区切り文字)の役割について掘り下げます。
🗺️ 初めての方・シリーズの全体像を知りたい方はこちら
本記事は、OSの仕組みからAnsible設計までを繋ぐ連載シリーズの一部です。
「どこから読み始めればいいか」あるいは、「OS/Shell/Ansible編の関係性」 について把握されたい場合は、以下の統合ガイドで整理しています。
10. 連載一覧:Ansibleが理解できない理由はLinuxにあった【Shell編】
| 回数とタイトル | 内容(概要) |
|---|---|
| 【Shell編】第0回:なぜShellを理解しないとAnsibleは使えないのか | AnsibleはShellを通してコマンドを実行している。Ansible → SSH → Shell → Linux の構造を理解し、なぜShell理解が必須なのかを整理する。 |
| 【Shell編】第1回:なぜAnsibleで出力が取得できないのか | Ansibleでregisterが空になる・エラーが見えない原因はstdout / stderrの違いにある。Shellの出力構造を理解することで原因を特定できる。 |
| 【Shell編】第2回:なぜ結果が消えるのか | Ansibleで実行結果が見えなくなる原因はパイプ・リダイレクトによる出力先の変化にある。どこに出力が流れたのかを追うことで原因を特定できる。 |
| 【Shell編】第3回:なぜgrepで見つからないのか | Ansibleでgrepがヒットしない原因は検索対象ではなく入力(stdin)の問題にある。Shellの入力(ストリーム)構造を理解することで原因を特定できる。 |
| 【Shell編】第4回:なぜ正規表現で壊れるのか | Ansibleで検索や置換が壊れる原因は正規表現の評価ルールにある。文字列ではなくルールとして解釈される仕組みを理解し、リテラルの境界を学ぶ。 |
| 【Shell編】第5回:なぜ環境が違うのか | 手動では動くのにAnsibleで失敗する原因は実行環境(PATH・環境変数)の差分にある。Shellの実行コンテキストの違いを理解し、環境を制御する。 |
| 【Shell編】第6回:なぜ条件分岐が失敗するのか | Ansibleのwhen条件が意図通りに動かない原因はexit codeにある。Shellの終了ステータスの判定ロジックを理解することで、正しく条件を組める。 |
| 【Shell編】第7回:なぜループがうまく動かないのか | Ansibleで繰り返し処理が期待通り動かない原因はShellのループと入力・サブシェルの干渉にある。データの扱い方を理解し、ループを安定させる。 |
| 【Shell編】第8回:なぜ途中で処理が止まるのか | Ansible実行中に意図せず処理が止まる原因はShellのエラーハンドリング(set -e 等)にある。エラー伝播の仕組みを理解し、停止条件を制御する。 |
| 【Shell編】第9回:なぜ変数が意図通りに展開されないのか | Ansibleで変数が空になる・壊れる原因はShellのクォートと展開順序にある。値の保護と展開のルールを理解し、安定したコマンドを記述する。 |
| 【Shell編】第10回:なぜ値が分割されてしまうのか | スペースや改行で意図せず値が壊れる原因はShellの単語分割(word splitting)にある。IFSなどの内部処理の本質を理解し、データの整合性を守る。 |
| 【Shell編】第11回:なぜAnsibleの挙動が読めるようになるのか | これまでの知識を統合し、AnsibleのエラーをShellの実行プロセスから逆算して分解できるようになる。実務で使える“読み方”を完成させる。 |