主题
本地媒体存储权限迁移(Named Volume / Bind Mount)
本流程只适用于 XHIM_MEDIA_STORAGE_DRIVER=local 的 Compose 开发、测试或 私有化部署。正式生产仍建议使用私有 S3-compatible 对象存储。
XHIM Server 以镜像内的非 root 用户 xhim 运行。新镜像会在切换用户前预建 /var/lib/xhim/media,因此新建的空 Named Volume 可以继承正确属主;但以下 两类已有存储不会被镜像层自动修复:
- 已存在的 Named Volume 会保留原有 UID、GID 和 Mode;
- Bind Mount 完全使用宿主机绝对路径的 UID、GID 和 Mode。
目录不可写时,媒体 PUT/PATCH 会失败。新版本 Server 会在启动以及 /health/ready 中执行 create + write + fsync + remove 探针,失败时启动失败 或返回 HTTP 503,但它不会自行变更宿主存储权限。
本流程只允许修改已经由运行容器核实的媒体挂载根目录及其第一层普通文件。 禁止对未知路径执行递归 chown/chmod,禁止跟随符号链接,禁止删除媒体文件, 禁止用 docker volume rm 解决权限问题。
1. 先确认挂载类型与候选运行身份
在停止服务前,记录当前 Server 容器和媒体挂载。XHIM_SERVER_CONTAINER 必须是 已核实的完整容器 ID 或唯一容器名,不能使用模糊匹配:
bash
XHIM_SERVER_CONTAINER=your_exact_server_container
docker inspect \
--format '{{range .Mounts}}{{if eq .Destination "/var/lib/xhim/media"}}{{printf "%s|%s|%s|%s|%t\n" .Type .Name .Source .Destination .RW}}{{end}}{{end}}' \
"$XHIM_SERVER_CONTAINER"输出必须且只能有一行:
text
volume|<volume-name>|<docker-volume-source>|/var/lib/xhim/media|true或:
text
bind||<absolute-host-path>|/var/lib/xhim/media|true如果没有输出、输出多行、目标不是 /var/lib/xhim/media、RW=false,或者类型 不是 volume/bind,立即停止,不要套用本文命令。
把本次只读观测记录到受访问控制的变更单中,格式如下:
text
Type: <volume-or-bind>
Source: <verified-volume-name-or-absolute-media-leaf>
Destination: /var/lib/xhim/media
Files: <first-level-regular-file-count>
Host owner: <current-uid>:<current-gid>
Host mode: <current-mode>
Container: uid=<candidate-xhim-uid> gid=<candidate-xhim-gid>公开文档和工单模板不得写入某个客户的真实绝对路径、文件数量或运行 UID/GID。 每次操作前必须重新执行 docker inspect、stat、文件计数和候选镜像身份 解析;不能把任何历史记录当作永久配置。
候选镜像应使用不可变 Digest,并从镜像本身解析 xhim 的数字 UID/GID:
bash
XHIM_CANDIDATE_IMAGE=registry.example.com/xhim-server@sha256:replace_me
XHIM_UID="$(
docker run --rm \
--entrypoint /bin/sh \
"$XHIM_CANDIDATE_IMAGE" \
-ceu 'id -u xhim'
)"
XHIM_GID="$(
docker run --rm \
--entrypoint /bin/sh \
"$XHIM_CANDIDATE_IMAGE" \
-ceu 'id -g xhim'
)"
case "$XHIM_UID" in
""|*[!0-9]*)
echo "invalid candidate xhim UID" >&2
exit 1
;;
esac
case "$XHIM_GID" in
""|*[!0-9]*)
echo "invalid candidate xhim GID" >&2
exit 1
;;
esac
printf 'candidate xhim UID:GID=%s:%s\n' "$XHIM_UID" "$XHIM_GID"如果候选镜像不能运行,应由发布系统从同一 Digest 的镜像配置中提取并签名记录 UID/GID,再由两人复核;禁止凭经验手填某个历史 <uid>:<gid>。
完成只读确认后进入维护窗口:
bash
docker compose stop gateway server停止后再次确认容器没有运行。备份、权限迁移和写探针期间不得有另一个 Server、 运维脚本或 Sidecar 写入同一媒体存储。
2. Named Volume 安全迁移
仅当第 1 节输出类型为 volume 时使用本节。直接采用 docker inspect 返回的 .Name,不要从 docker volume ls 猜测:
bash
XHIM_MEDIA_VOLUME=your_exact_volume_name
XHIM_MEDIA_BACKUP_DIR=/absolute/protected/xhim-media-backup
XHIM_MEDIA_BACKUP_FILE="$XHIM_MEDIA_BACKUP_DIR/named-volume-before-permission-migration.tgz"
test -n "$XHIM_MEDIA_VOLUME"
test "${XHIM_MEDIA_BACKUP_DIR#/}" != "$XHIM_MEDIA_BACKUP_DIR"
mkdir -p "$XHIM_MEDIA_BACKUP_DIR"
chmod 0700 "$XHIM_MEDIA_BACKUP_DIR"
test ! -e "$XHIM_MEDIA_BACKUP_FILE"先以只读方式检查根目录、拒绝非普通文件,并保存内容哈希和逐文件元数据:
bash
docker run --rm \
--mount "type=volume,src=$XHIM_MEDIA_VOLUME,dst=/source,readonly" \
--entrypoint /bin/sh \
"$XHIM_CANDIDATE_IMAGE" \
-ceu '
test -d /source
test ! -L /source
unexpected="$(
find /source -mindepth 1 -maxdepth 1 ! -type f -print -quit
)"
test -z "$unexpected" || {
echo "refusing unexpected media entry: $unexpected" >&2
exit 1
}
invalid_name="$(
find /source -mindepth 1 -maxdepth 1 -type f \
-print |
sed "s#^/source/##" |
grep -Ev "^[0-9a-f]{64}([.]partial)?$" |
sed -n "1p"
)"
test -z "$invalid_name" || {
echo "refusing invalid media filename: $invalid_name" >&2
exit 1
}
{
printf ".|"
stat -c "%u|%g|%a" /source
find /source -mindepth 1 -maxdepth 1 -type f \
-exec stat -c "%n|%u|%g|%a" "{}" + |
sed "s#^/source/##"
} | LC_ALL=C sort
' >"$XHIM_MEDIA_BACKUP_DIR/metadata.before"
docker run --rm \
--mount "type=volume,src=$XHIM_MEDIA_VOLUME,dst=/source,readonly" \
--entrypoint /bin/sh \
"$XHIM_CANDIDATE_IMAGE" \
-ceu '
find /source -mindepth 1 -maxdepth 1 -type f \
-exec sha256sum "{}" + |
sed "s# /source/# #" |
LC_ALL=C sort -k 2
' >"$XHIM_MEDIA_BACKUP_DIR/content-sha256.before"创建保留内容与元数据的只读来源备份:
bash
docker run --rm \
--mount "type=volume,src=$XHIM_MEDIA_VOLUME,dst=/source,readonly" \
--mount "type=bind,src=$XHIM_MEDIA_BACKUP_DIR,dst=/backup" \
--user 0:0 \
--entrypoint /bin/sh \
"$XHIM_CANDIDATE_IMAGE" \
-ceu '
tar -C /source -czpf \
/backup/named-volume-before-permission-migration.tgz .
'
sha256sum "$XHIM_MEDIA_BACKUP_FILE" \
>"$XHIM_MEDIA_BACKUP_FILE.sha256"
sha256sum -c "$XHIM_MEDIA_BACKUP_FILE.sha256"
tar -tzf "$XHIM_MEDIA_BACKUP_FILE" >/dev/null受控修改只挂载目标 Volume,不挂载 Docker Host 根文件系统。命令逐项修改第一层 普通文件,最后才修改媒体根目录;没有 -R:
bash
docker run --rm \
--mount "type=volume,src=$XHIM_MEDIA_VOLUME,dst=/media" \
--env XHIM_UID="$XHIM_UID" \
--env XHIM_GID="$XHIM_GID" \
--user 0:0 \
--entrypoint /bin/sh \
"$XHIM_CANDIDATE_IMAGE" \
-ceu '
test -d /media
test ! -L /media
unexpected="$(
find /media -mindepth 1 -maxdepth 1 ! -type f -print -quit
)"
test -z "$unexpected" || {
echo "refusing unexpected media entry: $unexpected" >&2
exit 1
}
invalid_name="$(
find /media -mindepth 1 -maxdepth 1 -type f \
-print |
sed "s#^/media/##" |
grep -Ev "^[0-9a-f]{64}([.]partial)?$" |
sed -n "1p"
)"
test -z "$invalid_name" || {
echo "refusing invalid media filename: $invalid_name" >&2
exit 1
}
find /media -mindepth 1 -maxdepth 1 -type f \
-exec chown "$XHIM_UID:$XHIM_GID" "{}" +
find /media -mindepth 1 -maxdepth 1 -type f \
-exec chmod 0600 "{}" +
chown "$XHIM_UID:$XHIM_GID" /media
chmod 0700 /media
'然后执行第 4 节公共验证。
3. 绝对 Bind Mount 安全迁移
仅当第 1 节输出类型为 bind 时使用本节。Bind Source 必须逐字复制自 docker inspect,并再次与预期部署路径核对:
bash
XHIM_MEDIA_BIND_SOURCE=/absolute/verified/media-leaf
XHIM_MEDIA_EXPECTED_FILES=replace_with_approved_count
XHIM_MEDIA_BACKUP_DIR=/absolute/protected/xhim-media-backup
XHIM_MEDIA_BACKUP_FILE="$XHIM_MEDIA_BACKUP_DIR/bind-before-permission-migration.tgz"
XHIM_INSPECTED_BIND_SOURCE="$(
docker inspect \
--format '{{range .Mounts}}{{if and (eq .Type "bind") (eq .Destination "/var/lib/xhim/media")}}{{.Source}}{{end}}{{end}}' \
"$XHIM_SERVER_CONTAINER"
)"
test "$XHIM_MEDIA_BIND_SOURCE" = "$XHIM_INSPECTED_BIND_SOURCE"
case "$XHIM_MEDIA_BIND_SOURCE" in
/*) ;;
*)
echo "bind source must be an absolute path" >&2
exit 1
;;
esac
test "$XHIM_MEDIA_BIND_SOURCE" != "/"
test "$(dirname "$XHIM_MEDIA_BIND_SOURCE")" != "/"
test -d "$XHIM_MEDIA_BIND_SOURCE"
test ! -L "$XHIM_MEDIA_BIND_SOURCE"
test "$(readlink -f -- "$XHIM_MEDIA_BIND_SOURCE")" = \
"$XHIM_MEDIA_BIND_SOURCE"
case "$XHIM_MEDIA_EXPECTED_FILES" in
""|*[!0-9]*)
echo "replace XHIM_MEDIA_EXPECTED_FILES with the approved count" >&2
exit 1
;;
esacreadlink -f 不相等表示路径自身或某一级父目录经过符号链接,必须停止并人工 核对真实挂载路径。可使用 namei -l "$XHIM_MEDIA_BIND_SOURCE" 逐层记录父目录 权限,但不得 chown/chmod 媒体叶子目录以外的任何父目录;受控 Helper Container 只会挂载并修改已经核实的媒体叶子目录。
再次拒绝任何子目录、符号链接、Socket、FIFO 或设备,并核对当前文件数、属主和 Mode。预期文件数必须来自维护窗口开始时重新批准的只读清单:
bash
unexpected="$(
find "$XHIM_MEDIA_BIND_SOURCE" \
-mindepth 1 -maxdepth 1 \
! -type f \
-print -quit
)"
test -z "$unexpected" || {
echo "refusing unexpected media entry: $unexpected" >&2
exit 1
}
actual_files="$(
find "$XHIM_MEDIA_BIND_SOURCE" \
-mindepth 1 -maxdepth 1 \
-type f \
-print |
wc -l |
tr -d " "
)"
test "$actual_files" = "$XHIM_MEDIA_EXPECTED_FILES"
stat -c 'bind root before: uid=%u gid=%g mode=%a path=%n' \
"$XHIM_MEDIA_BIND_SOURCE"备份目录必须是源目录之外的受保护绝对路径:
bash
test "${XHIM_MEDIA_BACKUP_DIR#/}" != "$XHIM_MEDIA_BACKUP_DIR"
case "$XHIM_MEDIA_BACKUP_DIR/" in
"$XHIM_MEDIA_BIND_SOURCE"/*)
echo "backup directory must not be inside media source" >&2
exit 1
;;
esac
case "$XHIM_MEDIA_BIND_SOURCE/" in
"$XHIM_MEDIA_BACKUP_DIR"/*)
echo "media source must not be inside backup directory" >&2
exit 1
;;
esac
mkdir -p "$XHIM_MEDIA_BACKUP_DIR"
chmod 0700 "$XHIM_MEDIA_BACKUP_DIR"
test "$(readlink -f -- "$XHIM_MEDIA_BACKUP_DIR")" = \
"$XHIM_MEDIA_BACKUP_DIR"
test ! -e "$XHIM_MEDIA_BACKUP_FILE"用候选镜像只读挂载精确叶子目录,保存逐文件 UID/GID/Mode 与内容哈希:
bash
docker run --rm \
--mount "type=bind,src=$XHIM_MEDIA_BIND_SOURCE,dst=/source,readonly" \
--entrypoint /bin/sh \
"$XHIM_CANDIDATE_IMAGE" \
-ceu '
test -d /source
test ! -L /source
unexpected="$(
find /source -mindepth 1 -maxdepth 1 ! -type f -print -quit
)"
test -z "$unexpected" || {
echo "refusing unexpected media entry: $unexpected" >&2
exit 1
}
invalid_name="$(
find /source -mindepth 1 -maxdepth 1 -type f \
-print |
sed "s#^/source/##" |
grep -Ev "^[0-9a-f]{64}([.]partial)?$" |
sed -n "1p"
)"
test -z "$invalid_name" || {
echo "refusing invalid media filename: $invalid_name" >&2
exit 1
}
{
printf ".|"
stat -c "%u|%g|%a" /source
find /source -mindepth 1 -maxdepth 1 -type f \
-exec stat -c "%n|%u|%g|%a" "{}" + |
sed "s#^/source/##"
} | LC_ALL=C sort
' >"$XHIM_MEDIA_BACKUP_DIR/metadata.before"
docker run --rm \
--mount "type=bind,src=$XHIM_MEDIA_BIND_SOURCE,dst=/source,readonly" \
--entrypoint /bin/sh \
"$XHIM_CANDIDATE_IMAGE" \
-ceu '
find /source -mindepth 1 -maxdepth 1 -type f \
-exec sha256sum "{}" + |
sed "s# /source/# #" |
LC_ALL=C sort -k 2
' >"$XHIM_MEDIA_BACKUP_DIR/content-sha256.before"创建完整备份并校验:
bash
docker run --rm \
--mount "type=bind,src=$XHIM_MEDIA_BIND_SOURCE,dst=/source,readonly" \
--mount "type=bind,src=$XHIM_MEDIA_BACKUP_DIR,dst=/backup" \
--user 0:0 \
--entrypoint /bin/sh \
"$XHIM_CANDIDATE_IMAGE" \
-ceu '
tar -C /source -czpf \
/backup/bind-before-permission-migration.tgz .
'
sha256sum "$XHIM_MEDIA_BACKUP_FILE" \
>"$XHIM_MEDIA_BACKUP_FILE.sha256"
sha256sum -c "$XHIM_MEDIA_BACKUP_FILE.sha256"
tar -tzf "$XHIM_MEDIA_BACKUP_FILE" >/dev/null受控修改使用 Root Helper Container,但只向它暴露精确媒体叶子目录,不暴露 /www 或宿主根目录。命令先逐个处理已核实的第一层普通文件,再处理根目录; 不会递归未知路径:
bash
docker run --rm \
--mount "type=bind,src=$XHIM_MEDIA_BIND_SOURCE,dst=/media" \
--env XHIM_UID="$XHIM_UID" \
--env XHIM_GID="$XHIM_GID" \
--env XHIM_MEDIA_EXPECTED_FILES="$XHIM_MEDIA_EXPECTED_FILES" \
--user 0:0 \
--entrypoint /bin/sh \
"$XHIM_CANDIDATE_IMAGE" \
-ceu '
test -d /media
test ! -L /media
unexpected="$(
find /media -mindepth 1 -maxdepth 1 ! -type f -print -quit
)"
test -z "$unexpected" || {
echo "refusing unexpected media entry: $unexpected" >&2
exit 1
}
invalid_name="$(
find /media -mindepth 1 -maxdepth 1 -type f \
-print |
sed "s#^/media/##" |
grep -Ev "^[0-9a-f]{64}([.]partial)?$" |
sed -n "1p"
)"
test -z "$invalid_name" || {
echo "refusing invalid media filename: $invalid_name" >&2
exit 1
}
actual_files="$(
find /media -mindepth 1 -maxdepth 1 -type f -print |
wc -l |
tr -d " "
)"
test "$actual_files" = "$XHIM_MEDIA_EXPECTED_FILES"
find /media -mindepth 1 -maxdepth 1 -type f \
-exec chown "$XHIM_UID:$XHIM_GID" "{}" +
find /media -mindepth 1 -maxdepth 1 -type f \
-exec chmod 0600 "{}" +
chown "$XHIM_UID:$XHIM_GID" /media
chmod 0700 /media
'不要在宿主机对媒体叶子目录的父目录执行递归 chown。候选镜像的 UID/GID 可能改变,而且父目录可能包含无关站点、证书和备份。
4. 公共验证与恢复服务
先以候选镜像的 xhim 数字身份只读核对所有对象元数据:
Named Volume
bash
XHIM_MEDIA_MOUNT="type=volume,src=$XHIM_MEDIA_VOLUME,dst=/media"Bind Mount
bash
XHIM_MEDIA_MOUNT="type=bind,src=$XHIM_MEDIA_BIND_SOURCE,dst=/media"将上面与实际类型匹配的值用于以下命令:
bash
docker run --rm \
--mount "$XHIM_MEDIA_MOUNT" \
--env XHIM_UID="$XHIM_UID" \
--env XHIM_GID="$XHIM_GID" \
--user 0:0 \
--entrypoint /bin/sh \
"$XHIM_CANDIDATE_IMAGE" \
-ceu '
test "$(stat -c %u /media)" = "$XHIM_UID"
test "$(stat -c %g /media)" = "$XHIM_GID"
test "$(stat -c %a /media)" = "700"
bad="$(
find /media -mindepth 1 -maxdepth 1 -type f \
\( ! -user "$XHIM_UID" -o ! -group "$XHIM_GID" -o ! -perm 0600 \) \
-print -quit
)"
test -z "$bad" || {
echo "unexpected media metadata: $bad" >&2
exit 1
}
'再以候选非 root 身份执行独占临时文件写探针。mktemp 只创建新的随机文件, trap 只清理该文件,不会匹配或删除已有对象:
bash
docker run --rm \
--mount "$XHIM_MEDIA_MOUNT" \
--user "$XHIM_UID:$XHIM_GID" \
--entrypoint /bin/sh \
"$XHIM_CANDIDATE_IMAGE" \
-ceu '
test -w /media
probe="$(mktemp /media/.xhim-manual-readiness-XXXXXX)"
cleanup() {
rm -f -- "$probe"
}
trap cleanup EXIT HUP INT TERM
umask 077
printf "%s\n" "xhim media readiness" >"$probe"
sync
rm -- "$probe"
trap - EXIT HUP INT TERM
'重新生成内容哈希并与迁移前清单比对。Named Volume 与 Bind Mount 分别使用对应 的只读 Mount:
bash
docker run --rm \
--mount "${XHIM_MEDIA_MOUNT},readonly" \
--entrypoint /bin/sh \
"$XHIM_CANDIDATE_IMAGE" \
-ceu '
find /media -mindepth 1 -maxdepth 1 -type f \
-exec sha256sum "{}" + |
sed "s# /media/# #" |
LC_ALL=C sort -k 2
' >"$XHIM_MEDIA_BACKUP_DIR/content-sha256.after"
cmp \
"$XHIM_MEDIA_BACKUP_DIR/content-sha256.before" \
"$XHIM_MEDIA_BACKUP_DIR/content-sha256.after"只有 UID/GID/Mode、文件数、非 root 写探针和内容哈希全部通过后,才能恢复服务:
bash
docker compose up -d server gateway
docker compose ps
curl -fsS http://127.0.0.1:18080/health/ready最后仍需用真实 SDK 执行一次:
text
prepare -> PATCH/PUT -> complete -> download -> SHA-256 校验5. 回滚
如果权限迁移、写探针或内容哈希验证失败:
- 保持 Gateway 与 Server 停止,不要让应用继续写入;
- 保存失败输出和当前只读
stat/find结果; - 如果内容哈希与迁移前一致,优先使用
metadata.before逐文件恢复原 UID/GID/Mode; - 如果内容哈希不同,不要继续权限操作。把当前目录另行只读备份,由两人核对 原始 Tar 后再制定内容恢复或目录交换方案;
- 恢复后重新执行只读哈希比对和旧镜像只读检查,再决定是否恢复旧服务。
以下 Metadata 回滚 Helper 同样只挂载目标叶子目录,不递归、不跟随符号链接。 XHIM_MEDIA_MOUNT、备份目录与候选镜像必须沿用本次迁移中已核实的值:
bash
docker run --rm \
--mount "$XHIM_MEDIA_MOUNT" \
--mount "type=bind,src=$XHIM_MEDIA_BACKUP_DIR,dst=/rollback,readonly" \
--user 0:0 \
--entrypoint /bin/sh \
"$XHIM_CANDIDATE_IMAGE" \
-ceu '
test -f /rollback/metadata.before
unexpected="$(
find /media -mindepth 1 -maxdepth 1 ! -type f -print -quit
)"
test -z "$unexpected" || {
echo "refusing unexpected media entry: $unexpected" >&2
exit 1
}
while IFS="|" read -r name uid gid mode; do
case "$uid" in
""|*[!0-9]*)
echo "invalid metadata UID for $name" >&2
exit 1
;;
esac
case "$gid" in
""|*[!0-9]*)
echo "invalid metadata GID for $name" >&2
exit 1
;;
esac
printf "%s\n" "$mode" |
grep -Eq "^[0-7]{3,4}$" || {
echo "invalid metadata mode for $name" >&2
exit 1
}
if [ "$name" = "." ]; then
target=/media
else
printf "%s\n" "$name" |
grep -Eq "^[0-9a-f]{64}([.]partial)?$" || {
echo "invalid media filename in metadata: $name" >&2
exit 1
}
target="/media/$name"
test -f "$target"
test ! -L "$target"
fi
chown "$uid:$gid" "$target"
chmod "$mode" "$target"
done </rollback/metadata.before
'回滚 Helper 只恢复元数据,不恢复或覆盖文件内容。只有在内容清单已经不一致、 经过审批且验证 Tar 完整性后,才允许从备份恢复内容;不要直接在原目录执行未 审查的 tar -x。对于 Bind Mount,也不要删除或重建已经核实的媒体源目录来 规避权限问题。