一、问题是怎么冒出来的

1.1 诡异报错的现场

有次线上跑一个 Flask 服务,功能很简单,就是让用户上传视频文件,视频不大不小,单个大概四五百兆。起初测试环境一切正常,可一到生产环境就开始间歇性抽风。更气人的是,报错信息特别不统一。有时候接口直接抛PermissionError,像是没权限写文件;有时候又抛OSError: No space left on device,明显是磁盘满了;还有的时候前一个请求还好好儿的,后一个请求连tempfile目录都建不出来。你单独看每一个报错都能猜个大概,可一旦把它们串在一起,就特别像灵异事件——明明磁盘刚清理过,怎么又满了?明明目录权限改了,怎么还报权限错误?

1.2 为什么会这样

其实这种诡异现象的本质,是临时目录权限不足磁盘空间不足两个问题同时存在,互相掩护,互相误导。

先说权限。Flask 接收上传文件时,如果文件比较大,它不会一直把数据留在内存里,而是会先写到一个临时目录,等请求处理完再让开发者决定怎么办。这个临时目录默认可能是系统/tmp,也可能是你通过配置指定的目录。如果这个目录的属主不是当前运行 Flask 的用户,或者ACL权限不对,那么PermissionError就来了。

再说磁盘空间。大文件上传本身就会把数据临时落盘,如果同一时间有好几个用户传文件,每个文件几百兆,临时目录再干净也可能瞬间被填满。这时候系统会报No space left on device。但问题在于——你看到这个报错时,可能以为只是磁盘被临时文件占满了,于是手动删掉一部分临时文件,腾出空间。可一旦删文件时权限不够,PermissionError又冒出来,你以为只是权限问题,结果改完权限,磁盘又满了。两个问题像打地鼠一样,永远修不完。

更烦躁的是,Flask 内部对临时文件的处理用了封装,底层的错误会被包装成各种奇怪的 HTTP 500,排查日志时你可能会看到一堆BadZipFileEOFError,甚至是ConnectionResetError,让人彻底抓狂。

二、先理解Flask的临时文件机制

2.1 Flask怎么存上传文件

Flask 处理上传主要靠request.files。当你调用request.files['file']时,Werkzeug(Flask 底层库)会根据文件大小决定存储方式。小文件直接放在内存里,大文件则自动转存为临时文件。这个转存动作由SpooledTemporaryFile完成,超过阈值就会落盘。

落盘的位置怎么定?Werkzeug 有自己的一套查找顺序,会去找环境变量TMPDIR,找不到就用系统临时目录。当然,你也可以在 Flask 里自己指定UPLOAD_FOLDER来做临时目录。但要注意,路径的权限你得自己负责。

2.2 权限和空间各自扮演什么角色

权限问题的核心是“写不写得进去”。比如你用nginx用户启动 Flask,但临时目录是root所有,权限是755,那nginx用户只能读不能写,上传处理百分之百报错。空间问题的核心是“写不写得下”。哪怕你有权限,磁盘满了照样写入失败。

两者混在一起时,最坑的一点是:你以为文件已经写进临时目录,其实可能只写了一半。 这种情况下,后续任何读取临时文件的操作都可能触发异常。而且当磁盘空间不足时,某些系统或库会尝试自动清理临时文件,但如果权限不够,清理失败,异常信息就会非常混乱。

三、归档清理机制设计要点

3.1 明确归档目标

我们说的“归档”,不是把文件随便扔到一个目录就完事,而是要让整个文件生命周期清晰可控。一次大文件上传,最简单的生命周期应该是:临时区→处理区→归档区。上传的文件先落在临时区,Flask 路由拿到文件后做校验、转码、解压等处理,处理成功后把最终成品移动到归档区,然后立刻把临时区的原始文件删掉。这样临时区永远只存在“正在处理中”的文件,不会积压垃圾。

3.2 目录设计:临时区、归档区、处理中

目录结构要清晰,我用一个目录树来说明:

/data/uploads/
├── tmp/          # Flask 写入的原始临时文件
├── processing/   # 当前正在处理的文件
└── archive/      # 处理成功的归档文件

tmpprocessing都可以定时清理,archive则根据业务要求保留一段时间。注意tmpprocessing尽量放在同一块磁盘分区,否则移动文件可能会变成跨设备复制,速度慢且容易出错。

