Ruby 3.3と3.4での一時ディレクトリ探索の違いとECSでの影響
始めに
某日、ECSでスケールアップで起動しようとしていたRailsのアプリケーションが起動しませんでした。この起きていた不具合を調査する過程で一時ディレクトリに対するRubyの挙動を知ったことをブログにします。
なお、今回のケースは時間経過で自動解決しているため、ある程度状況には仮説も入っていますが、次のようなことが発生していたと思われます。
環境
- AWS
- ECS
- Ruby
- 3.3
- 3.4
状況(仮説を含む)
事前状況
- ECSでセキュリティのために
readonlyRootFilesystem: true
を指定して、基本的にファイルを書き込めないようにしている - 環境変数
TMPDIR
にボリュームマウント先の/rails/tmp
を指定する
発生時状況
- ECSのスケールアップ時でRailアプリケーションが起動しようとする
- AWS側でRailsアプリケーションのボリュームマウント先のアクセス制御を失敗していた
- 発生していたエラーメッセージ
- TMPDIR is world-writable: /rails/tmp
- 発生していたエラーメッセージ
- Rubyとしては、
TMPDIR
のボリュームマウント先が信用できないから、フォールバックして別のディレクトリを探す -
/tmp
を読み込んだ結果、OSの判断で書き込めると判断 - 書き込める判断をしたので
/tmp
にプロセス立ち上げ時にpid等を作成する - 書き込めずにEROFSが発生して起動しない
実装
Rubyのコードに書き込める一時ディレクトリを探すコードが含まれています。
※ Rubyのバージョンによって、少々実装が異なることがあるので、正確なコードを知りたいときはバージョン指定してください。
- https://github.com/ruby/ruby/blob/master/lib/tmpdir.rb
- https://github.com/ruby/ruby/blob/ruby_3_3/lib/tmpdir.rb
- https://github.com/ruby/ruby/blob/master/ext/etc/etc.c#L737
こちらのコードを見ると次の読み込み順序であることがわかります。
- 環境変数
TMPDIR
- 環境変数
TMP
- 環境変数
TEMP
- システムの一時ディレクトリパス(
systmpdir
) - 固定値の
/tmp
ディレクトリ - カレントディレクトリ(
.
)
def self.tmpdir
Tmpname::TMPDIR_CANDIDATES.find do |name, dir|
unless dir
next if !(dir = ENV[name] rescue next) or dir.empty?
end
dir = File.expand_path(dir)
stat = File.stat(dir) rescue next
case
when !stat.directory?
warn "#{name} is not a directory: #{dir}"
# NOTE: ここの判断ロジックがRuby 3.3まではこうだった。Ruby 3.4 以降、File.writable?を使用した下のロジックになる
# when !stat.writable?
when !File.writable?(dir)
# We call File.writable?, not stat.writable?, because you can't tell if a dir is actually
# writable just from stat; OS mechanisms other than user/group/world bits can affect this.
warn "#{name} is not writable: #{dir}"
when stat.world_writable? && !stat.sticky?
warn "#{name} is world-writable: #{dir}"
else
break dir
end
end or raise ArgumentError, "could not find a temporary directory"
end
module Tmpname # :nodoc:
module_function
# System-wide temporary directory path
systmpdir = (defined?(Etc.systmpdir) ? Etc.systmpdir.freeze : '/tmp')
# Temporary directory candidates consisting of environment variable
# names or description and path pairs.
TMPDIR_CANDIDATES = [
'TMPDIR', 'TMP', 'TEMP',
['system temporary path', systmpdir],
%w[/tmp /tmp],
%w[. .],
].each(&:freeze).freeze
end
static VALUE
etc_systmpdir(VALUE obj)
{
#ifdef _WIN32
WCHAR path[MAX_PATH];
DWORD len = GetTempPathW(MAX_PATH, path);
if (len) {
VALUE tmpdir = rb_w32_conv_from_wchar(path, len);
rb_enc_associate(tmpdir, rb_filesystem_encoding());
return tmpdir;
}
#else
const char *tmpdir;
/* Unix */
tmpdir = getenv("TMPDIR");
if (!tmpdir) {
# if defined(P_tmpdir)
tmpdir = P_tmpdir;
# else
tmpdir = "/tmp";
# endif
}
return rb_str_new_cstr(tmpdir);
#endif
return rb_str_new_cstr("/tmp");
}
なお、今回の事象を受けて改めて調べたところ、ファイル書き込み権限の確認がRuby 3.3では!stat.writable?
だったのが、Ruby 3.4から!File.writable?(dir)
に変更されていたので、同じ事象でもバージョンが違えばまた違うエラーメッセージが発生していたかもしれません。おそらく、system temporary path is not writable: /tmp
等が発生したのち、最終的に書き込めずにcould not find a temporary directory
が発生していたと思われます。
stat と Fileの違い
Rubyにおけるstat.writable?
とFile.writable?(dir)
の違いは、一見すると似たような機能に見えますが、実際には重要な違いがあります。
stat.writable? の挙動
stat.writable?
メソッドは、ファイルやディレクトリのパーミッションビット(ユーザー、グループ、その他)のみをチェックします。これは単純にファイルシステムのメタデータを確認する方法で、以下のような特徴があります:
- ファイルシステムのパーミッションビットのみに基づいて判断
- 現在のユーザーの実効UID(euid)と対象のファイル所有者を比較
- 基本的なUNIXパーミッションモデルに準拠
- マウントオプションや特殊なファイルシステム属性を考慮しない
例えば、readonlyRootFilesystem: true
で設定されたECSコンテナでは、ファイルシステムレベルでの書き込み禁止は、通常のパーミッションビットには反映されないため、stat.writable?
はtrue
を返す可能性があります。
File.writable? の挙動
対照的に、File.writable?
は実際のファイルシステム操作の可能性をより正確に反映します:
- 実際にファイルシステムに対する書き込み権限をOSに問い合わせる
- マウントオプション(readonly mountなど)を考慮する
- ファイルシステムの種類や特性を考慮する
- SELinux、AppArmor、Capabilities、Namespaceなど、より高度なセキュリティメカニズムも反映
Ruby 3.4でこの変更が行われたことは、コンテナ環境やクラウド環境など、複雑なセキュリティコンテキストでRubyを使用する場合により正確に動作するようになったことを意味します。
実際の影響
この違いは、特に以下のような環境で顕著になります:
- コンテナ環境(Docker、Kubernetes、ECSなど)
- readonlyマウントされたファイルシステム
- オーバーレイファイルシステム
- 高度なLinuxセキュリティメカニズムを使用している環境
Ruby 3.3までは、これらの環境で「書き込みできると思ったのに実際には書き込めない」という状況が発生する可能性がありましたが、Ruby 3.4ではより正確な判断が可能になりました。
この改善は、Ruby言語が現代のインフラストラクチャ環境により適応していることを示す良い例です。開発者としては、アプリケーションをどのRubyバージョンで実行するかによって、一時ディレクトリの挙動が変わる可能性があることを認識しておくべきでしょう。
ソースコード
なし。
終わりに
運用中に急にEROFS
が発生してびっくりしました。自分の経験上、このエラーが発生するときはファイルシステムの容量がマックスになって、何も書き込めなくなり、アプリから応答がなくなるクリティカルな障害だと思うのですが、普通に操作ができていたので不審に思っていたのです。
精神衛生上は良くない事象だったのですが、Rubyの挙動をさらに知れて良かったです。
Discussion