QNAP NAS(QTS 5.2.10)でUbuntu 24.04のコンテナをビルドするとsystemdとrsyncが落ちる問題と回避策
事務所の業務用スクリプトは、QNAP NASのContainer Station上で、自前でビルドしたUbuntu 22.04ベースのコンテナで動かしています。
このコンテナを24.04(noble)に上げようとしたところ、docker buildがsystemdのpostinstで止まりました。ビルドを通しても、今度は実行時にrsync -aが落ちます。
原因を調べていくと、DockerfileでもaptでもなくQNAPのカーネルにありました。QNAPのカーネルは、upstreamのsyscall番号451〜455に独自のsyscallを割り当てていたのです。
切り分けの経緯と、最終的に採った回避策(ビルド側の2点と、実行時のseccompプロファイル)をメモしておきます。
環境
QTS:5.2.10.3577
Container Station:3.1.2.1742
Docker Engine:27.1.2-qnap8(containerd 1.7.20、runc 1.1.13)
カーネル:5.10.60-qnap
移行元と移行先:ubuntu:22.04 から ubuntu:24.04
コンテナはsupervisordで常駐デーモンをまとめて動かす構成で、systemdは起動していません。それでもcron、cups、libreofficeの依存関係でsystemdパッケージはインストールされます。
症状
ビルド時にsystemdのpostinstがexit 1で終わる
fchmod() of /etc/credstore failed: No such file or directory
Creating group 'systemd-journal' with GID 999.
Creating user 'systemd-network' (systemd Network Management) with UID 998 and GID 998.
Failed to copy permissions from /etc/group to /etc/.#group3602cef3edff7bbf: No such file or directory
dpkg: error processing package systemd (--configure):
installed systemd package post-installation script subprocess returned error exit status 1
docker run --rm ubuntu:24.04 で apt-get install systemd を実行するだけで再現します。22.04では通ります。
ビルド時にgcloud SDKのtar展開で「Cannot change mode」が出る
tar: google-cloud-sdk/platform/bundledpythonunix/bin/python: Cannot change mode to rwxrwxrwx: Bad address
失敗するのはシンボリックリンク(1052本)とディレクトリだけで、通常のファイルは展開できています。
実行時にrsync -aとPythonのos.chmod(follow_symlinks=False)が落ちる
rsync: [generator] failed to set permissions on "/t2/d": Bad address (14)
rsync error: some files/attrs were not transferred (see previous errors) (code 23)
OSError: [Errno 14] Bad address: '/s/d/f'
cp -aとinstall -mは問題なく動きます。22.04ではすべて通ります。
最初に疑ったもの
最初に3つの仮説を立てましたが、いずれも実際に測って否定しました。
1つ目はseccompとAppArmorです。--security-opt seccomp=unconfined とし、AppArmorもunconfinedにしても同じように失敗しました。後で説明するとおり、unconfinedにしても結果は変わらず、既定プロファイルの防御が外れるだけです。
2つ目は、古いDockerでglibc 2.38以降が全く動かなくなることで知られるclone3のEPERM問題です。コンテナ内で測るとclone3はENOSYSを返し、glibcはcloneへのfallbackができていました。Docker 27の既定プロファイルはclone3をerrnoRet: 38でENOSYSにする設計なので、この問題が起きる条件がそろいません。
3つ目はaptの依存解決とi386のindexです。必要なパッケージはすべてnobleのindexに同じ名前で存在していました。違いはlibmagic1からlibmagic1t64への改名だけで、これもProvidesで解決されます。i386のindexも公開されています。失敗していたのは依存解決の段階ではなく、dpkgの設定段階でした。
原因はsyscall 451〜455の番号の衝突
コンテナ内からsyscall(2)で番号を直接呼ぶと、次の結果になりました。Dockerの既定seccompプロファイルの下で測った値です。左から、番号、upstream(x86_64)での名前、upstreamで追加された版、既定seccomp下での応答の順です。
445〜449 landlock_*、memfd_secret、process_mrelease、futex_waitv(5.13〜5.16) ENOSYS(正常。5.10には無い)
450 set_mempolicy_home_node(5.17) EPERM(seccompの既定拒否。カーネルの応答は未測定)
451 cachestat(6.5) EFAULT
452 fchmodat2(6.6) EFAULT(AT_EMPTY_PATH形ではENOENT)
453 map_shadow_stack(6.6) EBADF
454 futex_wake(6.7) EBADF
455 futex_wait(6.7) EINVAL
456〜 futex_requeue以降(6.7〜) ENOSYS(正常)
450のEPERMはカーネルではなくseccompの応答です。moby v27.1.2の既定プロファイルでは、set_mempolicy_home_nodeはCAP_SYS_NICEを持つ場合にだけ許可されます。既定のdocker runにはこのcapabilityが無いので、既定の拒否(defaultErrnoRet: 1)でEPERMが返ります。450についてカーネル自身がどう応答するかは測っていません。
451〜455の5つは既定プロファイルで無条件に許可されているので、上の値はカーネルの応答です。
5.10のカーネルなら、450以降はすべてENOSYSを返すはずです。QNAPは5.10に独自syscallを追加する際、当時upstreamでまだ使われていなかった番号を使ったと見られ、少なくとも451〜455がそれに当たります。カーネルが新しい番号を知っているかのように応答するので、呼び出し側は未実装だと判断できません。
問題になるのは452番のfchmodat2です。
systemd 255(src/basic/fs-util.c のfchmod_opath())は、O_PATHで開いたfdのmodeを変えるとき、まず fchmodat2(fd, "", m, AT_EMPTY_PATH) を呼びます。/proc/self/fd/N 経由のchmodへfallbackするのは、errnoがENOSYSかEPERMのときだけです。このカーネルではENOENTが返るので、systemdは本当のエラーとして扱います。その結果、sysusersが/etc/groupを書けず、postinstが落ちます。
glibc 2.39(sysdeps/unix/sysv/linux/fchmodat.c)も、fchmodat(…, AT_SYMLINK_NOFOLLOW) ではまずfchmodat2を呼び、ENOSYSのときだけO_PATHと/procを使う方法へfallbackします。lchmod()はこの経路を呼ぶだけの薄いラッパです。
この関数を使っているのが、rsyncの-p(-aに含まれます)です。rsyncはlchmodを呼びます。GNU tar 1.35も、シンボリックリンクと、展開の最後にまとめて行うディレクトリのmode設定でfchmodat(AT_SYMLINK_NOFOLLOW)を使います。Pythonのos.chmod(follow_symlinks=False)も同じ関数です。
22.04のglibc 2.35とsystemd 249はfchmodat2を呼ばないので、問題が出ませんでした。つまり、この問題は24.04固有ではなくカーネル固有です。glibc 2.39以上とsystemd 255以上を含む26.04にも同じ経路があります(26.04は未検証です)。
手元で確かめる方法
docker run --rm ubuntu:24.04 bash -c 'apt-get update -qq && apt-get install -y -qq --no-install-recommends python3 >/dev/null 2>&1; python3 -c "
import ctypes
l = ctypes.CDLL(None, use_errno=True)
for n in range(445, 458):
l.syscall(n, -100, b\"\", 0, 0); print(n, ctypes.get_errno())
既定のseccompプロファイルの下では、素の5.10でも450は1(EPERM)になり、それ以外の445以降は38(ENOSYS)になります。QTS 5.2.10では、451〜455に14、2、9、9、22が並びます。
452は空のパスを渡しているので2(ENOENT)です。実在するパスを渡すと14(EFAULT)になり、いずれにしても38にはなりません。この記事の環境での実測結果は次のとおりです。
445 38 446 38 447 38 448 38 449 38 450 1 451 14 452 2 453 9 454 9 455 22 456 38 457 38
回避策
実行時とビルド時で手段を分けたのは、docker build(BuildKit)ではRUNの段階でseccompプロファイルを差し替えられないためです。--security=insecure を使うとunconfinedになりますが、452はもともと既定プロファイルの下でもカーネルまで届いているので、結果は変わりません。外れるのは既定プロファイルの防御だけです。
実行時はseccompプロファイルで450〜455をENOSYSに見せる
Dockerの既定プロファイル(moby v27.1.2の profiles/seccomp/default.json)をもとに、6つのsyscall名をallowリストから外し、SCMP_ACT_ERRNOとerrnoRet: 38のルールを1つ追加します。既定プロファイル自身もclone3を同じ形式でENOSYSにしているので、Docker側でも使われている書き方です。
VER=27.1.2 # docker version --format '{{.Server.Version}}' の先頭に合わせる
T=$(mktemp) && curl -fsSL -o "$T" "https://raw.githubusercontent.com/moby/moby/v${VER}/profiles/seccomp/default.json" && \
python3 - "$T" << 'PY'
import json, sys
NAMES = ["set_mempolicy_home_node", "cachestat", "fchmodat2", "map_shadow_stack", "futex_wake", "futex_wait"]
p = json.load(open(sys.argv[1]))
for r in p["syscalls"]:
r["names"] = [n for n in r["names"] if n not in NAMES]
p["syscalls"].append({"names": NAMES, "action": "SCMP_ACT_ERRNO", "errnoRet": 38,
"comment": "QNAP kernel 5.10.60-qnap assigns vendor syscalls to 450-455; report ENOSYS as an upstream 5.10 kernel would"})
with open("seccomp-qnap-kernel510.json", "w") as f:
json.dump(p, f, indent="\t"); f.write("\n")
PY
docker run --rm --security-opt seccomp=$PWD/seccomp-qnap-kernel510.json ubuntu:24.04 bash -c '...'
これでrsync -aも、Pythonのfollow_symlinks=Falseも通るようになりました。実測では4項目ともrc=0で、modeも保持されていました。
451〜455については、upstreamの5.10が本来返す値に戻すだけなので、将来QNAPがカーネルを更新して本物の451〜455を持つようになっても害はありません。ENOSYSが返れば、呼び出し側がfallbackを選ぶだけです。
6つの中には450(set_mempolicy_home_node)も入れています。450はもともと既定プロファイルの下でもカーネルに届いていないので、このプロファイルで変わるのはerrnoの値(1から38)だけです。残しても害はありません。
allowを全開にしてdenylistだけを書く小さなプロファイルにすれば、Dockerのバージョンに合わせて作り直す必要はなくなります。ただ、その場合は既定プロファイルが塞いでいるmount、kexec_load、bpfなどへの防御を捨てることになります。そのため、既定プロファイルをコピーして6つの名前だけを変える方法にしました。Container Stationの更新でDockerのバージョンが変わったら、同じバージョンの既定プロファイルから作り直します。
ビルド時の対策(1)sysusersが作るユーザーを先に作っておく
systemd-sysusersは、作るべきユーザーやグループがすでに存在していれば/etc/groupを書き換えません。そのため、失敗する経路を通らずに済みます。
nobleのsystemdパッケージはsystemd-journal(グループ)とsystemd-network(ユーザーとグループ)を作り、cron-daemon-commonはcrontab(グループ)を作ります。そこで、aptの前に次のレイヤを1つ追加します。
RUN groupadd -g 999 systemd-journal && \
groupadd -g 998 systemd-network && \
useradd -u 998 -g 998 -r -M -s /usr/sbin/nologin systemd-network && \
groupadd -r crontab
|| true は付けていません。衝突した場合は0以外で止まり、ビルドログに理由が残るからです。
なお、systemd-tmpfiles --create は || true 付きで実行され、失敗しても止まりません。そのため、tmpfiles.dが指定するmodeのうちmkdirの既定と違うもの(/etc/credstoreの700など)は、適用されないまま残ります。cronはpostinstが自分でchmod 1730するので問題ありません。
ビルド時の対策(2)tarの展開をPython標準のtarfileに替える
GNU tar 1.35は、シンボリックリンクと、最後にまとめて行うディレクトリのmode設定でfchmodat(AT_SYMLINK_NOFOLLOW)を呼びます。標準ライブラリのtarfileはシンボリックリンクをchmodせず、ファイルとディレクトリにはflag 0のchmodを使うので、仕組み上この経路を通りません。
RUN curl -O https://dl.google.com/dl/cloudsdk/channels/rapid/downloads/google-cloud-cli-linux-x86_64.tar.gz && \
python3.12 -m tarfile --filter=fully_trusted -e google-cloud-cli-linux-x86_64.tar.gz / && \
rm google-cloud-cli-linux-x86_64.tar.gz
展開結果は、GNU tarで展開したものと型、mode、所有者、シンボリックリンク先がすべて一致しました(37,190エントリを22.04で比較)。
--filter=fully_trusted は、GNU tarと同じく何も加工せずに展開する指定で、Python 3.12の既定と同じ動作です。警告を消すために明示しています。
noble固有の注意点(base imageのubuntuユーザー)
これはカーネルとは関係ありませんが、24.04のbase imageには ubuntu:x:1000:1000 が最初から入っています。NASのファイル所有権に合わせてuid 1000でユーザーを作る構成では、useraddが「UID 1000 is not unique」で失敗します。先に userdel -r ubuntu しておきます。
結果
上記の対策を入れたdocker buildは、すべてのレイヤを通過しました(約5分)。
--security-opt seccomp= を付けたスモークテストでは、rsync -aでmodeが保持されること、follow_symlinks=Falseが成功すること、syscall(452)が38を返すことを確認しています。BrotherとCanonのプリンタドライバ(i386の.debと、LIPSLXのinstaller)も、24.04上で導入まで通りました。
まとめ
QTS 5.2.10のカーネル(5.10.60-qnap)は、syscall 451〜455に独自のsyscallを載せていて、upstreamのfchmodat2(452)がそこに当たります。glibc 2.39以上とsystemd 255以上はfchmodat2を先に呼び、想定外のerrnoが返るとfallbackしません。22.04が無事で24.04が落ちるのはそのためで、26.04も同じ構造です(未検証)。
実行時は、docker runのseccompプロファイルで450〜455をENOSYSに見せれば直ります。既定プロファイルをコピーして6つの名前を変えるだけで済みます。
ビルド時は、BuildKitがプロファイルを差し替えられないので、sysusersのユーザーを事前に作ることと、tarをtarfileに替えることでしのいでいます。
seccomp=unconfinedは解決策になりません。452は既定プロファイルの下でもカーネルまで届いているので、unconfinedにしても結果は変わらず、既定プロファイルの防御が外れるだけです。