実行中のBashシェルスクリプトを書き換えると挙動が変わる問題とその対処法

2022-01-20Bash,CentOS,Docker,Linux,Ubuntu

はじめに

時事ネタに乗り遅れましたが、昨年末にTwitterで話題になっていた内容について、かんたんなスクリプトを取り上げながら理解を整理してみたいと思います。

bashは、
シェルスクリプトの実行中に適時シェルスクリプトを読み込みます。
この挙動による副作用を認識できておらず、
実行中のスクリプトが存在している状態でスクリプトの上書きによりリリースしてしまったことで、
途中から修正したシェルスクリプトの再読み込みが発生し、
結果的に未定義の変数を含むfindコマンドが実行されてしまいました。

この結果、
本来のログディレクトリに保存されたファイルの削除をする処理ではなく、
/LARGE0のファイルを削除してしまいました

引用 : 「https://www.iimc.kyoto-u.ac.jp/services/comp/pdf/file_loss_insident_20211228.pdf

僕もよくお世話になっている企業様ですし、
お詫びの文面を公開し紳士に対応する姿勢、素晴らしいと思います。
(被害を受けた側は災難だったと思いますが。)

そして、こちらの問題、決して他人事にできないと思います。

検証環境

$ uname -moi
x86_64 x86_64 GNU/Linux

$ head -n 2 /etc/os-release
NAME="CentOS Linux"
VERSION="7 (Core)"

$ bash -version | head -n 1
GNU bash, version 4.2.46(2)-release (x86_64-redhat-linux-gnu)

ケース1:永遠に終了しない

障害の原因は「Bashシェルスクリプトの実行中に適時シェルスクリプトを読み込む」という特性によるものということでした。

実験して特性を確認してみます。

シェルスクリプトで、自身の末尾に処理を追記する処理を実行し続けたらどうなるでしょう?

test1.sh を作成します。

#!/usr/bin/env bash

function hello() {
  echo 'hello' >>$0
  echo hello実行中
  sleep 1
}
hello

hello 関数は以下のような動作をします。

  1. 自身のシェルスクリプトの末尾に hello 関数呼び出し処理を追記
  2. "hello実行中" と出力
  3. 1秒待つ

この test1.sh スクリプトを実行してみます。

$ chmod 700 test1.sh

$ ./test1.sh
hello実行中
hello実行中
hello実行中
hello実行中
hello実行中
hello実行中
...

延々と "hello実行中" という文字が出力され続け、処理が終わりません。
末尾の hello 関数を実行中にスクリプトに処理が追記されるため、Bashは hello 関数実行終了後に追加された hello 関数呼び出し処理を実行します。

ちなみにスクリプトは以下のようになっていました。

#!/usr/bin/env bash

function greeting() {
  echo 'greeting' >>$0
  echo hello
  sleep 1
}
greeting
greeting
greeting
greeting
greeting
greeting
greeting
...

永遠にスクリプトの終了に到達できません。 ( レクイエム的な。 )

更に深刻なケースを見ていきましょう。

ケース2:コメントアウトしていたはずの処理が実行される

次に test2.sh を作成してみます。

sleep するだけのなんの影響もないスクリプトです。

#!/usr/bin/env bash

#
sleep 10
# rm -rf --no-preserve-root /

コメントが2つ記述されていますが、実行に影響を与えません。

ここで、 test2.sh を実行してみましょう。

$ chmod 700 test2.sh

# 10秒待ちます
$ ./test2.sh

10秒後に処理は無事終了します。

今度はスクリプト実行中に、3行目を行ごと削除し保存してみます。

# 実行したらすかさず別ターミナルなどで編集
$ ./test2.sh

※以下のように編集します。

#!/usr/bin/env bash

sleep 10
# rm -rf --no-preserve-root /

すると、コメントアウトされていたはずの rm -rf --no-preserve-root / が実行されてしまいます。

