ハートビーツ最年長エンジニアの滝澤です。以前、弊社CTOにシニアおっさんエンジニアから若手エンジニアに向けて何か書いてくれと言われた気がしたので、アトミック(atomic)なファイル操作について3編に分けて紹介します。この内容は弊社の社内勉強会で話した内容をまとめ直したものです。

今回は「みなさん、安全にファイルの更新ができていますか?」ということについて、考えてみましょう。

そのファイル、安全に更新できていますか?

あなたはあるサーバ上のファイルの更新を依頼され、もらったファイルをサーバ上でコピーして上書きしました。しばらくして、データに異常が発生したので調べて欲しいと言われました。さて、何が起きたのでしょうか。

調べてみると、サーバ上にあるファイルは正常でしたが、アプリケーションに読み込まれたデータが不完全でした。アプリケーションがデータファイルを読み込んでいる途中でファイルを上書きコピーをしてしまったようです。さて、何かいけなかったのでしょうか。このケースではアプリケーションを停止できないとします。

ファイルを上書きコピーしたときのファイルの状態を追ってみましょう。

コピー開始前完全(古いデータ)
コピー中不完全(データの一部分)
コピー完了後完全(新しいデータ)

コピー中ではデータが不完全な状態にありました。ファイルを上書きコピーしたのがいけなかったようです。利用中のファイルを更新するときにはアトミック(atomic)にファイルを更新すべきでした。

それでは、アトミックにファイルを更新するとはどういうことかを考えてみましょう。

アトミック(atomic)とは

'atomic'の名詞である'atomicity'は「原子性」「不可分性」などと訳されており、トランザクション処理のACID特性の'A'として知られています。英和・和英辞書の英辞朗では「トランザクションが完全に成功するか、またはエラーが起きたら全部取り消し、中途半端に終わらない性質」「一連の処理が不可分の一体として成功・失敗のいずれかになり、かつ処理途中の状態にほかからアクセスできないこと」という説明があります。

ファイル操作においてアトミックであるということは、ファイルの更新途中の中途半端な状態ではなく、更新前の状態か更新が完了した後の状態でのみアクセス可能であることを意味します。また、新規にファイルを作成するときには、ファイルの作成途中の中途半端な状態ではなく、ファイルが存在しないか、あるいは作成済みの完全なファイルが存在するかのどちらかになります。

アトミックにファイルを更新するには

アトミックにファイルを更新するには、rename()というファイル名を変更するシステムコールを使います。mvコマンドはこのrename()を使ってファイル名の変更を行っています。

"man 2 rename"を実行するとrename()のマニュアルが表示されますので一度読んでみてください。

int rename(const char *old, const char *new);

変更後のファイル名のファイルnewがすでに存在するときには、ファイルoldがファイルnewを「置き換え」ます。変更前に存在していたファイルnewを「書き換え」ているのではないのです。内部的に行われているのはファイルnewが存在しているディレクトリエントリの書き換えです。ディレクトリエントリの書き換えはアトミックに行われます。そのため、rename()実行前にファイルnewを開いていれば、rename()実行後も更新前のファイルを閉じるまでは操作し続けることができます。rename()実行後にファイルnewを開けば、更新後のファイルを開くことになります。

rename()の利用例

ここでrename()を利用している例として/etc/passwdの更新について紹介しましょう。

もしかして、あなたは/etc/passwdファイルをエディタで直接開いて更新していませんか。複数の人が同時にファイルを編集してしまったら、ファイルが壊れる恐れがあります。また、編集途中で中途半端な状態の内容でファイルを保存してしまったら、UNIXアカウント情報が正しくない状態が発生する恐れもあります。直接編集していたら、なんて恐ろしいことをしていたんだと反省してください。