3.3 清理策略:按年龄、按大小、按状态

清理不能只盯着一个指标。三个维度要同时看:

  1. 按年龄:文件在tmp里待了超过30分钟,大概率是请求挂了,直接删除。
  2. 按大小:如果tmpprocessing总占用超过磁盘阈值的80%,就得优先清理最老的、最大的文件。
  3. 按状态:如果文件还在processing里,但对应的任务已经不存在,比如进程被杀,也可以认为“僵尸文件”,直接清理。

3.4 权限检查要和空间检查分离

不要让清理脚本既管权限又管空间。你可以在启动时检查权限,用独立的脚本修一次;而空间监控交给另一个任务,只负责报告和触发清理。这样就算磁盘满了,也不会因为权限问题导致清理脚本本身崩溃。

四、完整示例:一个稳妥的清理机制

4.1 技术栈

Python Flask + APScheduler

这里我们用一个 Python 脚本搞定所有事情:Flask 负责上传接口,APScheduler 负责后台定时清理任务。

4.2 配置文件示例

# config.py
import os

class BaseConfig:
    # 上传根目录
    UPLOAD_BASE_PATH = '/data/uploads'

    # 三个子目录
    TMP_DIR = os.path.join(UPLOAD_BASE_PATH, 'tmp')
    PROCESSING_DIR = os.path.join(UPLOAD_BASE_PATH, 'processing')
    ARCHIVE_DIR = os.path.join(UPLOAD_BASE_PATH, 'archive')

    # 允许的扩展名
    ALLOWED_EXTENSIONS = {'mp4', 'avi', 'mov', 'mkv'}

    # 最大上传 2GB
    MAX_CONTENT_LENGTH = 2 * 1024 * 1024 * 1024

    # 清理配置
    TMP_FILE_MAX_AGE_SECONDS = 30 * 60          # 临时文件最多保留30分钟
    PROCESSING_FILE_MAX_AGE_SECONDS = 10 * 60   # 处理中文件最多保留10分钟
    ARCHIVE_FILE_MAX_AGE_SECONDS = 7 * 24 * 3600 # 归档文件保留7天
    MAX_TMP_DISK_USAGE_PERCENT = 80             # 临时区磁盘使用率超80%就触发清理

    # 是否开启定时清理任务
    ENABLE_CLEANER = True

4.3 上传路由示例

# app.py
import os
import tempfile
import uuid
from flask import Flask, request, jsonify
from werkzeug.utils import secure_filename
from config import BaseConfig

app = Flask(__name__)
app.config.from_object(BaseConfig)

def allowed_file(filename):
    """检查文件扩展名是否在允许列表里。"""
    return '.' in filename and \
           filename.rsplit('.', 1)[1].lower() in app.config['ALLOWED_EXTENSIONS']

def ensure_directories():
    """确保临时区、处理区、归档区都存在,并且权限正确。"""
    for d in [app.config['TMP_DIR'],
              app.config['PROCESSING_DIR'],
              app.config['ARCHIVE_DIR']]:
        os.makedirs(d, exist_ok=True)
        # 只让当前用户有读写权限,避免其他用户乱搞
        os.chmod(d, 0o700)