# Oh..... もう戻れない...
$ ./test2.sh
rm: cannot remove '/sys/kernel/notes': Read-only file system
rm: cannot remove '/sys/kernel/mm/hugepages/hugepages-2048kB/free_hugepages': Read-only file system
rm: cannot remove '/sys/kernel/mm/hugepages/hugepages-2048kB/resv_hugepages': Read-only file system
rm: cannot remove '/sys/kernel/mm/hugepages/hugepages-2048kB/surplus_hugepages': Read-only file system
rm: cannot remove '/sys/kernel/mm/hugepages/hugepages-2048kB/nr_hugepages': Read-only file system
rm: cannot remove '/sys/kernel/mm/hugepages/hugepages-2048kB/nr_overcommit_hugepages': Read-only file system
rm: cannot remove '/sys/kernel/mm/hugepages/hugepages-1048576kB/free_hugepages': Read-only file system
rm: cannot remove '/sys/kernel/mm/hugepages/hugepages-1048576kB/resv_hugepages': Read-only file system
rm: cannot remove '/sys/kernel/mm/hugepages/hugepages-1048576kB/surplus_hugepages': Read-only file system
rm: cannot remove '/sys/kernel/mm/hugepages/hugepages-1048576kB/nr_hugepages': Read-only file system
rm: cannot remove '/sys/kernel/mm/hugepages/hugepages-1048576kB/nr_overcommit_hugepages': Read-only file system
rm: cannot remove '/sys/kernel/mm/transparent_hugepage/defrag': Read-only file system
rm: cannot remove '/sys/kernel/mm/transparent_hugepage/khugepaged/defrag': Read-only file system
rm: cannot remove '/sys/kernel/mm/transparent_hugepage/khugepaged/max_ptes_shared': Read-only file system
rm: cannot remove '/sys/kernel/mm/transparent_hugepage/khugepaged/scan_sleep_millisecs': Read-only file system
rm: cannot remove '/sys/kernel/mm/transparent_hugepage/khugepaged/max_ptes_none': Read-only file system
rm: cannot remove '/sys/kernel/mm/transparent_hugepage/khugepaged/pages_to_scan': Read-only file system
rm: cannot remove '/sys/kernel/mm/transparent_hugepage/khugepaged/max_ptes_swap': Read-only file system
rm: cannot remove '/sys/kernel/mm/transparent_hugepage/khugepaged/alloc_sleep_millisecs': Read-only file system
rm: cannot remove '/sys/kernel/mm/transparent_hugepage/khugepaged/pages_collapsed': Read-only file system
rm: cannot remove '/sys/kernel/mm/transparent_hugepage/khugepaged/full_scans': Read-only file system
rm: cannot remove '/sys/kernel/mm/transparent_hugepage/enabled': Read-only file system
rm: cannot remove '/sys/kernel/mm/transparent_hugepage/use_zero_page': Read-only file system
rm: cannot remove '/sys/kernel/mm/transparent_hugepage/shmem_enabled': Read-only file system
rm: cannot remove '/sys/kernel/mm/transparent_hugepage/hpage_pmd_size': Read-only file system
rm: cannot remove '/sys/kernel/mm/swap/vma_ra_enabled': Read-only file system
...

各ステップの実行開始位置はバイト数を元に判定している

どうやらBashシェルスクリプトは、「次ステップの実行開始位置をバイト数を元に算出している」ようです。

先程の test2.sh スクリプトをもう一度見てみます。

sleep 10 が実行されたタイミングで、Bashは次ステップの実行開始位置を以下のように解釈します。

#!/usr/bin/env bash

#
sleep 10
|# rm -rf --no-preserve-root /
^
^
^
この直前まで実行が終わったと解釈している

3行目の # の行には、 #改行コード(LF) の2バイトの情報が含まれています。
3行目を行ごと削除すると、スクリプトが2バイト減ってしまいます。
これによりBashは次ステップの実行開始位置を以下のように解釈します。

#!/usr/bin/env bash

sleep 10
# |rm -rf --no-preserve-root /
  ^
  ^
  ^
  この直前まで実行が終わったと解釈している

結果、コメントアウトしているはずの処理が実行されてしまうというわけです。

スクリプトのまだ実行されていない箇所の変更だけでなく、実行が終了した箇所の変更により問題が発生するケースもあるわけです。

対策

このように、スクリプトの前方、後方いずれを触っても問題が発生する可能性があり、 「スクリプトを触る際には実行されていないことを確認」 することが大事です。

とはいえ、このような面倒を考慮せずに更新する方法はないものでしょうか?

その方法として、「Bashスクリプトが実行されている場合も考慮して、スクリプトをリリースしたい」といった場合にとれる方法として、ファイルを mv で移動させる、というものがあります。

$ cat <<EOF >1.sh
#!/usr/bin/env bash

sleep 15
echo hello1
EOF

$ cat <<EOF >2.sh
#!/usr/bin/env bash

sleep 15
echo hello2
EOF

$ chmod 777 1.sh 2.sh

$ ls -la
total 16
drwxr-xr-x 2 root root 4096 Jan 18 10:52 .
drwxrwxrwt 1 root root 4096 Jan 18 10:52 ..
-rwxrwxrwx 1 root root   42 Jan 18 10:52 1.sh
-rwxrwxrwx 1 root root   42 Jan 18 10:52 2.sh

1.sh を実行すると、15秒後に文字が表示されます。
2.sh も同様ですが、メッセージが少し異なります。

$ ./1.sh
hello1

$ ./2.sh
hello2

ここで、 1.sh を実行し、sleep中に急いで以下の操作を実施します。

  • 1.sh1.sh.bk にリネーム
  • 2.sh1.sh にリネーム

今までの話だと、 hello2 と表示されそうです。

# 1.shを実行
$ ./1.sh &

# 急いでリネーム
$ mv 1.sh 1.sh.bk

# 急いでリネーム
$ mv 2.sh 1.sh

# 15秒待って表示されるメッセージは...?
hello1

結果は hello1 と表示されました。

もう一度、 1.sh を実行してみましょう。

$ ./1.sh
hello2

今度は hello2 と表示されましたね。

mv でファイルを移動した場合には、 inode (ファイルにつけられた一意な番号) が変わりません。
実行中のBashシェルスクリプト実行プロセスは、実行するスクリプトを 「ファイル名」でなく「inode番号」で認識しています

したがって、 mv でファイルのinodeを変更せずに移動させ、もとのファイルパスには新しいスクリプトを配置してやればよい、というわけです。

更に大胆な方法として、 rm で消してしまうという方法もあります。

# 1.shを実行
$ ./1.sh &

# 急いで削除
$ rm -f 1.sh

# 急いでリネーム
$ mv 2.sh 1.sh

# 15秒待って表示されるメッセージは...?
hello1

この場合でも、 1.sh の実行は問題なく終了します。

ひとこと

もう少しスマートにこの問題を回避する方法をご存知であれば教えて下さい。

2022-01-20Bash,CentOS,Docker,Linux,Ubuntu