💎

Ruby 3.3と3.4での一時ディレクトリ探索の違いとECSでの影響

に公開

始めに

某日、ECSでスケールアップで起動しようとしていたRailsのアプリケーションが起動しませんでした。この起きていた不具合を調査する過程で一時ディレクトリに対するRubyの挙動を知ったことをブログにします。

なお、今回のケースは時間経過で自動解決しているため、ある程度状況には仮説も入っていますが、次のようなことが発生していたと思われます。

環境

  • AWS
    • ECS
  • Ruby
    • 3.3
    • 3.4

状況(仮説を含む)

事前状況

  1. ECSでセキュリティのためにreadonlyRootFilesystem: trueを指定して、基本的にファイルを書き込めないようにしている
  2. 環境変数TMPDIRにボリュームマウント先の/rails/tmpを指定する

発生時状況

  1. ECSのスケールアップ時でRailアプリケーションが起動しようとする
  2. AWS側でRailsアプリケーションのボリュームマウント先のアクセス制御を失敗していた
    1. 発生していたエラーメッセージ
      1. TMPDIR is world-writable: /rails/tmp
  3. Rubyとしては、TMPDIRのボリュームマウント先が信用できないから、フォールバックして別のディレクトリを探す
  4. /tmpを読み込んだ結果、OSの判断で書き込めると判断
  5. 書き込める判断をしたので/tmpにプロセス立ち上げ時にpid等を作成する
  6. 書き込めずにEROFSが発生して起動しない

実装

Rubyのコードに書き込める一時ディレクトリを探すコードが含まれています。

※ Rubyのバージョンによって、少々実装が異なることがあるので、正確なコードを知りたいときはバージョン指定してください。

こちらのコードを見ると次の読み込み順序であることがわかります。

  1. 環境変数TMPDIR
  2. 環境変数TMP
  3. 環境変数TEMP
  4. システムの一時ディレクトリパス(systmpdir
  5. 固定値の/tmpディレクトリ
  6. カレントディレクトリ(.
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を使用する場合により正確に動作するようになったことを意味します。

実際の影響

この違いは、特に以下のような環境で顕著になります:

  1. コンテナ環境(Docker、Kubernetes、ECSなど)
  2. readonlyマウントされたファイルシステム
  3. オーバーレイファイルシステム
  4. 高度なLinuxセキュリティメカニズムを使用している環境

Ruby 3.3までは、これらの環境で「書き込みできると思ったのに実際には書き込めない」という状況が発生する可能性がありましたが、Ruby 3.4ではより正確な判断が可能になりました。
この改善は、Ruby言語が現代のインフラストラクチャ環境により適応していることを示す良い例です。開発者としては、アプリケーションをどのRubyバージョンで実行するかによって、一時ディレクトリの挙動が変わる可能性があることを認識しておくべきでしょう。

ソースコード

なし。

終わりに

運用中に急にEROFSが発生してびっくりしました。自分の経験上、このエラーが発生するときはファイルシステムの容量がマックスになって、何も書き込めなくなり、アプリから応答がなくなるクリティカルな障害だと思うのですが、普通に操作ができていたので不審に思っていたのです。

精神衛生上は良くない事象だったのですが、Rubyの挙動をさらに知れて良かったです。

Discussion