flockでshellの多重起動を防止する仕組みについて考えてみた

Linuxサーバ上で定期実行だったり、多くの人が実行する可能性があるshellの多重起動を防止するための仕組みはサービスを運用していると割と遭遇しました。

その方法の一つであるflockを使った防止方法について、仕組みを分かる範囲で整理してみます。

# 方法1 実行コマンドに記述する(crontabの中とか)

    0,15,30,45 8-20 * * * flock -n /tmp/somthing.lock something.sh

# 方法2 スクリプトの中に記述する

    #!/bin/bash
    
    exec 9>/tmp/$(basename $0 .sh).lock
    flock -n 9
    if [ $? -ne 0 ]; then
    	exit 1
    fi

# 方法1の仕組み

紹介した多重起動防止処理の中でやっていることをざっくり説明すると2段階に分けられます。

- あるファイルに対し、コマンドが完了次第解放されるロックをかける
- ロックがかけられているファイルに重ねてロックをかけようとしたら処理を中断する

です。

## flockとは

flockは下記のような形式で使われます。

    # flock --help の実行結果を抜粋
    使い方:
     flock [オプション] <ファイル>|<ディレクトリ> <コマンド> [<引数>...]
     flock [オプション] <ファイル>|<ディレクトリ> -c <コマンド>
     flock [オプション] <ファイル記述子番号>

これは何かというと

`あるコマンドを実行する際に、その実行期間中に特定のファイルにロックをかけるコマンド`

です。分かりにくい。

RDBのロックに似ていて、実行中に値が変わったり参照されると困るものを保護するためのものなんだと思います。

オプションの一部

    オプション:
     -s, --shared             共有ロックを取得します
     -x, --exclusive          排他ロックを取得します (既定値)
     -u, --unlock             ロックを解除します
     -n, --nonblock           待機が必要な場合、失敗させるようにします

これを見ると分かるようにflockは`デフォルトで排他ロック`を取得します。排他ロックは共有ロックも排他ックも受け付けないので、次にflockを同じファイルに対して実行すると待ちになります。

方法1と方法2では両方-n オプションを指定していますが、これはロック待ちが発生しても待たずに終了するオプションなので、ロック中に実行されたら強制的に処理を終了することになります。

ここまでくると大体分かりますが、この記事で紹介してる多重起動防止法はflockでファイルに対してロックをかけて保護し、後続の処理のロックを-nオプションをつけることで強制終了させることで実現しています。

次に方法2のファイルディスクリプタを経由する方法を見てみます。

# 方法2の仕組み

execコマンドとFD(ファイルディスクリプタ)の説明が必要になるのでまずファイルディスクリプタから説明します。

## ファイルディスクリプタとは

ファイルディスクリプタとはプロセスの中でファイルや出力をOSが認識するための識別子のことです。

参考までにIT用語辞典には下記のような説明がされています。

> 通常、ファイルディスクリプタは0から順番に未使用の最も小さい値が与えられるようになっており、プログラム上では整数型の変数などとして扱われる。ただし、番号によっては固定的に特殊な対象を表す場合があり、一般的には「0」は標準入力（stdin）、「1」は標準出力（stdout）、「2」は標準エラー出力（stderr）としてプログラムの実行中は常に開いた状態になっている。

実際FDがどう割り当てられているのか確認します。

下記のようなsleepだけするスクリプトを2種類用意します。(sleep_1.sh と sleep_2.sh)

    #!/bin/bash
    
    exec 9>/tmp/$(basename $0 .sh).lock
    flock -n 9
    if [ $? -ne 0 ]; then
            exit 1
    fi
    
    echo "start"
    
    sleep 300
    
    echo "end"

これをそれぞれ別プロセスで実行した時に割り当てられるFDはpid毎に出力されるため、下記のコマンドで確認できます。

sleep_1.sh

    $ ls -al /proc/{pid_1}/fd
    合計 0
    dr-x------ 2 some-user some-user  0  2月 21 22:22 .
    dr-xr-xr-x 9 some-user some-user  0  2月 21 22:22 ..
    lrwx------ 1 some-user some-user 64  2月 21 22:22 0 -> /dev/pts/0
    lrwx------ 1 some-user some-user 64  2月 21 22:22 1 -> /dev/pts/0
    lrwx------ 1 some-user some-user 64  2月 21 22:22 2 -> /dev/pts/0
    lr-x------ 1 some-user some-user 64  2月 21 22:22 255 -> /tmp/sleep_1.sh
    l-wx------ 1 some-user some-user 64  2月 21 22:22 9 -> /tmp/sleep_1.lock

sleep_2.sh

    $ ls -al /proc/{pid_2}/fd
    合計 0
    dr-x------ 2 some-user some-user  0  2月 21 22:22 .
    dr-xr-xr-x 9 some-user some-user  0  2月 21 22:22 ..
    lrwx------ 1 some-user some-user 64  2月 21 22:22 0 -> /dev/pts/0
    lrwx------ 1 some-user some-user 64  2月 21 22:22 1 -> /dev/pts/0
    lrwx------ 1 some-user some-user 64  2月 21 22:22 2 -> /dev/pts/0
    lr-x------ 1 some-user some-user 64  2月 21 22:22 255 -> /tmp/sleep_2.sh
    l-wx------ 1 some-user some-user 64  2月 21 22:22 9 -> /tmp/sleep_2.lock

9番に指定したlockファイルが割り当てられています。

プロセス毎にそれぞれFDが割り振られているので独立した採番になっていることが分かります。

255は実行ファイルが割り当てられるようです。

- execコマンドでFDをファイルにリダイレクトするとFDが割り当てられる
- プロセス毎に独立してる

あたりがポイントだと思います。

## execコマンドとは

これ以外の用途で使ったことはないですが、現行のジョブに置き換えてコマンドを実行する仕組みのようです。これについては今回はあまり踏み込みませんが、現行プロセスのまま実行できることがポイントです。

    # man exec
    exec(1)                                                                                  fish                                                                                  exec(1)
    
    NAME
           execexec - execute command in current process
            -
    
       Synopsis
           exec COMMAND [OPTIONS...]
    
       Description
           exec replaces the currently running shell with a new command. On successful completion, exec never returns. exec cannot be used inside a pipeline.

ここではコマンドを指定せずにFDとリダイレクト先だけ指定してるので、同じプロセスの中でリダイレクトを変更するための手段。くらいの認識です。

リダイレクトはコマンド実行時に指定することが多いと思いますが、それをスクリプト内で指定するときに使えます。~~スケジューラアプリの方に多重起動を防止する機能がなくて辛いので乗り換えたいからshellの方にロジックを持ちたいときとかに使えますね。~~

話を方法2に戻します。

まずexecコマンドでFD９を/tmp/something.lockにリダイレクトすることで、割り当てを行います。

flockコマンドはFDを指定しており、この場合後続処理を継続するかどうかは終了ステータスを見る必要があるため、`$?`を見ています。正常終了していないということはロックの取得に失敗している、つまり前回分のロックがまだ残っていることになります。

これで多重起動を防ぐことができます。

# まとめ
- flockでshellの多重起動を防止するときはコマンド実行時とスクリプトの中に書くときで方法が異なる
- flockは特定のファイルに対してロックをかけるコマンドで、デフォルトで排他ロックになる
- flock の -n オプションはロック待ちではなく失敗にさせる
- FDはプロセス毎に割り当てられる
- execコマンドを使うことで同一プロセス内でファイルにFDを割り当てることができる
- コマンド実行時であればロックするファイルを直接指定すれば良い