@app.route('/upload', methods=['POST'])
def upload_file():
    """用户上传大文件的接口。"""
    # 每次请求前确保目录存在(幂等操作)
    ensure_directories()

    if 'file' not in request.files:
        return jsonify({'code': 400, 'msg': '没有找到文件参数'}), 400

    file = request.files['file']
    if file.filename == '':
        return jsonify({'code': 400, 'msg': '文件名为空'}), 400

    if not allowed_file(file.filename):
        return jsonify({'code': 400, 'msg': '不支持的文件类型'}), 400

    # 使用 secure_filename 去掉路径中的危险字符
    safe_name = secure_filename(file.filename)
    # 临时文件名加一个唯一前缀,防止多个请求冲突
    stored_name = f"{uuid.uuid4().hex}_{safe_name}"

    # 先写到临时目录
    temp_path = os.path.join(app.config['TMP_DIR'], stored_name)

    try:
        # 分块保存,避免一次读入大文件
        with open(temp_path, 'wb') as f:
            while True:
                chunk = file.stream.read(1024 * 1024)  # 每次读 1MB
                if not chunk:
                    break
                f.write(chunk)

        # 假设这里会调用一个处理函数(转码、审核等)
        # 真实场景中可能要把文件移动到 processing 目录再处理
        # 这里为了演示,直接把处理结果放入归档区
        archive_path = os.path.join(app.config['ARCHIVE_DIR'], stored_name)
        os.rename(temp_path, archive_path)  # 同分区移动,瞬间完成

        return jsonify({'code': 200, 'msg': 'ok', 'filename': stored_name}), 200

    except PermissionError as e:
        # 权限不足要立刻返回明确信息,方便排查
        return jsonify({'code': 500, 'msg': f'临时目录权限异常:{str(e)}'}), 500

    except OSError as e:
        # 磁盘满了或者其他文件系统错误
        return jsonify({'code': 500, 'msg': f'文件写入失败:{str(e)}'}), 500

    finally:
        # 如果临时文件还在,说明没转存成功,清理掉
        if os.path.exists(temp_path):
            try:
                os.remove(temp_path)
            except PermissionError:
                # 这里先记录下来,不能影响主流程
                app.logger.error(f'清理临时文件失败: {temp_path}')

4.4 归档清理脚本示例

# cleaner.py
import os
import shutil
import time
from apscheduler.schedulers.background import BackgroundScheduler
from config import BaseConfig

class UploadCleaner:
    """负责清理临时文件、处理中文件以及过期归档文件。"""

    def __init__(self, app):
        self.app = app
        self.tmp_dir = app.config['TMP_DIR']
        self.processing_dir = app.config['PROCESSING_DIR']
        self.archive_dir = app.config['ARCHIVE_DIR']
        self.scheduler = BackgroundScheduler()

    def _get_disk_usage(self, path):
        """获取某个路径所在磁盘的使用率百分比。"""
        disk_usage = shutil.disk_usage(path)
        return disk_usage.used / disk_usage.total * 100

    def _clean_files_in_dir(self, dir_path, max_age_seconds):
        """清理目录中超过指定年龄的文件,返回删除数量。"""
        now = time.time()
        removed_count = 0
        if not os.path.isdir(dir_path):
            return 0

        for filename in os.listdir(dir_path):
            full_path = os.path.join(dir_path, filename)
            # 只处理普通文件,跳过目录
            if os.path.isfile(full_path):
                file_age = now - os.path.getmtime(full_path)
                if file_age > max_age_seconds:
                    try:
                        os.remove(full_path)
                        removed_count += 1
                        self.app.logger.info(f'清理过期文件: {full_path}')
                    except PermissionError:
                        # 权限错误不要跳过,记录在案,稍后统一处理
                        self.app.logger.error(f'权限不足,清理失败: {full_path}')
        return removed_count

    def clean_task(self):
        """定时执行的主清理函数。"""
        with self.app.app_context():
            need_force_clean = False

            # 检查磁盘空间,如果临时区所在磁盘使用率过高,就清理得更激进
            if self._get_disk_usage(self.tmp_dir) > self.app.config['MAX_TMP_DISK_USAGE_PERCENT']:
                need_force_clean = True

            # 先清理临时的超龄文件
            self._clean_files_in_dir(self.tmp_dir,
                                     self.app.config['TMP_FILE_MAX_AGE_SECONDS'])
            # 再清理处理中的超龄文件
            self._clean_files_in_dir(self.processing_dir,
                                     self.app.config['PROCESSING_FILE_MAX_AGE_SECONDS'])

            # 如果磁盘还紧张,把临时文件年龄门槛临时降到5分钟
            if need_force_clean:
                self._clean_files_in_dir(self.tmp_dir, 5 * 60)
                self._clean_files_in_dir(self.processing_dir, 1 * 60)

            # 归档文件按业务要求保留7天,到时间就删
            self._clean_files_in_dir(self.archive_dir,
                                     self.app.config['ARCHIVE_FILE_MAX_AGE_SECONDS'])

    def start(self):
        """启动定时任务。"""
        if not self.app.config['ENABLE_CLEANER']:
            return
        self.scheduler.add_job(
            func=self.clean_task,
            trigger='interval',
            minutes=5,  # 每5分钟跑一次
            id='upload_cleaner'
        )
        self.scheduler.start()

4.5 启动任务示例

