file・copy・blockinfile — パスがどんな状態であるべきかを書く
一言でいうと
ファイルモジュールは、「このコマンドを実行せよ」ではなく、「このパスがこの状態でなければならない」を書く場所であり、stateという1つの単語が、そのステートマシンのすべてです。残りは、その状態に付随する権限・バックアップ・検証・マーカーの問題です。
なぜ必要なのか
サーバーを手で扱っていた時代の設定のデプロイは、4行でした。mkdir -p /etc/app、cp app.conf /etc/app/、chmod 640 /etc/app/app.conf、sed -i 's/8080/9090/' /etc/app/app.conf。この4行をそのままshellタスクに移すと、自動化されたように見えますが、実際には、3つのことが同時に崩れます。
1つ目は、2回目の実行が1回目と異なることです。sed -iは、すでに9090になっているファイルに再び実行してもchangedと報告され、パターンが2回あれば、2回変更します。2つ目は、何が変わったのかわからないことです。cpは上書きして終わりなので、前に何があったのかが、どこにも残りません。設定を1つ間違えて上げて障害が起きたとき、元に戻すための根拠が消えます。3つ目は、壊れた設定がそのまま上がることです。文法が間違ったJSONでもnginxの設定でも、cpは尋ねずに上げます。サービスは次の再起動のときに落ち、そのときは、すでにデプロイしてからずいぶん経ったあとです。
Ansibleのファイルモジュールは、この3つの問題に、それぞれ1つの引数で答えます。冪等性はstateとモジュール自体が、元に戻すための根拠はbackupが、壊れた設定の遮断はvalidateが担います。シェルの4行をモジュールに変えるのは、文体の問題ではなく、この3つを得られるかどうかの問題です。
どう動くのか
state: ファイルモジュールのステートマシン
ansible.builtin.fileは、引数が多く見えますが、骨格はstate1つです。
| state | 意味 | ないとき | すでにそうなっているとき |
|---|---|---|---|
directory |
ディレクトリである必要がある | 途中のパスまで作成する | ok |
file |
すでにあるファイルの属性だけを合わせる | 失敗する(作成しない) | 属性だけを比較 |
touch |
なければ空のファイルを作成する | 作成する | mtimeを更新して常にchanged |
link |
シンボリックリンクである必要がある | 作成する | 対象が同じならok |
hard |
ハードリンクである必要がある | 作成する | inodeが同じならok |
absent |
存在してはいけない | ok | 削除する(ディレクトリはまるごと) |
ここで、2つのマス目が人を捕まえます。state: fileはファイルを作成しません。権限だけを直そうとしたタスクが、「パスがない」と言って失敗します。そしてstate: touchは冪等ではありません。ファイルがすでにあっても、mtimeを触って、毎回changedを出します。2回目の実行でchangedを0にしたいなら、touchを使わないか、使うなら、modification_time: preserveとaccess_time: preserveを一緒に指定します。
state: absentは、ディレクトリを再帰的に削除します。この1行が、パス変数のタイプミス1つで、見当違いのディレクトリをまるごと吹き飛ばした事故が、何度もありました。削除するタスクには、パスを変数で組み立てないほうが安全で、どうしても組み立てる必要があるなら、前にassertでプレフィックスを確認するタスクを1つ置きます。
mode: 引用符1つが権限を変える
最もよく起きる事故で、静かに起きます。
- ansible.builtin.copy: {dest: /root/demo/a, content: "x\n", mode: "0640"} # → 0640
- ansible.builtin.copy: {dest: /root/demo/b, content: "x\n", mode: 0644} # → 0644
- ansible.builtin.copy: {dest: /root/demo/c, content: "x\n", mode: 644} # → 1204
3行目が問題です。YAMLは、先頭に0がない644を10進数の644として読み、モジュールはその整数をそのまま権限ビットとして使います。10進数の644は、8進数では1204で、先頭の1はstickyビットです。結果は、--w----r--にstickyが付いた、誰も意図していない権限です。エラーも警告も出ません。そのため、ルールは1つです。modeは常に、引用符で囲んだ文字列で書きます。"0640"のように。u=rw,g=r,o=のようなシンボリック表記も文字列なので安全で、人が読むには、むしろこちらのほうがよいです。
fileモジュールのmodeには、大文字のXも使えます。u=rwX,g=rX,o=rXは、「ディレクトリか、すでに誰かに実行権限があるファイルにだけ、実行ビットを与える」という意味なので、ディレクトリツリーにrecurse: trueで一度に適用するのに向いています。
owner・groupは、名前を指定すると、対象ホストで解釈されます。コントローラーにいるユーザーではなく、対象にいるユーザーである必要があること、そして、対象にそのユーザーがいなければ、タスクが失敗することが、よく引っかかる点です。
copy: srcとcontent、そしてbackup・validate
copyは、2つの入力を受け取ります。srcは、コントローラーのファイルを送り、contentは、文字列をその場で内容として書きます。この2つは、一緒には使えません。短い設定はcontentが読みやすく、長いファイルやバイナリはsrcが合います。値を入れる必要があるなら、templateに移る場面であり、contentにJinjaを長く押し込む場面ではありません。
backup: trueを指定すると、上書きの直前の内容を、同じディレクトリに残します。名前は、app.conf.416.2026-09-17@05:16:32の末尾にチルダが1つ付く形で、元のファイルとバックアップが並んで見えます。戻り値のbackup_fileにそのパスが入るので、registerで受け取っておけば、ロールバックのタスクですぐに使えます。バックアップは、対象ホストに残るという点を覚えておく必要があります。コントローラーに持ってくるには、あとでfetchが別に必要です。
validateは、「検査に通ったものだけを、所定の場所に置く」という契約です。文字列の中の%sの場所に、一時ファイルのパスが入り、そのコマンドが0で終了して初めて、対象のパスへ移されます。失敗すれば、タスクが失敗し、対象のパスには、何のファイルもできません。既存のファイルがあれば、そのファイルがそのまま残ります。visudo -cf %s、nginx -t -c %s、python3 -c "import json,sys; json.load(open(sys.argv[1]))" %sが、よくある形です。ここでミスしやすいのが2つあります。%sを抜かすと、検査コマンドが見当違いのファイルを見てしまうこと、そして、検査コマンドがsudoやサービスの再起動のような副作用を持つと、失敗したデプロイが副作用だけを残すことです。
blockinfile: マーカーが冪等性の鍵である
複数行のブロックを、他人の設定ファイルの中に載せる必要があるときがあります。/etc/hostsに内部ホストを数行、sshd_configに自社のポリシーを数行、のようなものです。このとき、lineinfileを行数の分だけ繰り返すと、3つのことが崩れます。行の間の順序と隣接が保証されず、あとでブロックをまるごと削除する方法がなく、1行がすでに別の文脈にあると、見当違いの場所が一致します。
blockinfileは、管理するブロックの前後にマーカーを残して、この問題を解決します。
- ansible.builtin.blockinfile:
path: /etc/hosts
marker: "# {mark} ANSIBLE MANAGED BLOCK: internal pool"
block: |
10.10.0.11 web1
10.10.0.12 web2
{mark}の場所に、BEGINとENDがそれぞれ入ります。次の実行で、モジュールはマーカーの間だけを自分の領域と見なして、その中をまるごと入れ替えます。外側には触れません。state: absentを指定すると、マーカーとその間を一緒に削除します。
ここで最もよくある事故は、マーカーの文字列をあとから変更することです。マーカーが変わると、モジュールは古いブロックを自分のものと見分けられず、新しいブロックをもう1つ作ります。ファイルに同じ内容が2つ残り、そのうち1つは、永遠に管理されません。1つのファイルにブロックを2つ以上入れるときは、必ずmarkerに互いに異なる名前を付ける必要がある理由も、同じです。デフォルトのマーカーは1つだけなので、後ろのタスクが前のブロックを上書きします。
境界: いつ何を使うか
| 状況 | 使うもの |
|---|---|
| ファイル全体が自分たちのもの | copyまたはtemplate |
| 他人のファイルに、キー=値の1行 | lineinfile |
| 他人のファイルに、複数行のブロック | blockinfile |
| ファイル内のパターンをすべて置換 | replace |
| 存在するか・パーミッション・ハッシュだけを確認 | stat |
| 対象のファイルをコントローラーへ | fetch |
statは、何も変更せず、事実だけを返します。registerで受け取ると、.stat.exists、.stat.mode、.stat.size、.stat.isdir、.stat.islnk、そしてchecksum_algorithmを指定した場合は.stat.checksumを見られます。条件分岐の根拠として使うときは、when: st.stat.existsのように、existsを先に見る習慣が重要です。パスがないと、modeのようなキー自体がないので、アクセスした瞬間に、未定義変数のエラーが出ます。
fetchは、copyの反対方向です。対象ホストのファイルを、コントローラーに持ってきます。デフォルトの動作が特殊なので、1回は驚きます。destの下に、ホスト名のディレクトリを作成して、元のファイルのフルパスをそのまま再現して格納します。dest: /root/backup/なら、/root/backup/web1/etc/app/app.confになります。複数台から同じファイルを回収するときに、混ざらないようにした設計です。1台だけの場合や、名前を自分で決めたい場合は、flat: trueを指定して、destをファイルパスで書きます。
リンクとfollow
state: linkは、シンボリックリンクを、state: hardは、ハードリンクを作成します。この2つの違いが、実務で表れる場面は、元のファイルを入れ替えるときです。copyは、ファイルをその場で書き換えません。一時ファイルに書き込み、名前を差し替えます。そのため、元のファイルをcopyで再配置すると、inodeが新しくできて、ハードリンクは古いinodeに残ります。次の実行で、state: hardのタスクは、「対象にすでにファイルがある」と言って失敗します。シンボリックリンクは、パスを指すので、この問題がありません。デプロイでよく使われるcurrentシンボリックリンクのパターンが、ハードリンクではない理由が、これです。
followは、「パスがシンボリックリンクのとき、リンク自体を見るのか、その先のファイルを見るのか」を決めます。fileモジュールは、デフォルトがfollow: trueなので、リンクにmodeを指定すると、リンクではなく、元のファイルの権限が変わります。Linuxでは、シンボリックリンク自体の権限が意味を持たないので、たいていはこちらが正しいですが、「リンクだけを付け直したかったのに、元のファイルが変わった」という事故が、ここで起きます。statは逆に、デフォルトがfollow: falseなので、リンク自体を見ます。同じ名前の引数でも、モジュールごとにデフォルト値が異なるということだけを覚えておけばよいです。
現場での姿
事例1: 権限640が1204で配布された日。秘密鍵をデプロイするロールで、mode: 600が引用符なしで書かれていました。実際に残った権限は1170で、グループに読み取り権限が開いていました。誰も気づかなかった理由は簡単です。デプロイは成功し、アプリケーションはrootで動いていて、ファイルを読むのに何の問題もなかったからです。半年後のセキュリティ点検で発見されました。その日以降、リンタールールを1つCIに入れました。mode:の後ろの値が引用符で囲まれていなければ、ビルドを落とします。
事例2: ブロックが2つになった設定。/etc/hostsに内部ホストを入れるロールで、マーカーの文言を「ANSIBLE MANAGED BLOCK」から「MANAGED BY PLATFORM」に変えるコミットが上がりました。次のデプロイで、すべてのサーバーの/etc/hostsに、同じ行が2つずつできました。古いブロックは、マーカーが違うために誰にも管理されなくなり、あとで1つのホストのIPが変わったときに、新しいブロックだけが更新されて、古い行が勝ちました。マーカーは、ファイルに残るインターフェースです。変えるなら、古いマーカーでstate: absentを1回実行して削除してから、変更します。
事例3: validateを付けなかった代償。設定テンプレートで変数が1つ空になり、レンダリング結果のJSONが壊れたままデプロイされました。デプロイは緑で、サービスは問題なく動いていました。その設定を読むタイミングが、次の再起動だったからです。8時間後にノードの再起動がかかって、半分が落ちました。validateが1行あれば、デプロイがその場で失敗し、障害の代わりに、失敗したパイプライン1つで終わったはずの出来事です。
次のラボですること
fileのstateでディレクトリツリーを宣言し、引用符なしのmodeが残す権限を、自分で計測して目で確認します。copyにbackupとvalidateを付けて、元に戻すための根拠を残し、壊れた設定を防いでみます。blockinfileでマーカー付きのブロックを作成して、2回実行してもブロックが1つだけであることを確認し、シンボリックリンクとハードリンクを作成して、followが何を変えるかを見ます。最後に、statとfetchで状態を集めてレポートを作成し、パスの一覧を受け取って、各パスが今どのような状態かを判定する点検スクリプトを、自分で書きます。