只读根文件系统限制对服务打包代码和配置所在文件系统层的普通写入,明确挂载仍可提供运行数据的可写位置。其边界是文件系统变更本身。它不会单独移除进程权限、阻止读取可访问数据,也不会禁止与其他服务通信,需要按实际作用范围理解其价值。
坏掉的样子
应用经常在主要数据目录之外写入临时文件、缓存、锁文件、生成配置或运行状态。只读根会让这些假设在启动或不常见操作中以 EROFS 暴露。只测健康检查端点,可能漏掉生成报表、刷新缓存或关停清理时才经过的写入路径。因此必须沿真实工作流程检查需求,不能把进程成功启动当成全部功能可用。
即使容器根层只读,可写挂载仍然可写,其中状态仍可能被故障或受损进程改坏。把大块可写目录覆盖到打包路径,也可能抵消这些路径原本需要的保护。应检查最终挂载布局,而不是根据一个根层选项推断整棵路径树的行为。路径看起来位于只读根之下,并不证明中间没有另一个可写挂载。
只读与保密也是不同属性。进程仍可能读取打包文件、执行已有程序,并通过允许的网络路径发送数据。该设置也不保证容器逃逸之后仍受相同保护,更不约束能够改变挂载安排的权限。应结合凭据、能力、隔离和应用行为理解它的贡献,避免把一项文件系统限制描述成完整安全边界。
快速验证
在一次性 Linux 容器内使用 root 身份,根层只读,/tmp 挂可写 tmpfs,/work 挂独立可写卷。镜像中必须已有 Python 3.7 或更新版本和 /etc/os-release,且断言保持启用。早期记录报告 Linux 6.12.76、Python 3.11.2,没有宿主机绑定挂载、特权模式或外部网络。这里没有复现该次运行;这些启动条件是前提,并非全部由脚本检查:
from pathlib import Path
from tempfile import mkdtemp
import errno,os,socket,subprocess,sys,time,uuid
def cleanup_steps(steps, primary):
failures=[]
for label, action in steps:
try:action()
except BaseException as error:
failures.append(label+': '+type(error).__name__+': '+str(error))
if failures:
message='cleanup incomplete or uncertain: '+'; '.join(failures)
try:print(message,file=sys.stderr)
except (OSError,ValueError):pass
if primary is None:raise RuntimeError(message)
return failures
def remove_file(path):
try:path.unlink()
except FileNotFoundError:pass
def require_empty(value):
if value:raise RuntimeError('residual resources: '+str(value))
assert os.geteuid()==0,'run inside a disposable read-only-root container as root'
assert os.statvfs('/').f_flag&os.ST_RDONLY
name='xbcatm-readonly-'+uuid.uuid4().hex
print('fixture root-tree probe:',name,flush=True)
for base in [Path('/'),Path('/usr'),Path('/etc')]:
path=base/name
fd=None
try:
try:fd=os.open(path,os.O_WRONLY|os.O_CREAT|os.O_EXCL,0o600)
except OSError as error:assert error.errno==errno.EROFS,error
else:raise AssertionError('root filesystem accepted a new file')
finally:
primary=sys.exc_info()[1]
if fd is not None:
cleanup_steps([('probe descriptor',lambda:os.close(fd)),('unexpected root-tree file '+str(path),lambda:remove_file(path))],primary)
print('even this root process receives EROFS on paths in the read-only root, usr and etc tree')
for base in ['/tmp','/work']:
assert not os.statvfs(base).f_flag&os.ST_RDONLY
directory=None
try:
directory=Path(mkdtemp(prefix='xbcatm-write-',dir=base))
path=directory/'data';path.write_bytes(b'allowed');assert path.read_bytes()==b'allowed'
path.write_bytes(b'changed');assert path.read_bytes()==b'changed'
finally:
primary=sys.exc_info()[1]
if directory is not None:
cleanup_steps([('temporary data',lambda:remove_file(directory/'data')),('temporary directory',directory.rmdir)],primary)
print('explicit tmpfs and data-volume mounts remain writable, including overwriting their contents')
assert Path('/etc/os-release').read_text()
result=subprocess.run([sys.executable,'-c','print("executed")'],capture_output=True,text=True,check=True,timeout=5)
assert result.stdout.strip()=='executed'
def remaining(deadline):
seconds=deadline-time.monotonic()
if seconds<=0:raise TimeoutError('loopback exchange deadline expired')
return seconds
def receive_exact(connection,count,deadline):
chunks=[];received=0
while received<count:
connection.settimeout(remaining(deadline))
chunk=connection.recv(count-received)
if not chunk:raise EOFError('loopback peer closed before the complete payload')
chunks.append(chunk);received+=len(chunk)
return b''.join(chunks)
listener=client=connection=None
try:
deadline=time.monotonic()+5
listener=socket.socket();listener.settimeout(remaining(deadline))
listener.bind(('127.0.0.1',0));listener.listen(1)
client=socket.socket();client.settimeout(remaining(deadline));client.connect(listener.getsockname())
listener.settimeout(remaining(deadline));connection,_=listener.accept()
client.settimeout(remaining(deadline));client.sendall(b'local')
assert receive_exact(connection,5,deadline)==b'local'
finally:
primary=sys.exc_info()[1]
cleanup_steps([(label,sock.close) for label,sock in [('connection',connection),('client',client),('listener',listener)] if sock is not None],primary)
print('reading packaged files, executing an existing program, and loopback network I/O still work')
脚本先检查根文件系统报告的只读标志,再尝试在根、usr 和 etc 下排他创建唯一名字的文件。即使采用 root 身份,三次都得到 EROFS。唯一名称与排他创建保证探测不会覆盖既有配置文件。结果来自当前文件系统真正拒绝操作,而不是只看配置中出现了某个只读选项。
随后检查 tmp 与 work 不带相同只读标志,并在两处创建、读取和覆盖临时文件。这确认可写例外仍能工作,也说明这些位置的暴露面:根层设置不会保护特意可写的数据免受同一进程修改。正确部署既要验证拒绝了不需要的写入,也要验证真正需要的写入仍然可用。
读取 os-release 和执行已有 Python 解释器均成功,区分了只读存储与禁止读取或执行。保护打包文件免于被修改很有用,却不是对现有代码所能执行动作的全面限制。进程仍然拥有其他权限时,可以继续利用那些权限处理数据或启动程序,不能仅凭根层不可写就推断其行为范围。
最后,一个监听端与客户端通过回环交换无害数据。容器没有外部网络,但本地套接字仍然工作,展示文件系统选项本身不会关闭网络。这没有验证任何外部连通策略,也没有访问生产服务。实验把文件系统修改和通信操作分开观察,避免把一个设置误解为同时覆盖所有资源类型。
修订后的夹具尚未运行。接收累积短读并拒绝提前 EOF,与连接、接受连接和发送共享五秒单调时钟截止时间;解释器超时也是五秒。创建前建立保护,套接字及文件清理独立尝试,不替换最初异常,只有清理失败时也会失败。root 身份、容器标记和只读标志不证明隔离,须独立核验启动条件。启动器负责容器与卷销毁;失败后应检查报告的残留。进程创建、停滞的文件系统和突然终止仍不受这些限制约束。这些探针仅覆盖选定操作;根层写入失败不证明覆盖应用所有路径,也不限制未来新增可写挂载。真实服务是否完整可用,仍应由其具体调用路径和异常流程验证。
分开打包文件、临时空间与持久状态
可写路径应来自观察到的应用需要,并让程序明确使用这些位置。例外范围要小到能够解释用途。出现失败时,应定位所需路径和生命周期,而不是为了一个意外缓存或临时文件,就重新开放整个根层。这样既能修复真实功能要求,也能保留原本需要的修改边界。
tmpfs 适合相应临时数据,同时要考虑内存使用和生命周期。Docker 提醒,这些内存页仍可能进入交换空间,因此“由内存支持”不保证敏感字节绝不会写到持久介质;临时存储选择不能直接代替保密要求。状态需要独立于容器可写层保留时,应使用单独管理的卷,并配套备份和恢复。可写卷提供存储位置,却不会自动建立崩溃持久性、保密性或已经验证的恢复流程。位置选择和数据保证是相关但不同的设计事项。
对于 systemd 管理的服务,ProtectSystem 与指定状态、运行目录等机制,可以表达相关限制,但各自具有文档规定的语义。应验证服务真实视图和身份,不能只按选项名字在平台之间机械翻译。启动、普通工作、异常操作和清理都应被覆盖,并确认禁止和必需的写入分别符合预期。
延伸阅读
来源
- Docker container run reference,只读根文件系统与单独挂载的可写路径,已核验 2026-09-12。
- Docker tmpfs mounts,临时挂载存储及生命周期,已核验 2026-09-12。
- Docker volumes,独立于容器根层的卷生命周期,已核验 2026-09-12。
- systemd 252 execution environment,ProtectSystem 与明确可写的服务目录,已核验 2026-09-12。