持久应用状态和可丢弃临时文件,需要不同的保留、容量与恢复策略。分开路径有助于表达这些策略,但目录本身不会创造持久性,也不会隔离磁盘容量。真正决定进程重启、容器替换、宿主故障或清理后保留什么的,是路径背后的存储。可靠布局需要把每类数据连接到明确生命周期,而不是仅仅给文件夹起两个不同名字。
坏掉的样子
如果清理任务把混合目录全部当作可丢弃内容,可能在删除临时下载时一起删除已提交记录。相反,也可能备份了大量可重建缓存,却漏掉真正重要的小数据库。文件扩展名和年龄不能代替对应用所有权的理解;需要知道路径由谁管理,以及其内容是否真的能从权威来源重新构造,才能决定能否删除。
容器重启和替换也不同。停止再启动同一个容器,通常保留其可写文件系统层;删除容器才会丢弃该层,而明确保留的命名卷可以继续存在。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 提交,不依赖尚未关闭的输出缓冲,从而让后续生命周期对照具有明确数据边界。
停止再启动同一个容器后,数据库记录与 /ephemeral 仍存在,临时文件却不见了。启动命令不会重建这些文件,因此观察的是存储寿命,不是初始化逻辑把丢失隐藏起来。这个结果把保留容器时的进程重启,与真正销毁可写层区分开。排查重启后数据行为时,应先确认实际执行了哪一种操作。
随后删除原容器,创建挂载同一命名卷的替换容器。数据库仍含 committed,但 /ephemeral 已不存在。新实例获得新的可写层,同时重新连接保留卷。这证明卷不依赖某个容器的生命周期,却不证明能够抵御 Docker 宿主丢失或卷本身被删除。持久性保证必须附带存储所在位置和删除规则。
接着,替换容器持续写入受限临时挂载,直到返回 ENOSPC。此时仍可向卷中的数据库插入并提交 after-scratch-full。成功提交证明本次配置的容量边界确实生效,不表示持久存储没有上限,也不保证免受其他共享资源耗尽影响,例如宿主总内存。一个隔离维度有效,不能推导所有资源都独立。
最后,通过 SQLite 备份接口创建数据库副本,关闭、重新打开,并按顺序核对两条已提交记录。这里使用数据库自己的复制接口,没有假定随手复制一个正在使用的数据库文件名,就能一致取得所有必要文件。副本仍在相同实验卷上,因此只验证复制可读性与内容,不是独立灾难恢复备份的证据。
让策略跟随数据契约
权威应用数据使用稳定状态目录,可重建工作使用独立管理的临时位置。文件系统层次标准把应用状态与 /var/lib 对应;容器可以选择不同路径,同时保留这种语义区别。通过应用配置明确目录,并说明哪个组件负责创建、权限、清理和迁移。只在部署文档里写路径,而应用实际写向别处,也无法形成有效边界。
有意设置临时预算,并在耗尽时处理错误,不要删除无关状态。缓存只有在权威来源和重建过程确实可用时才可丢弃。一些看似临时的文件,可能是进行中事务或恢复所必需,所以清理需要应用契约,不能仅凭不加区分的年龄规则。删除之前应知道它为何存在,而不是只知道它看起来旧。
独立备份应具有符合数据要求的保留策略,并在新环境中测试恢复。卷跨容器替换保留,是持久性证据,不是备份证据;同卷副本无法抵御该卷整体丢失。监控真实底层存储,核对恢复内容,并把破坏性的卷删除设为明确生命周期决策,才能让目录分离真正服务于数据安全和可维护性。
延伸阅读
来源
- Docker volumes,独立于容器可写层的数据生命周期,已核验 2026-09-12。
- Docker tmpfs mounts,临时内存挂载及大小限制,已核验 2026-09-12。
- Filesystem Hierarchy Standard: /var/lib,需要保留的应用可变状态信息,已核验 2026-09-12。
- SQLite online backup API,通过数据库接口执行一致复制,已核验 2026-09-12。