V2EX = way to explore
V2EX 是一个关于分享和探索的地方
现在注册
已注册用户请  登录
The Go Programming Language
http://golang.org/
Go Playground
Go Projects
Revel Web Framework
zihuyishi
V2EX  ›  Go 编程语言

golang 服务器处理上传时,如果 form 文件过大,居然会往/tmp 目录写文件,要怎么优雅的处理呢?

  •  
  •   zihuyishi · 17 天前 · 2596 次点击

    我用的 gin 库,发现如果上传文件较大(大于 10mb ),就会往系统/tmp 目录写文件,然而由于是运行在容器里,一般也不会去清理/tmp ,导致容器占用空间越来越大。写了好几年 golang 了,第一次注意到这个问题。 我查了下文档,基本上需要自己去主动调用 MultipartForm 的 RemoveAll 方法

    	if c.Request.MultipartForm != nil {
    		defer c.Request.MultipartForm.RemoveAll()
    	}
    

    主要想吐槽的是,写了这么久 go ,好像从来没在任何教程或者 example 里看到有人调用这个方法..而且 gin 为啥自己不去主动调用呢,还是说其实有更优雅的写法,但是我不知道?

    38 条回复    2025-07-04 14:43:22 +08:00
    julyclyde
        1
    julyclyde  
       17 天前
    定期重启一下
    wunonglin
        2
    wunonglin  
    PRO
       17 天前
    服务本身不处理,丢给 s3 去存
    hingle
        3
    hingle  
       17 天前
    zihuyishi
        4
    zihuyishi  
    OP
       17 天前
    @wunonglin 本来就是传给 s3 的,但是 golang 的 multipart-form 在文件大于一个值时默认行为就是往/tmp 目录下写文件。如果没主动去调用 RemeveAll 他居然就留着/tmp 下文件不管了...
    zihuyishi
        5
    zihuyishi  
    OP
       17 天前
    @hingle 好像还真是,所以归根到底问题是 gin 框架的?他自己没有调用 RemoveAll?
    hingle
        6
    hingle  
       17 天前
    @zihuyishi gin 用的就是标准库的 http.Server ,会调用 RemoveAll 的啊

    是不是你客户端那边一直没断开连接?或者出现一些异常导致没发送 FIN 之类的。
    zihuyishi
        7
    zihuyishi  
    OP
       17 天前
    @hingle 我在 debug/pprof 里面看链接都断开了呀,而且上传请求也都是正常的返回,难道有些 middleware 会改变一些默认行为?
    nicoljiang
        8
    nicoljiang  
       17 天前
    不考虑直传给 s3 吗(中转各方面也不划算)?
    zsj1029
        9
    zsj1029  
       17 天前
    流处理可不可以,哦对了,form 表单好像不行
    gaeco
        10
    gaeco  
       16 天前
    python 也这样吧
    FrankAdler
        11
    FrankAdler  
       16 天前
    如果你能预估出来上传的文件大小范围,而且内存充足,可以调大 MaxMultipartMemory ,可以减少临时文件使用,默认是 32MB 。
    bv
        12
    bv  
       16 天前
    1. 这一块是标准库实现的逻辑,当 HTTP 请求响应结束后,会自动删除临时文件,无需手动调用 RemoveAll 。
    2. 虽然可以手动控制 req.ParseMultipartForm(maxMemory int64) 参数来缓解,但是无法根治该问题。
    3. 上传的临时文件越来越多,竟然没有自动删除,我觉得你需要排查一下这个问题。

    自动删除逻辑: https://github.com/golang/go/blob/0f8ab2db177baee7b04182f5641693df3b212aa9/src/net/http/server.go#L1718-L1720
    FrankAdler
        13
    FrankAdler  
       16 天前   ❤️ 3
    我在 gin 那看到你提的 pr 和 issue 了,我发现自己的项目也有这个问题,起初我以为是 gin 的 bug ,后来排查下来发现其实是我自己引起的,确实很隐蔽
    先说结论,你可能跟我一样在 middleware 里使用了 ctx.Request = xxx 的操作,导致 http.serve 创建的 req 变掉了
    而自动清理临时文件是在 finishRequest 方法调用,依赖的是 w.req.MultipartForm != nil ,如果你在前面覆盖了 req ,那就和这里 w.req 指向不一样了,
    MultipartForm 最开始是没有初始化的,算是按需初始,也就是执行完 middleware 到了 handler 后打算获取 file 的时候,初始化后赋值给 req ,但是这时候已经是新的 req 了,而 finishRequest 还在调用旧的 req
    bv
        14
    bv  
       16 天前
    @FrankAdler #13 你这个方向靠谱
    DefoliationM
        15
    DefoliationM  
       15 天前
    不要用自带的,自己用 mutilpart form 解析就不会写临时文件了。
    zihuyishi
        16
    zihuyishi  
    OP
       13 天前
    @FrankAdler 好像还真是这个,我为了加 opentelemetry 自己替换了一个 request. 所以我还是要自己主动在替换掉的地方处理一下?
    FrankAdler
        17
    FrankAdler  
       13 天前
    @zihuyishi #16 知道根因了,你自己想办法解决吧,无非就是去掉替换 Req 或者主动调用 RemoveAll 了,或者加一个 middleware 在最后也来个 req.MultipartForm != nil
    zihuyishi
        18
    zihuyishi  
    OP
       13 天前
    @FrankAdler 再加一个 middleware 去最后调用 RemoveAll 好像也不是太稳妥,因为可能最后拿到的 Request 不是持有 MultipartForm 的 Request ,可能还是原地改 Request 的去调用比较好?
    bv
        19
    bv  
       13 天前
    @zihuyishi #16 我没用过 opentelemetry ,不太理解为什么加 opentelemetry 要替换原来的 Request ,看看有没有什么写法:再不替换原来的 Request 情况下加入 opentelemetry 。
    bv
        20
    bv  
       13 天前
    @bv #19 替换 Request 容易给自己埋坑,你发现了 File 没删除你需要打补丁去删除 MultipartFile ,过几天又发现 req.Body 没有自动 Close ,是不是还要打补丁
    zihuyishi
        21
    zihuyishi  
    OP
       13 天前   ❤️ 1
    @bv 确实,我仔细看了,可能我要做的是把 c.Request = c.Request.WithContext(ctx)改成 c.Request = c.Request.Clone(ctx),这样应该就没问题了
    zihuyishi
        22
    zihuyishi  
    OP
       13 天前
    好像也不太对,因为只要 override 了 c.Request,那么再在 handler 里去解析 multipartForm ,net/server 持有的 request 还是以前的没有 multipartForm 的,所以最终还是应该要我自己去 RemoveAll
    bv
        23
    bv  
       12 天前
    @zihuyishi #22 还确实这样,目前想到的是:在处理完业务程序后,1. 手动 RemoveAll 或者 2: req.MultipartForm = cloneReq.MultipartForm 交给 finishRequest 处理
    guanzhangzhang
        24
    guanzhangzhang  
       12 天前
    应该后端给一个 sts token 给前端,前端去直接上传到 oss 和 s3 的
    zihuyishi
        25
    zihuyishi  
    OP
       12 天前
    @guanzhangzhang 前端的环境很多时候是访问不了 oss 的。尤其我们经常在不同云厂商迁移...
    dextercai
        26
    dextercai  
       12 天前
    合理怀疑你是我隔壁组的同事 😆
    guanzhangzhang
        27
    guanzhangzhang  
       12 天前
    @zihuyishi #24 可以后端返回 oss 的 endpoint ,我好好几个视频网站上传接口都是这样
    eudore
        28
    eudore  
       12 天前
    1 、不用 form 格式,嫌弃数据格式麻烦,直接使用 octet-stream iocopy 写入文件。
    2 、WithContext 方法比 Clone 更好,一些标准库修改 context 就是使用的 WithContext 方法。
    3 、应该是你使用了什么奇奇怪怪的东西,正常情况下在 HandlerFunc 之前不会去解析 body ,建议删除内容和使用 nethttp 差异测试。
    4 、无法复现不是问题。
    bv
        29
    bv  
       11 天前
    OpenTelemetry 的官方 SDK 也是 r.WithContext(ctx) 后并没有考虑 MultipartForm.RemoveAll 。
    至于这算是个 OpenTelemetry 的 BUG 还是待讨论的功能,我觉得 OP 可以给 OpenTelemetry 提一个 issue ,如何优雅的解决 MultipartForm Remove 问题。

    https://github.com/open-telemetry/opentelemetry-go-contrib/blob/instrumentation/net/http/otelhttp/v0.62.0/instrumentation/net/http/otelhttp/handler.go#L179-L180
    zihuyishi
        30
    zihuyishi  
    OP
       11 天前
    @eudore 稳定可以复现的
    ```golang
    package main

    import (
    "context"
    "net/http"

    "github.com/gin-gonic/gin"
    )

    func main() {
    router := gin.Default()
    router.Use(func(c *gin.Context) {
    ctx := c.Request.Context()
    ctx = context.WithValue(ctx, "something", 1)
    c.Request = c.Request.WithContext(ctx)
    c.Next()
    })
    router.POST("/upload", func(c *gin.Context) {
    file, err := c.FormFile("file")
    if err != nil {
    c.JSON( http.StatusBadRequest, gin.H{"error": err.Error()})
    return
    }
    c.SaveUploadedFile(file, "test.txt")
    c.JSON( http.StatusOK, gin.H{"message": "File uploaded successfully"})
    })
    router.Run()
    }
    ```
    然后上传一个大于 32mb 的文件,你就能看到上传一次/tmp 下多一个文件 multipart-xxxxxxxx
    zihuyishi
        31
    zihuyishi  
    OP
       11 天前
    @bv 因为 go 是通过 context 来带上下文,opentelemetry 也是通过这个 context 把链路跟踪信息注入进去的,但是又没有办法把改后的 ctx 传给原来的 Request ,所以只能通过 Request.WithContext(ctx)去新建个 Request ,保证自己注入的 context 能被后续调用访问到。之后例如数据库操作就可以通过 context 把链路链起来了
    zihuyishi
        32
    zihuyishi  
    OP
       11 天前
    @bv https://github.com/open-telemetry/opentelemetry-go-contrib/issues/5946 提了之后发现已经是个历史遗留 bug 了,根本没人管。
    eudore
        33
    eudore  
       11 天前
    @zihuyishi
    WithContext 后新旧两个 req 的 MultipartForm 值为 nil 不是 ptr ,旧 req 不包含新 req 初始化后 ptr ,旧 req 在 finshreq 的时没有执行清理。
    属于 nethttp 缺陷,没有好的方法直接修正。

    修复:将新旧 req 的 MultipartForm 值同步。

    ```go
    router.Use(func(c *gin.Context) {
    rr := c.Request
    ctx := c.Request.Context()
    c.Request = c.Request.WithContext(ctx)
    c.Next()

    fmt.Println(rr.MultipartForm)
    fmt.Println(c.Request.MultipartForm)
    if (rr.MultipartForm == nil) != (c.Request.MultipartForm == nil) {
    rr.MultipartForm = c.Request.MultipartForm
    }
    })
    ```
    eudore
        34
    eudore  
       11 天前
    多思考了一下,我选择方法 2 在使用 form file 后手动释放临时文件,本身是一个低频率功能,在 HandlerFunc 里面同时完成 Parse 和 RemoveAll ;而不是在 WithContext 时添加额外操作。

    可能在 finshReq 执行 RemoveAll 就不是一个好的方法。

    ```go
    func main() {
    router := gin.Default()
    router.Use(func(c *gin.Context) {
    rr := c.Request
    c.Request = c.Request.WithContext(c.Request.Context())
    c.Next()

    fmt.Println(rr.MultipartForm)
    fmt.Println(c.Request.MultipartForm)
    // fix 1
    if rr.MultipartForm != c.Request.MultipartForm {
    rr.MultipartForm = c.Request.MultipartForm
    }
    })
    router.POST("/upload", func(c *gin.Context) {
    c.Request.ParseMultipartForm(1<<10)
    })
    router.Run()
    }
    func main() {
    router := gin.Default()
    router.Use(func(c *gin.Context) {
    c.Request = c.Request.WithContext(c.Request.Context())
    })
    router.POST("/upload", func(c *gin.Context) {
    c.Request.ParseMultipartForm(1<<10)
    // fix 2
    c.Requset.MultipartForm.RemoveAll()
    c.Requset.MultipartForm.File = nil
    })
    router.Run()
    }
    ```
    eudore
        35
    eudore  
       11 天前
    没想好,怎么都不完美,大致思路。

    方法 1:WithContext post 执行 MultipartForm 同步,执行一次 WithContext 后未传递就将导致无效,包含第三方传递逻辑。
    方法 2:在 ParseMultipartForm 后手动执行 RemoveAll ,所有 form 请求方法都需要执行,不仅仅是上传,防止其他 form 请求被攻击爆磁盘。
    方法 3:在 HandlerFunc 之后执行一次 RemoveAll ,写法比较丑陋。
    方法 4:BodyLimt 和 maxMemory 避免使用临时文件,BodyLimt 可能被不靠谱的原因调大,maxMemory 过大影响内存使用。
    方法 5:框架层实现自定义清理。
    bv
        36
    bv  
       11 天前   ❤️ 1
    站内大佬 @lesismal 曾向 go 官方提交过添加 Request.SetContext 方法的 issue ,被拒了。
    https://github.com/golang/go/issues/48811
    eudore
        37
    eudore  
       10 天前
    在中间件上执行 RemoveAll ,不能保证后续没有 Wrap Handler 然后再次使用了 WithContext 。
    在 ParseMultipartForm 后手动执行 RemoveAll ,需要修改所有的 form 请求代码。
    在 ParseMultipartForm 后启动新的 goroutine ,使用 context 到期删除,不太想使用新的 goroutine 。

    对于 go http server ,如果存在使用 multipart/form-data 的请求,我们可以构造 form 请求,额外添加一个 32MB 的 file ;如果这个服务端使用了 WithContext 但是没有 limitBody ,我们爆掉他的服务端的 tmpdir 。


    不想了,等 go 团队先处理。 https://github.com/golang/go/issues/74455
    lesismal
        38
    lesismal  
       9 天前
    @bv 我都快忘记了。有时候确实觉得官方封闭有点过度,这种新增一个方法并不影响已有的实现,但是能给 std 之外带来很大收益。

    另外,gin 、echo 这些,应该是作非静态资源类的 API 比较好,静态资源类的这个上传文件的问题、还有下载文件 gin 、echo 自实现的 Response 好像也都不支持 sendfile 。之前有 nbio 用户遇到过,相关:
    https://github.com/lesismal/nbio/issues/263

    所以,静态资源类的用标准库或者其他没问题的 router ,其他 API 用 gin echo 这些分开好些:
    https://github.com/lesismal/nbio/issues/330#issuecomment-1639202593
    关于   ·   帮助文档   ·   自助推广系统   ·   博客   ·   API   ·   FAQ   ·   实用小工具   ·   844 人在线   最高记录 6679   ·     Select Language
    创意工作者们的社区
    World is powered by solitude
    VERSION: 3.9.8.5 · 22ms · UTC 20:09 · PVG 04:09 · LAX 13:09 · JFK 16:09
    Developed with CodeLauncher
    ♥ Do have faith in what you're doing.