/etc/passwdの編集を安全に行うためのコマンドはたいていのシステムで用意されているのでそれを使ってください。Linuxディストリビューションや*BSDの場合だとvipwコマンドが用意されていると思います。このvipwコマンドは次のことを行います。(Linuxディストリビューションでよく使われているshadowパッケージのvipwコマンドの場合)

  1. ロックをかける。
  2. /etc/passwdファイルのコピー/etc/passwd.editを作成する。
  3. コピーされたファイル/etc/passwd.editを指定してエディタを起動する。
  4. (作業者がエディタで編集を行い保存し、エディタを終了させる。)
  5. 古いバックアップファイル/etc/passwd_を削除する。
  6. /etc/passwdファイルのバックアップファイル/etc/passwd_をハードリンクで作成する。
  7. rename()により、保存したファイル/etc/passwd.editで/etc/passwdを置き換える。

以上のような処理により、複数の人が同時に編集するのを防ぎつつ、安全に/etc/passwdファイルを更新することができます。なお、下から3つの処理のソースコードは次の内容です。

        unlink (filebackup);
        link (file, filebackup);
        if (rename (fileedit, file) == -1) {

rename()の制限

rename()を使うことができるのは、変更前のファイルのファイル名と変更後のファイルのファイル名が同じファイルシステム上にある場合のみです。異なるファイルシステム間ではrename()を使うことができません。

mvコマンドの制限

mvコマンドはrename()のコマンド実装です。rename()は同じファイルシステム上にある場合のみ利用できるので、mvコマンドは元々は同じファイルシステム上にある場合だけしか使うことができませんでした。ちなみに、皆さんが普段使っている機能拡張されたmvコマンドではファイルシステムをまたがって使うことができます。ファイルシステムをまたがっているときには、rename()を使うのではなく、ファイルのコピーと削除を行っているのです。例えば、次のコマンドを実行するとしましょう。/oldpathと/newpathは異なるファイルシステムとします。

mv /oldpath/to/file /newpath/to/file

mvコマンドは、ファイル/newpath/to/fileが存在しているときには、ファイル/newpath/to/fileを削除します。次に、ファイル/oldpath/to/fileを/newpath/to/fileにコピーします。そして、ファイル/oldpath/to/fileを削除します。このときのmvコマンドは実質的に次のコマンドと等価と考えてよいでしょう。

rm -f /newpath/to/file && cp -p /oldpath/to/file /newpath/to/file && rm -f /oldpath/to/file

もちろん、この操作はアトミックではありません。

mvコマンドによるアトミックなファイルの更新例

mvコマンドでファイルを更新する例で確認してみましょう。

まず、2つのファイルnew.datとold.datを用意します。

$ echo new > new.dat
$ echo old > old.dat

lsコマンドでiノードを確認します。

$ ls -li
total 8
531483 -rw-rw-r-- 1 taki taki 4 Oct  5 01:09 new.dat
531484 -rw-rw-r-- 1 taki taki 4 Oct  5 01:09 old.dat

mvコマンドを使ってold.datの内容でnew.datを更新します。

$ mv old.dat new.dat

再びiノードを確認します。

$ ls -li
total 4
531484 -rw-rw-r-- 1 taki taki 4 Oct  5 01:09 new.dat

new.datのiノードが書き換わっているのがわかるでしょう。new.datの内容を確認します。

$ cat new.dat 
old

new.datの内容はold.datのものになっていることがわかります。

このように、ディレクトリエントリの書き換えだけが行われているため、アトミックにファイルの更新ができ、ファイルnew.datは常に完全な状態で存在します。

ちなみに、cpコマンドを使って上書きコピーをした場合についても確認してみます。

先ほどと同じようにnew.datとold.datを作成し、iノードを確認します。

$ ls -li
total 8
531483 -rw-rw-r-- 1 taki taki 4 Oct  5 01:12 new.dat
531484 -rw-rw-r-- 1 taki taki 4 Oct  5 01:12 old.dat

cpコマンドを使ってold.datの内容でnew.datを更新し、用済みのold.datを削除します。

$ cp old.dat new.dat
$ rm old.dat

再びiノードを確認します。

$ ls -li
total 4
531483 -rw-rw-r-- 1 taki taki 4 Oct  5 01:12 new.dat

new.datのiノードが変わっていないことがわかるでしょう。new.datの内容を確認します。

$ cat new.dat 
old

new.datの内容はold.datのものになっていますが、上書きされているため、アトミックではありません。

さらに、このnew.datに対してリダイレクトにより更新してみましょう。

$ echo new > new.dat
$ ls -li
total 4
531483 -rw-rw-r-- 1 taki taki 4 Oct  5 01:18 new.dat
$ cat new.dat 
new

このときもiノードが変わらないことが確認できるでしょう。同じファイルを書き換えているため、アトミックではありません。

スクリプト言語の場合

スクリプト言語を使う場合でも、rename()を内部的に使っている関数やメソッドがあるのでそれを使えばアトミックにファイルを更新できます。たいていはrename()という名前になっています。

Pythonの例を次に示します。osモジュールのrename()関数を使います。

#!/usr/bin/python
import sys
import os

def main():
    try:
        os.rename('old.dat', 'new.dat')
    except OSError:
        print "%s" % (sys.exc_value)
        sys.exit(1)

if __name__ == "__main__":
    main()

シェルスクリプトによるファイルの更新例

シェルスクリプトでアトミックにファイルを更新する例を紹介します。

ここで紹介するスクリプトは次の内容になります。アップロードしたファイルで指定のファイルを更新します。このときアップロード場所のファイルシステムは指定のファイルのファイルシステムと異なります。

#!/bin/bash
PATH=/bin:/usr/bin
export PATH

UPLOADED_FILE=/path/to/upload/file.dat 
TARGET_FILE=/path/to/data/file.dat
TMPFILE=${TARGET_FILE}.$(dd if=/dev/urandom count=2 2>/dev/null | openssl sha1 -r | head -c 40)

cp ${UPLOADED_FILE} ${TMPFILE}
chmod 644 ${TMPFILE}
mv ${TMPFILE} ${TARGET_FILE}
exit 0

主要な部分について説明します。

1. 更新前のファイルと更新対象のファイルのファイル名を次のように変数に定義します。

UPLOADED_FILE=/path/to/upload/file.dat 
TARGET_FILE=/path/to/data/file.dat

2. 一時的に利用するユニークなファイル名を生成します。ユニーク性が保てないと、同じ処理を行う他のプロセスがあったときにはデータが破壊される恐れがあります。

ファイル名にプロセスIDやUNIX時間などを付ければそこそこユニーク性を保てるでしょう。

TMPFILE=${TARGET_FILE}.$$.$(date +%s)

このとき次のようなファイル名が生成できます。

/path/to/data/file.dat.4726.1380907208

ファイル名のユニーク性を向上させるために、疑似乱数生成器/dev/urandomを使うと次のようになります。

TMPFILE=${TARGET_FILE}.$(dd if=/dev/urandom count=2 2>/dev/null | openssl sha1 -r | head -c 40)

このとき次のようなファイル名が生成できます。

/path/to/data/file.dat.7c90b8add1a953000da98b50ab3e15867aa306b7

3. 更新元ファイルを更新先ファイルと同じファイルシステム上にコピーします。

cp ${UPLOADED_FILE} ${TMPFILE}

4. パーミッションを更新先ファイル名に合わせます。

chmod 644 ${TMPFILE}

5. mvコマンドによりファイルを更新します。

mv ${TMPFILE} ${TARGET_FILE}

以上の処理によりアトミックにファイルの更新を行うことができました。

最後に

今回紹介した内容は基本的な内容ですが、若手エンジニアには意外と知られていない気がします。知らなかった人はアトミックなファイル操作について考えてみてください。

追加情報

  • 2013-10-10 16:10 「rename()の利用例」を追加。
  • 2013-10-10 17:40 a_saitoh様より 「a_saitoh: renameコマンドはUNIXではmvだが、昔のUNIXはreameシステムコールはなくて、mvコマンド はsrc とdstをハードリンクした上でsrcをremoveするという実装になっていた。全然アトミックじゃない。」とのことです。

株式会社ハートビーツのインフラエンジニアから、ちょっとした情報をお届けします。