# run.py
import os
from app import app
from cleaner import UploadCleaner

if __name__ == '__main__':
    # 确保目录和权限就绪
    from app import ensure_directories
    ensure_directories()

    # 启动定时清理
    cleaner = UploadCleaner(app)
    cleaner.start()

    # 如果你用 gunicorn 之外的开发服务器,这样启动就行
    app.run(host='0.0.0.0', port=8000, debug=False)

把这个脚本整合到你的项目里,你只需要改掉config.py里的路径,再检查一下运行用户对/data/uploads有读写权限,基本就能同时解决临时目录权限和磁盘空间不足造成的诡异报错。注意,这里的所有示例都使用 Python 技术栈,没有掺别的东西。

五、应用场景和优缺点

5.1 适用场景

这种清理机制最适合以下场景:

  • 视频、压缩包、安装包等大文件上传服务。
  • 多用户并发上传,临时文件生命周期不一致的环境。
  • 服务器磁盘容量有限,不能靠人工天天清理的线上部署。
  • 对文件安全有要求,不能把临时文件直接暴露在公开目录下的业务。

5.2 优点

第一,报错定位清晰。我们的代码分了PermissionErrorOSError两种情况,这两类错误不再混在一起,日志里能直接看出是权限问题还是空间问题。第二,自动回收垃圾文件。定时任务每隔几分钟就检查一次,不管是线程崩溃还是请求超时留下的半成品,都会被扫描出来删掉。第三,目录职责分离。临时区与归档区分开,归档区的文件就算积压了,也不会影响新上传的临时文件写入,因为两块区域可以在不同磁盘分区上。

5.3 缺点

这个方案也不是银弹。首先,分区规划需要提前做。如果你把所有目录都放在同一个分区,临时文件满了照样会导致整个分区满,归档区也会跟着遭殃。其次,定时清理有延迟。极端情况下磁盘可能瞬间被塞满,等不到5分钟后的清理任务就挂了。所以这个机制更适合配合实时空间监控来使用,而不能完全依赖它。最后,代码逻辑稍微复杂,对刚接触 Flask 的开发者不太友好,需要理解os.renameshutil.disk_usage等底层 API 的行为。

六、注意事项

6.1 不要裸奔清空

千万别写一个脚本扫到临时目录就把所有文件全删了。万一有一个合法请求正在处理中,你把人家的临时文件删了,后续流程立刻崩掉。所以必须有“超时年龄”概念,比如我们的示例里,临时文件必须超过30分钟才能删。

6.2 考虑多进程部署

如果你用 gunicorn 跑多个 worker,每个 worker 都是独立进程,但目录是共享的。定时清理任务最好只在一个进程里启动,否则多个 worker 同时跑去扫描目录,互相删文件,可能引发竞态条件。示例里把 cleaner 放在单独模块,你可以在入口处根据环境变量决定只有master才启动cleaner

6.3 权限不能简单777

有的同学为了省事,直接chmod 777临时目录。这样虽然权限问题是没了,但安全风险极大。任何系统用户都能往里面写文件,如果 Flaks 应用被黑客利用,就能在临时目录里塞入恶意文件。我们示例里用的是0o700,只允许当前用户读写执行,这才是稳妥的做法。

6.4 磁盘空间监控要单独做

定时清理只是“事后补救”。最好再写一个单独的监控脚本,用df -h或者psutil检查磁盘空间,当超过阈值时立刻通知管理员。空间不足这种事,越早发现越好,不要等到服务写不进去才慌张。如果你用 Linux,还可以给临时目录单独挂一块分区,这样就算临时分区满了,主分区上的应用日志和数据库也不会受牵连。

七、总结

回到开始那个诡异报错。本质上,是因为我们没有把临时目录权限和磁盘空间这两个风险点放在一起考虑,导致问题出现时,两个故障因素互相掩盖。解决思路很简单:把临时目录和归档目录分开,设计一套清晰的清理流程,让权限检查在启动时完成,让空间监控在运行时生效,让定时清理兜底处理那些僵尸文件。当我们把这些机制落地成 Flask 代码后,生产环境再也没跟那种忽隐忽现的报错打过照面。大文件上传本来就敏感,文件系统稍微一点风吹草动都会影响用户体验,只有把底层目录、权限、空间、生命周期都安排得明明白白,才能睡得安稳。