只读根文件系统限制对服务打包代码和配置所在文件系统层的普通写入,明确挂载仍可提供运行数据的可写位置。其边界是文件系统变更本身。它不会单独移除进程权限、阻止读取可访问数据,也不会禁止与其他服务通信,需要按实际作用范围理解其价值。
坏掉的样子 应用经常在主要数据目录之外写入临时文件、缓存、锁文件、生成配置或运行状态。只读根会让这些假设在启动或不常见操作中以 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。唯一名称与排他创建保证探测不会覆盖既有配置文件。结果来自当前文件系统真正拒绝操作,而不是只看配置中出现了某个只读选项。
...