📚 Ansibleが理解できない理由はLinuxにあった【Shell編】第10回:なぜ値が分割されてしまうのか(Word Splitting & IFS)
※Shellの仕組みからAnsibleを理解するシリーズです。
① なぜShellを理解しないとAnsibleは使えないのか
② なぜAnsibleで出力が取得できないのか
③ なぜ結果が消えるのか
④ なぜgrepで見つからないのか
⑤ なぜ正規表現で壊れるのか
⑥ なぜ環境が違うのか
⑦ なぜ条件分岐が失敗するのか
⑧ なぜループがうまく動かないのか
⑨ なぜ途中で処理が止まるのか
⑩ なぜ変数が意図通りに展開されないのか
⑪ なぜAnsibleの挙動が読めるようになるのか
🗺️ 初めての方・シリーズの全体像を知りたい方はこちら
本記事は、OSの仕組みからAnsible設計までを繋ぐ連載シリーズの一部です。
「どこから読み始めればいいか」あるいは、「OS/Shell/Ansible編の関係性」 について把握されたい場合は、以下の統合ガイドで整理しています。
📑 【Shell編】全体のまとめはこちら
→ Ansibleが理解できない理由はLinuxにあった【Shell編】まとめ
📋 目次
- 前回の振り返り(第9回)
- 逆引き辞典との連動
- 問題定義:スペース一つで「1つのデータ」が「複数の引数として解釈される」現象
- なぜ分割されるのか:単語分割(Word Splitting)のメカニズム
- 【実機検証】単語分割が引き起こす「引数の意図しない断片化」の再現
- 観測方法:分割された「引数の数」を可視化する
- よくある実務ミス2選(単語分割編)
- まとめ:データの整合性を保つための基本原則
- 最終回予告:なぜAnsibleの挙動が読めるようになるのか
- 連載一覧:Ansibleが理解できない理由はLinuxにあった【Shell編】
※本記事の位置とシリーズ全体の関係を先に確認します。
シリーズ全体構造(学習 × 問題解決)
本シリーズは
「理解(各記事)」と「問題解決(逆引き辞典)」を組み合わせて
スキルを身につける構成になっています。
この図は、どこから学び、どこに進めばよいかを示した“ロードマップ”です。
📍 現在の位置
現在はこの図の「Shell編の第10回」になります。
📍 はじめに:この記事のスタンス
前回の第9回では、Ansible(Jinja2)とShellによる「変数評価のタイミング」の違いと、引用符の衝突によってデータが壊れる仕組みを確認しました。
その対策として、変数の値を壊さずにコマンドへ渡す | quote フィルタなどの方法を扱いましたが、実務でシェル系のモジュール(ansible.builtin.shell など)を安全に扱うためには、もう一つ深く理解しておくべきOSレイヤーの仕様があります。
-
スペースを含むパスを渡しただけなのに、意図せず複数の引数に分断される
-
分断された結果、片方が相対パス扱いになり関係のない場所にディレクトリが作成される
-
空の変数を展開した結果、引数そのものが消失してコマンドが変質・エラーになる
これらは、Ansibleの記述ミスやJinja2のバグではなく、文字列を受け取った先にあるLinuxのShellが持つ独自の仕様が原因で発生します。
それが、今回解説する 単語分割(Word Splitting) のメカニズムです。
Shellは、展開後の文字列を内部のルール(IFS)に従って自動的に分割し、コマンドの引数として解釈します。この仕組みと挙動を正しく認識していないと、スペースを含むファイルパスや不確定な変数データを意図通りに制御することは不可能です。
本記事では、この単語分割が起きる一連のシーケンスを整理し、実機検証を通してその挙動を確認しながら、対策を整理します
1. 前回の振り返り(第9回)
前回は、変数が展開される「二段階の評価シーケンス」を整理しました。今回は、その展開が完了した後に行われる、Shellによるデータの分割(単語分割)の仕組みに焦点を当てます。
2. 逆引き辞典との連動
具体的なエラー名から原因を特定したい場合は、以下の逆引き辞典を活用してください。
【保存版】Ansibleよくあるエラー一覧と原因まとめ(Shell編)
- まず「逆引き辞典」で該当ステップを特定する
- 次に「本編(各連載記事)」で仕組みを理解する
このサイクルが、OS編から続くトラブル解決の最短ルートです。
3. 問題定義:スペース一つで「1つのデータ」が「複数の引数として解釈される」現象
Ansibleからシェルコマンドを実行する際に、変数へスペースを含む文字列を渡すと、Shellの解釈によって意図しない引数分割が発生することがあります。
スペースを含む値を扱う場合の挙動
vars:
# スペースを含むディレクトリ名
target_dir: "/tmp/my app"
tasks:
- name: "検証:クォートなしでのディレクトリ作成"
ansible.builtin.shell: mkdir {{ target_dir }}
発生する現象
-
期待:
/tmp/my appというスペースを含んだ1つのディレクトリが作成される -
現実: シェルの解釈により文字列がスペースで分割され、結果として複数の引数として渡される
Jinja2による変数展開が行われた結果、リモートノード側では以下のコマンドラインが組み立てられます。
mkdir /tmp/my app
この時、mkdir には「1つのパス」ではなく、以下の2つの引数が引き渡されます。
-
/tmp/my(絶対パス) -
app(相対パス)
mkdir は複数の引数を同時に受け取って処理できる仕様であるため、それぞれを別々のディレクトリとして作成してしまいます。結果として、意図した場所にディレクトリが作られないだけでなく、関係のないディレクトリにまで影響が及ぶことになります。
なぜShellは、人間が「1つの塊」として扱っている文字列を分割してしまうのか。その仕組みについて次章で説明します。
4. なぜ分割されるのか:単語分割(Word Splitting)のメカニズム
Shellは、変数展開が行われた後の文字列をさらに解析し、特定の文字(デフォルトではスペース、タブ、改行)を区切りとして扱います。この処理を単語分割(Word Splitting)と呼びます。
単語分割のシーケンス:空白を区切りとして解釈する流れ
[ 1. 展開フェーズ ]
記述: mkdir {{ target_dir }}
展開: mkdir /tmp/my app
|
v
[ 2. 単語分割(Word Splitting)フェーズ ] ★ここがポイント
スキャン: "mkdir" "[SPACE]" "/tmp/my" "[SPACE]" "app"
| | | | |
判定: [コマンド] [区切り] [引数1] [区切り] [引数2]
|
v
[ 3. コマンド実行フェーズ ]
実行: [mkdir] に対して、第1引数「/tmp/my」、第2引数「app」を渡す
IFS(Internal Field Separator)の役割
Shellが「どの文字を区切りとして扱うか」を決めているのが、環境変数 IFS です。
デフォルトでは IFS=$' \t\n'(スペース、タブ、改行)が設定されており、Shellは展開後の文字列に対してこの分割ルールを機械的に適用します。
そのため、クォートによる保護を行わない場合、スペースを含む値はすべて個別の引数として解釈されることになります。次節の実機検証で、この挙動が引き起こす実際の結果を確認します。
5. 【実機検証】単語分割が引き起こす「引数の意図しない断片化」の再現
第3セクションで例に挙げた「スペースを含むパス」を用い、実際にターゲットノードで mkdir コマンドを実行します。一見成功したように見えるタスクが、OSレイヤーでどのように「変質」しているかを直接観測します。
① 検証準備
実行するPlaybook(test_word_splitting.yml)
クォート処理を行わず、スペースを含む変数をそのまま shell モジュールに渡します。
# 単語分割(Word Splitting)の挙動を確認するための検証用Playbook
- name: 第10回:実機検証(単語分割による引数の分断再現)
hosts: test_servers
become: yes
vars:
target_dir: "/tmp/my app"
tasks:
- name: "検証:クォートなしでのディレクトリ作成"
ansible.builtin.shell: mkdir {{ target_dir }}
register: mkdir_result
- name: 実行結果の分析
ansible.builtin.debug:
msg:
- "1. Ansibleが構築したコマンドライン: {{ mkdir_result.cmd }}"
- "2. 終了ステータス (rc): {{ mkdir_result.rc }}"
実行コマンド
ansible-playbook -i inventory.ini test_word_splitting.yml -K
② 検証結果
(コントローラーノード側:Ansibleの出力)
PLAY [第10回:実機検証(単語分割による引数の分断再現)] ************************
TASK [Gathering Facts] *********************************************************
ok: [192.168.1.21]
TASK [検証:クォートなしでのディレクトリ作成] **********************************
changed: [192.168.1.21]
TASK [実行結果の分析] **********************************************************
ok: [192.168.1.21] => {
"msg": [
"1. Ansibleが構築したコマンドライン: mkdir /tmp/my app",
"2. 終了ステータス (rc): 0"
]
}
PLAY RECAP *********************************************************************
192.168.1.21 : ok=3 changed=1 unreachable=0 failed=0 s kipped=0 rescued=0 ignored=0
(リモートノード側)
[ansibleuser@localhost ~]$ pwd
/home/ansibleuser
[ansibleuser@localhost ~]$ ls -l
drwxr-xr-x. 2 root root 6 5月 16 14:51 app
[ansibleuser@localhost ~]$ ls -l /tmp
drwxr-xr-x. 2 root root 6 5月 16 14:51 my
ログの読み解きと考察:なぜ「成功(rc: 0)」しているのに危険なのか
Ansibleの実行ログだけを見ると、タスクは changed となり、rc: 0(正常終了)を返しています。しかし、ターゲットノードの実態を確認すると、深刻な不整合が起きています。
-
Ansible側の「構築」プロセス
ログの"cmd"項目にはmkdir /tmp/my appと記録されています。Jinja2は変数を正しく展開しており、Ansibleとしての仕事はここで完了しています。 -
リモートShell側の「単語分割」プロセスとパスの解釈
問題は、この文字列を受け取ったリモートOS側のShellの挙動です。
ShellはIFS(区切り文字)に従い、展開後の文字列をスペースの位置で分割します。その結果、mkdirコマンドには/tmp/myとappという「2つの独立した引数」が引き渡されました。
ここで重要なのは、2つ目の引数である app です。頭に / がついていない相対パスとして解釈されたため、/tmp/app ではなく、コマンド実行時のカレントディレクトリ(/home/ansibleuser)の直下に app フォルダが作成されるという、結果となりました。
mkdir コマンド自体は複数の引数(別々の場所の作成)を受け取れる仕様であるため、OS側は何のエラーも吐かずに処理を完了してしまったのです。
テスト結果のまとめ
今回の実機検証により、以下の技術的挙動が確認されました。
-
サイレントな失敗と意図しない場所への作成:
期待していた/tmp/my appではなく、別の場所にappディレクトリが分散して作成されました。エラー(rc: 1)にならないため、自動化のフロー上では発見が遅れやすい状態です。 -
IFS(区切り文字)による強制的な分割ルール:
クォートによる保護がない場合、Shellは人間が「1つの塊」と考えているデータを機械的に分断し、それぞれの断片を個別の引数としてコマンドへ引き渡すことが実証されました。 -
観測の限界:
Ansibleの標準ログ(cmd項目)だけでは、その後の「OSレイヤーでの分割」や「相対パスの解釈」までは可視化されません。クォートによる保護が必要です。
6. 観測方法:分割された「引数の数」を可視化する
第5セクションの検証で確認した通り、単語分割の事象はAnsibleの標準ログ(cmd 項目)を眺めるだけでは、OS側でどう処理されたかまでを見極めることが困難です。
また、デバッグ時に安易に echo {{ target_dir }} などを実行しても、echo コマンドは受け取った複数の引数をスペースで再結合して出力してしまうため、分割の有無を判別できません。
Shellが認識している「正確な引数の数」を可視化・実証するには、以下の方法が有効です。
方法①:ループ処理による引数の個別展開(実機検証)
引数がどのように分断されているかを確実に突き止めるには、Shellのループに展開させて行数を数えるのが最も確実です。
① 検証準備
実行するPlaybook(test_word_splitting_loop.yml)
変数をクォートせずに、そのままShellの for arg in ... に流し込み、処理された行数を記録します。
# 単語分割(Word Splitting)の数を可視化する検証用Playbook
- name: 第10回:実機検証(引数の数を確認するデバッグ)
hosts: test_servers
become: yes
vars:
target_dir: "/tmp/my app"
tasks:
- name: "検証:ループ処理による引数の個別展開"
ansible.builtin.shell: |
for arg in {{ target_dir }}; do
echo "Argument: [$arg]"
done
register: loop_result
- name: 展開結果の出力
ansible.builtin.debug:
var: loop_result.stdout_lines
実行コマンド
ansible-playbook -i inventory.ini test_word_splitting_loop.yml -K
② 検証結果
(コントローラーノード側:Ansibleの出力)
(ansible) [root@localhost workspace]# ansible-playbook -i inventory.ini test_word_splitting_loop.yml -K
BECOME password:
PLAY [第10回:実機検証(引数の数を確認するデバッグ)] ***************************************************************************************************************************************
TASK [Gathering Facts] **********************************************************************************************************************************************************************
ok: [192.168.1.21]
TASK [検証:ループ処理による引数の個別展開] *************************************************************************************************************************************************
changed: [192.168.1.21]
TASK [展開結果の出力] ***********************************************************************************************************************************************************************
ok: [192.168.1.21] => {
"loop_result.stdout_lines": [
"Argument: [/tmp/my]",
"Argument: [app]"
]
}
PLAY RECAP **********************************************************************************************************************************************************************************
192.168.1.21 : ok=3 changed=1 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
ログの読み解きと考察:配列の「行数」が示すShellの解釈
loop_result.stdout_lines の出力結果が、明確に2つの要素(2行)に分断されていることが確認できます。
-
Argument: [/tmp/my] -
Argument: [app]
もしShellが /tmp/my app を「1つの引数」として認識していれば、ループは1回しか回らないため、結果は ["Argument: [/tmp/my app]"] という1行の配列になるはずです。
しかし、クォートによる保護がないために IFS による単語分割が適用され、Shellの内部では完全に別のデータとして切り離されてループ処理に回されたことが、この実証データから視覚的に分かります。
方法②:Ansibleの -vvv ログと構造の対比
トラブルの原因を、デバッグ用のタスクを追加せずに既存のタスクログから特定したい場合は、通常の ansible-playbook コマンドに -vvv(最上位のデバッグフラグ) を付与して実行します。
① コマンドの実行
検証対象のPlaybook(test_word_splitting_loop.yml)に対して、以下のように -vvv を付けて実行します。
ansible-playbook -i inventory.ini test_word_splitting_loop.yml -vvv
② 膨大なログから「構造の不備」を見極める手順
-vvv を付けるとターミナルに膨大なログが流れますが、注目すべきは対象タスク(検証:ループ処理による引数の個別展開)の直後に出力される、以下の changed: [...] => { ... } のJSONデータ です。
// -vvv の出力からタスク直後の生のJSONログを抽出
changed: [192.168.1.21] => {
"changed": true,
"cmd": "for arg in /tmp/my app; do\n echo \"Argument: [$arg]\"\ndone\n",
"delta": "0:00:00.006585",
"end": "2026-05-16 15:55:18.978504",
"invocation": {
"module_args": {
"_raw_params": "for arg in /tmp/my app; do\n echo \"Argument: [$arg]\"\ndone\n",
"_uses_shell": true,
"argv": null,
"chdir": null,
"creates": null,
"executable": null,
"removes": null,
"stdin": null,
"stdin_add_newline": true,
"strip_empty_ends": true
}
},
"msg": "",
"rc": 0,
"start": "2026-05-16 15:55:18.971919",
"stderr": "",
"stderr_lines": [],
"stdout": "Argument: [/tmp/my]\nArgument: [app]",
"stdout_lines": [
"Argument: [/tmp/my]",
"Argument: [app]"
]
}
このログをデバッグする際の着眼点は、以下の2点です。
-
"cmd"や"_raw_params"の構造欠陥
ログ内に"for arg in /tmp/my app; do"と記録されています。変数があった場所にパスが展開されていますが、この値を一とかたまりとして保護するためのクォート(" "や' ')がどこにも埋め込まれていないことが分かります。 -
"stdout_lines"に残る証拠
実際にリモートで実行された結果である"stdout_lines"を見ると、見事に2行(要素数2)に別れて出力されています。
このログを確認した時点で、「Jinja2による変数展開までは正常に行われたが、クォートによる保護がない。そのため、リモートOSのShellにデータが渡った瞬間に単語分割(Word Splitting)が発生する構造になっている」と、ソースコードを直接見直さずともログの形から一目で特定することができます。
7. よくある実務ミス2選(単語分割編)
ミス①:ダブルクォートによる単語分割の抑止不足
第5・第6セクションの検証で発生した「スペースによる引数の分断」を防ぐ最も確実な方法は、展開される変数をシェルレイヤーでダブルクォートで囲むことです。
-
修正前の記述(不十分な例):
ansible.builtin.shell: mkdir {{ target_dir }} -
修正後の記述(正しい例):
ansible.builtin.shell: mkdir "{{ target_dir }}"
抑止の効果
変数をダブルクォートで囲むことにより、Jinja2によって mkdir "/tmp/my app" と展開されます。
Shellはダブルクォートで囲まれた内部のスペースを IFS(区切り文字)として評価しないため、単語分割が抑止され、全体が「1つの引数(塊)」として安全に mkdir コマンドへ引き渡されます。
ミス②:空の変数による「引数の消失」と構文エラー
単語分割のもう一つが、「変数が空(または未定義)のときに引数そのものが消滅する」という挙動です。
例えば、オプションでファイル名を受け取る以下のようなタスクを、クォートなしで記述したとします。
# optional_file の値が空文字列("")の場合
ansible.builtin.shell: ls -l {{ optional_file }}
発生する現象
optional_file が空の場合、Jinja2展開後は ls -l となります。
Shellはこれをスキャンした際、有効な文字が存在しないため「引数はゼロ」とみなします。結果として、特定のファイルを指定したかったはずが、ただの ls -l として実行されてしまい、カレントディレクトリの一覧が全件出力されるという意図しない挙動に繋がります。
また、これが test コマンド(条件分岐)などの場合、さらに深刻な構文エラーを引き起こします。
# 引数が消失し、[ -f ] となってしまい「引数が足りない」例
if [ -f {{ optional_file }} ]; then ...
解決策
こちらも同様に、変数展開部分を確実にクォートで保護します。
ansible.builtin.shell: ls -l "{{ optional_file }}"
クォートで囲んでおけば、中身が空であっても Shell は「空文字という1つの引数("")」がそこに存在すると認識するため、引数の消失による意図しないコマンドの変質や構文エラーを防ぐことができます。
8. まとめ:データの整合性を保つための基本原則
単語分割対策のチェックリスト
-
リモートShellに渡る変数展開をダブルクォートで囲んでいるか
・第5・第6セクションで実証した通り、クォートによる保護がない変数はIFS(区切り文字)によって機械的に分断されます。特別な理由がない限り、"{{ var }}"のように記述し、値を1つの塊(引数)としてShellに認識させてください。 -
意図しない「相対パスの解釈」や「引数の消失」のリスクを排除しているか
・スペースで断片化された引数が相対パスとして解釈され、カレントディレクトリに意図しないフォルダ(appなど)が作成される挙動や、空の変数が原因で引数そのものが消滅するリスクをコード段階で防げているかを確認します。 -
| quoteフィルタの特性を理解して使い分けているか
・ダブルクォート("{{ var }}"): 主に単語分割を抑止し、スペースを含む値を1つの引数としてまとめるために使用します。
・| quoteフィルタ({{ var | quote }}): Ansible(Jinja2)側で、文字列に適切なクォート付与と特殊文字のエスケープを自動で行う機構です。
どちらも有効なので、状況に応じて使い分けてください。
調査時の着眼点(トラブルシューティング)
「タスクは正常終了(rc: 0)しているのに、期待した場所にファイルやディレクトリがない」「スペースを含む値を入れた途端にコマンドの挙動が変わる」といった事象に直面した場合は、以下の手順でデバッグを行います。
-
-vvvオプションによる生データの確認
コマンドの末尾に-vvvを付与してPlaybookを実行し、対象タスク直後のJSONログ("cmd"や"_raw_params")を抽出します。 -
文字列構造のチェック
ログに記録された展開後の文字列自体に、変数を囲むクォート(" "や' ')が存在しない場合は、リモートOSに渡った瞬間に単語分割(Word Splitting)が発生していると判断できます。 -
再現環境でのループ展開
原因の特定が難しい場合は、第6セクションで用いたfor arg in {{ var }}; do ...のような簡易タスクを挟み、stdout_linesの要素数(行数)を確認することで、Shellが認識している正確な引数の数を確実に可視化できます。
9. 最終回予告:なぜAnsibleの挙動が読めるようになるのか
全10回にわたる【Shell編】は以上で一区切りです。
「データの流れ」「実行環境の違い」「エラーの伝播」「変数の評価」「単語分割」など、
Ansibleの挙動に影響する要素を段階的に整理してきました。
最終回では、これらの知識を前提として、
Linuxの挙動を起点にAnsibleの動作を読み解く方法を整理します。
次回:最終回
【Shell編】第11回:なぜAnsibleの挙動が読めるようになるのか
Ansibleの実行結果を「ブラックボックス」として扱うのではなく、
Ansibleの動作をShellの視点から読み解く方法を整理します。
📑 【Shell編】全体のまとめはこちら
→ Ansibleが理解できない理由はLinuxにあった【Shell編】まとめ
🗺️ 初めての方・シリーズの全体像を知りたい方はこちら
本記事は、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の実行プロセスから逆算して分解できるようになる。実務で使える“読み方”を完成させる。 |