持久应用状态和可丢弃临时文件,需要不同的保留、容量与恢复策略。分开路径有助于表达这些策略,但目录本身不会创造持久性,也不会隔离磁盘容量。真正决定进程重启、容器替换、宿主故障或清理后保留什么的,是路径背后的存储。可靠布局需要把每类数据连接到明确生命周期,而不是仅仅给文件夹起两个不同名字。
坏掉的样子 如果清理任务把混合目录全部当作可丢弃内容,可能在删除临时下载时一起删除已提交记录。相反,也可能备份了大量可重建缓存,却漏掉真正重要的小数据库。文件扩展名和年龄不能代替对应用所有权的理解;需要知道路径由谁管理,以及其内容是否真的能从权威来源重新构造,才能决定能否删除。
容器重启和替换也不同。停止再启动同一个容器,通常保留其可写文件系统层;删除容器才会丢弃该层,而明确保留的命名卷可以继续存在。tmpfs 又有不同生命周期,容器停止时其内容会消失。把这三类位置统称为临时,会掩盖影响恢复流程的区别,导致测试重启成功后就误以为替换也安全。
同一个文件系统里的两个目录,仍然竞争该文件系统的剩余空间。只把临时路径从 /data/tmp 改成 /scratch,却不改变存储策略,并不会自动保护数据库写入。容量隔离需要合适的挂载、配额或其他可执行预算,同时监控应区分临时空间增长与持久状态增长。路径整齐不能替代真正的资源边界。
停止再启动与容器替换对应不同存储生命周期。原文另检查受限临时挂载与同卷数据库副本重开;两者均不是独立灾难恢复证明。 快速验证 先取得固定镜像,再用宿主 Python 和 Docker 运行脚本。实测为 Docker 29.3.1、Linux arm64,镜像内 Python 3.11.16 提供 SQLite 模块。它创建一个私有命名卷和八 MiB 的 tmpfs 临时挂载,只删除自身容器与卷。小规模临时空间耗尽被限制在实验内部,不修改既有服务存储:
import subprocess,uuid BASE='python@sha256:528257d48c1da0dcecc2e725d1ae34498d60c965f1241e39cd6a85a8859bdf84' def docker(*args,check=True): result=subprocess.run(['docker',*args],text=True,capture_output=True,timeout=120) if check:assert result.returncode==0,result.stderr return result def execute(container,code):return docker('exec',container,'python','-c',code).stdout.strip() name='xbcatm-state-'+uuid.uuid4().hex[:10];volume=name+'-data';containers=[name+'-one',name+'-replacement'] try: docker('volume','create',volume) def start(container): docker('run','-d','--name',container,'--network','none','--mount','type=volume,src='+volume+',dst=/state','--tmpfs','/scratch:rw,size=8m',BASE,'python','-c','import time;time.sleep(180)') start(containers[0]) execute(containers[0],'from pathlib import Path;import sqlite3;c=sqlite3.connect("/state/data.sqlite");c.execute("create table records(value text)");c.execute("insert into records values (?)",("committed",));c.commit();c.close();Path("/ephemeral").write_text("container-layer");Path("/scratch/crumb").write_text("scratch")') docker('stop','--time','1',containers[0]);docker('start',containers[0]) execute(containers[0],'from pathlib import Path;import sqlite3;assert Path("/ephemeral").exists();assert not Path("/scratch/crumb").exists();c=sqlite3.connect("/state/data.sqlite");assert c.execute("select value from records").fetchall()==[("committed",)];c.close()') print('stop/start retains the container writable layer and volume data, while the tmpfs scratch mount loses its file') docker('rm','-f',containers[0]);start(containers[1]) execute(containers[1],'from pathlib import Path;import sqlite3;assert not Path("/ephemeral").exists();c=sqlite3.connect("/state/data.sqlite");assert c.execute("select value from records").fetchall()==[("committed",)];c.close()') print('replacement loses the old writable layer but retains the named-volume database') execute(containers[1],'''import errno,os,sqlite3 fd=os.open('/scratch/fill',os.O_CREAT|os.O_WRONLY,0o600) try: try: while True:os.write(fd,b'x'*1048576) except OSError as error:assert error.errno==errno.ENOSPC finally:os.close(fd) source=sqlite3.connect('/state/data.sqlite');source.execute('insert into records values (?)',('after-scratch-full',));source.commit() backup=sqlite3.connect('/state/check-copy.sqlite');source.backup(backup);backup.close();source.close() restored=sqlite3.connect('/state/check-copy.sqlite');assert restored.execute('select value from records order by rowid').fetchall()==[('committed',),('after-scratch-full',)];restored.close() ''') print('bounded scratch reaches ENOSPC while state still commits; a SQLite backup copy reopens with both committed records') finally: docker('rm','-f',*containers,check=False);docker('volume','rm',volume,check=False) 第一个容器在 /state 下创建 SQLite 数据库并提交一条记录,在普通可写层写入 /ephemeral,再把临时文件放进 /scratch。三个路径都通过普通文件接口访问,背后却是不同存储位置。持久应用记录通过 SQLite commit 提交,不依赖尚未关闭的输出缓冲,从而让后续生命周期对照具有明确数据边界。
...