はじめに
こんにちは、新人エンジニアの松下です。
SAM を使った研修課題に取り組んでいたところ、コンテナビルドでシンボリックリンクを含むプロジェクトがエラーになる問題に遭遇しました。
エラーメッセージには openat2 ...libs: not a directory と表示され、一見 Docker やコンテナランタイムの根本的な制約に見えます。
しかし調査の結果、真の原因は Ubuntu パッケージの runc 1.3.3 のバグでした。
この記事では、エラーの発生から誤った仮説を立てて回り道した経緯、根本原因の特定、そして解決策までをまとめます。
対象読者
- WSL2 / Ubuntu 環境で SAM CLI のコンテナビルドを利用している方
sam build --use-container --mount-symlinksでエラーが出て困っている方
環境
| 項目 | バージョン |
|---|---|
| OS | WSL2(Ubuntu 24.04) |
| SAM CLI | 1.155.2 |
| Docker | 28.2.2 |
| runc | 1.3.3-0ubuntu1~24.04.3 |
背景: SAM CLI v1.133.0 でのセキュリティ修正
AWS SAM CLI(以下、SAM CLI)v1.133.0(2025年2月リリース)で、脆弱性 CVE-2025-3047 への対応がおこなわれました。
この脆弱性は、sam build --use-container の実行時にプロジェクト内のシンボリックリンクを経由してホスト上の制限されたファイルにアクセスできるというディレクトリトラバーサル(パストラバーサル)の問題です。
修正により、コンテナビルド時にシンボリックリンクがデフォルトで無視されるようになりました。
シンボリックリンクを使用しているプロジェクトでは、明示的に --mount-symlinks オプションを指定する必要があります。
sam build --use-container --mount-symlinks
発生したエラー
関数ディレクトリ内に共通ライブラリ(libs/)と設定ファイル(settings.py)へのシンボリックリンクがあるプロジェクトで、上記コマンドを実行したところ以下のエラーが発生しました。
error mounting "<プロジェクトディレクトリ>/common/libs" to rootfs at "/tmp/samcli/source/libs": create mountpoint for /tmp/samcli/source/libs mount: make mountpoint "...libs": openat2 ...libs: not a directory
SAM CLI はシンボリックリンクを解決し、リンク先の実体をコンテナ内にバインドマウントしようとしています。しかし not a directory というエラーで失敗しています。
誤った仮説と暫定的な回避策
エラーメッセージから、当時は以下のように解釈しました。
Docker のバインドマウントでは、マウント先がシンボリックリンク(ファイル)の場合、その上にディレクトリをマウントできない。
つまり --mount-symlinks はディレクトリ向けシンボリックリンクには対応していない
— これは間違いでした。
この解釈のもと、回避策としてビルド前にシンボリックリンクを実体コピーに置き換え、ビルド後に自動で復元するスクリプトを作成しました。
#!/bin/bash
# sam-build.sh - ビルド前にシンボリックリンクを解決し、ビルド後に復元する
set -euo pipefail
PROJECT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
FUNCTIONS_DIR="$PROJECT_DIR/src/functions"
# 関数ディレクトリ内のシンボリックリンクを検出
SYMLINKS=()
while IFS= read -r -d '' link; do
SYMLINKS+=("$link")
done < <(find "$FUNCTIONS_DIR" -maxdepth 2 -type l -print0)
if [ ${#SYMLINKS[@]} -eq 0 ]; then
echo "No symlinks found, running sam build directly."
sam build --use-container "$@"
exit $?
fi
echo "==> Resolving ${#SYMLINKS[@]} symlinks before container build..."
# シンボリックリンクのターゲットを保存し、実体コピーに置き換え
declare -A SYMLINK_TARGETS
for link in "${SYMLINKS[@]}"; do
target="$(readlink -f "$link")"
SYMLINK_TARGETS["$link"]="$(readlink "$link")"
rm "$link"
cp -r "$target" "$link"
echo " $link -> copied from $target"
done
# 終了時(成功・失敗問わず)にシンボリックリンクを復元
restore_symlinks() {
echo "==> Restoring symlinks..."
for link in "${SYMLINKS[@]}"; do
rm -rf "$link"
ln -s "${SYMLINK_TARGETS[$link]}" "$link"
echo " $link -> ${SYMLINK_TARGETS[$link]}"
done
}
trap restore_symlinks EXIT
echo "==> Running: sam build --use-container $*"
sam build --use-container "$@"
このスクリプトで --mount-symlinks を使わずにビルドを通すことはできました。ただし、これはあくまで暫定的な回避策です。
なお、このスクリプトは src/functions 直下のシンボリックリンク(maxdepth 2)のみを対象にしています。ネストが深いプロジェクトでは調整が必要です。
真の原因: runc 1.3.3 のバグ
SAM CLI や Docker はオープンソースプロジェクトなので、こうした問題は Issue で報告・修正されているのではないかと考え、後日改めて調査しました。
すると、SAM CLI の Issue #8464 と runc の Issue #5007 で同様の問題が報告されていることがわかりました。
runc 1.3.3 で何が変わったのか
runc 1.3.3(2025年11月リリース)では、3 件の深刻な脆弱性が修正されました。リリースノートには以下のように記載されています。
This release contains fixes for three high-severity security vulnerabilities in runc (CVE-2025-31133, CVE-2025-52565, and CVE-2025-52881). All three vulnerabilities ultimately allow (through different methods) for full container breakouts by bypassing runc's restrictions for writing to arbitrary
/procfiles.(訳: 今回のリリースには、runc における 3 つの深刻なセキュリティ脆弱性(CVE-2025-31133、CVE-2025-52565、CVE-2025-52881)への修正が含まれています。これら 3 つの脆弱性は、それぞれ手法は異なりますが、いずれも任意の
/procファイルへの書き込みを制限する runc の機能を回避し、最終的にはコンテナからの完全な脱出(ブレイクアウト)を許してしまう恐れがあります。)
この修正の一環として、ファイルパスの検証に openat2 システムコールが積極的に使われるようになりました。
openat2 は RESOLVE_BENEATH フラグにより指定ディレクトリの外に出るパスを拒否できる、セキュリティ面で強力なシステムコールです。
バグのメカニズム
問題は、openat2 の利用可否チェックのタイミングにありました。
runc の Issue #5007 で、@lifubang 氏が最初に原因の核心を突きました。
Might this be caused by
seccomp filters? We callHasOpenat2just once.(訳: これ、
seccomp filtersが原因だったりしませんか?HasOpenat2を呼び出しているのは一度だけなのですが。)
runc メンテナーの @cyphar 氏がこれを受けて確認し、問題を特定しました。
CloseExecFromis called after we apply seccomp (and presumably you are using a custom profile which doesn't permitopenat2) -- I think you might be right @lifubang...(訳:
CloseExecFromは seccomp を適用した後に呼び出されます(おそらくopenat2を許可していないカスタムプロファイルを使用しているのでしょう)。@lifubang、君の言う通りかもしれない……)
さらに、修正 PR(filepath-securejoin #82)で @cyphar 氏が問題を正確に説明しています。
Programs like runc apply seccomp filters to themselves during execution, and so it is possible that subsequent calls into pathrs-lite could result in errors because pathrs-lite thinks that openat2(2) is permitted but the new seccomp filter denies it. This would result in ENOSYS (or other errors) being returned from the openat2(2) resolver rather than using the fallback.
(訳: runc のようなプログラムは、実行中に自身に対して seccomp フィルタを適用します。そのため、その後に pathrs-lite を呼び出すと、エラーが発生する可能性があります。これは、pathrs-lite 側では openat2(2) の使用が許可されていると判断していても、新しく適用された seccomp フィルタがそれを拒否してしまう場合があるためです。この状況に陥ると、フォールバック(代替手段)が実行される代わりに、openat2(2) のリゾルバから ENOSYS(あるいはその他のエラー)が返されることになります。)
これを踏まえて、バグの流れを整理すると以下のようになります。
コンテナ起動の流れ(runc 1.3.3) 1. runc が起動する 2. openat2 が使えるかチェック → 「使える」とキャッシュ(HasOpenat2 の結果) ※ この時点では seccomp フィルタは未適用 3. runc 自身に seccomp フィルタが適用される ※ Docker のデフォルト seccomp プロファイルでは openat2 は許可されていない 4. パス解決処理(filepath-securejoin)で openat2 を呼び出す → キャッシュは「使える」なので openat2 を実行 → seccomp にブロックされて ENOSYS が返る → フォールバックされずにエラー ✗
たとえるなら、「入館証を発行してもらった後にセキュリティゲートのルールが変わったのに、古い入館証のまま通ろうとして弾かれる」ような状況です。
チェックした時点と実際に使う時点で状況が変わっているのに、最初のチェック結果をそのまま信じてしまうというバグでした。
エラーメッセージの openat2 ...libs: not a directory は、openat2 が seccomp にブロックされた結果のエラーであり、「ディレクトリではない」という文言は誤解を招く表現でした。
runc 1.3.4 での修正
runc 1.3.4(2025年11月27日リリース)で、この問題が修正されました。リリースノートには以下のように記載されています。
This is the fourth patch release of the 1.3.z release series of runc, and primarily contains a few fixes for some regressions introduced in 1.3.3.
Fixed - Fix various file descriptor leaks and add additional tests to detect them as comprehensively as possible. (#5007, #5021, #5034)
(訳: 今回のリリースは、runc 1.3.z シリーズにおける 4 番目のパッチリリースです。主に、バージョン 1.3.3 で発生したいくつかのデグレ(以前の修正による不具合)への対策が含まれています。
修正内容:
ファイル記述子(ファイルディスクリプタ)のさまざまなリーク問題を修正しました。また、これらを可能な限り網羅的に検知できるよう、テスト項目を追加しました。(#5007, #5021, #5034))
具体的には、HasOpenat2 の結果をキャッシュしないように変更され(filepath-securejoin PR #82)、openat2 が seccomp でブロックされた場合に openat ベースのフォールバックが正しく動作するようになりました。
runc 1.3.2 へのダウングレードは非推奨
「以前動いていた 1.3.2 に戻せばよいのでは?」と考えるかもしれません。
しかし、1.3.3 で修正された 3 件の脆弱性(CVE-2025-31133 など)はいずれもコンテナブレイクアウトに直結する深刻なものです。
ダウングレードするとこれらの脆弱性が再び露出するため、セキュリティの観点から非推奨です。
解決策の比較
| 方法 | 概要 | メリット | デメリット | 推奨度 |
|---|---|---|---|---|
| runc 1.3.4 に更新 | proposed リポジトリから取得 | 根本解決。セキュリティ修正も含まれる | proposed リポジトリの利用が必要 | 開発環境では推奨 |
| ビルド前スクリプト | シンボリックリンクを実体コピーに置き換え | runc の変更不要 | 暫定的な回避策に留まる | runc 更新ができない環境向け |
| runc 1.3.2 にダウングレード | openat2 採用前に戻す | 正式リポジトリのパッケージ | CVE-2025-31133 など 3 件の脆弱性が未修正に戻る | 非推奨 |
proposed リポジトリについて
2026年3月時点では、Ubuntu 24.04 の正式リポジトリにはまだ runc 1.3.4 がありません。proposed リポジトリはテスト段階のパッケージが含まれるリポジトリです。
本番環境やお客様環境への適用は、正式リポジトリへの昇格を待つことを推奨します。
ローカル開発環境や CI 環境であれば、proposed からの取得は現実的な選択肢です。
対処手順
方法 1: runc を 1.3.4 に更新する(推奨)
# proposed リポジトリを一時的に追加 echo "deb http://archive.ubuntu.com/ubuntu noble-proposed main universe" \ | sudo tee /etc/apt/sources.list.d/noble-proposed.list sudo apt update # runc を更新 sudo apt install -t noble-proposed runc # proposed リポジトリを無効化(他のパッケージが影響を受けないように) sudo rm /etc/apt/sources.list.d/noble-proposed.list sudo apt update # Docker を再起動 sudo systemctl restart docker # バージョン確認 runc --version # → runc version 1.3.4-0ubuntu1~24.04.1
更新後、以下のコマンドが正常に動作します。
sam build --use-container --mount-symlinks
方法 2: ビルド前スクリプトを使う
runc を更新できない環境では、前述の sam-build.sh を使用します。
--mount-symlinks は不要です。
./scripts/sam-build.sh
まとめ
- SAM CLI v1.133.0 以降、セキュリティ修正(CVE-2025-3047)によりコンテナビルド時にシンボリックリンクがデフォルトで無視されるようになった
--mount-symlinksを付けてもエラーになる場合、runc 1.3.3 のバグ(Issue #5007)が原因の可能性がある- runc 1.3.4 に更新することで解決する
- エラーメッセージ
not a directoryは実際の原因(seccomp による openat2 のブロック)を正確に表していない。
エラーメッセージに惑わされず、関連 Issue を確認することが重要