golang 服务器处理上传时,如果 form 文件过大,居然会往/tmp 目录写文件,要怎么优雅的处理呢? - V2EX
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 157 天前 3325 次点击
    这是一个创建于 157 天前的主题,其中的信息可能已经有所发展或是发生改变。

    我用的 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  
       157 天前
    定期重启一下
    shakaraka
        2
    shakaraka  
    PRO
       157 天前
    服务本身不处理,丢给 s3 去存
    hingle
        3
    hingle  
       157 天前
    zihuyishi
        4
    zihuyishi  
    OP
       157 天前
    @wunonglin 本来就是传给 s3 的,但是 golang 的 multipart-form 在文件大于一个值时默认行为就是往/tmp 目录下写文件。如果没主动去调用 RemeveAll 他居然就留着/tmp 下文件不管了...
    zihuyishi
        5
    zihuyishi  
    OP
       157 天前
    @hingle 好像还真是,所以归根到底问题是 gin 框架的?他自己没有调用 RemoveAll?
    hingle
        6
    hingle  
       157 天前
    @zihuyishi gin 用的就是标准库的 http.Server ,会调用 RemoveAll 的啊

    是不是你客户端那边一直没断开连接?或者出现一些异常导致没发送 FIN 之类的。
    zihuyishi
        7
    zihuyishi  
    OP
       157 天前
    @hingle 我在 debug/pprof 里面看链接都断开了呀,而且上传请求也都是正常的返回,难道有些 middleware 会改变一些默认行为?
    nicoljiang
        8
    nicoljiang  
    PRO
       157 天前
    不考虑直传给 s3 吗(中转各方面也不划算)?
    zsj1029
        9
    zsj1029  
       157 天前
    流处理可不可以,哦对了,form 表单好像不行
    gaeco
        10
    gaeco  
       156 天前
    python 也这样吧
    FrankAdler
        11
    FrankAdler  
       156 天前
    如果你能预估出来上传的文件大小范围,而且内存充足,可以调大 MaxMultipartMemory ,可以减少临时文件使用,默认是 32MB 。
    bv
        12
    bv  
       156 天前
    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  
       156 天前   3
    我在 gin 那看到你提的 pr 和 issue 了,我发现自己的项目也有这个问题,起初我以为是 gin 的 bug ,后来排查下来发现其实是我自己引起的,确实很隐蔽
    先说结论,你可能跟我一样在 middleware 里使用了 ctx.Request = xxx 的操作,导致 http.serve 创建的 req 变掉了
    而自动清理临时文件是在 finishRequest 方法调用,依赖的是 w.req.MultipartForm != nil ,如果你在前面覆盖了 re ,那就和这里 w.req 指向不一样了,
    MultipartForm 最开始是没有初始化的,算是按需初始,也就是执行完 middleware 到了 handler 后打算获取 file 的时候,初始化后赋值给 req ,但是这时候已经是新的 req 了,而 finishRequest 还在调用旧的 req
    bv
        14
    bv  
       156 天前
    @FrankAdler #13 你这个方向靠谱
    DefoliationM
        15
    DefoliationM  
       155 天前
    不要用自带的,自己用 mutilpart form 解析就不会写临时文件了。
    zihuyishi
        16
    zihuyishi  
    OP
       153 天前
    @FrankAdler 好像还真是这个,我为了加 opentelemetry 自己替换了一个 request. 所以我还是要自己主动在替换掉的地方处理一下?
    FrankAdler
        17
    FrankAdler  
       153 天前
    @zihuyishi #16 知道根因了,你自己想办法解决吧,无非就是去掉替换 Req 或者主动调用 RemoveAll 了,或者加一个 middleware 在最后也来个 req.MultipartForm != nil
    zihuyishi
        18
    zihuyishi  
    OP
       153 天前
    @FrankAdler 再加一个 middleware 去最后调用 RemoveAll 好像也不是太稳妥,因为可能最后拿到的 Request 不是持有 MultipartForm 的 Request ,可能还是原地改 Request 的去调用比较好?
    bv
        19
    bv  
       153 天前
    @zihuyishi #16 我没用过 opentelemetry ,不太理解为什么加 opentelemetry 要替换原来的 Request ,看看有没有什么写法:再不替换原来的 Request 情况下加入 opentelemetry 。
    bv
        20
    bv  
       153 天前
    @bv #19 替换 Request 容易给自己埋坑,你发现了 File 没删除你需要打补丁去删除 MultipartFile ,过几天又发现 req.Body 没有自动 Close ,是不是还要打补丁
    zihuyishi
        21
    zihuyishi  
    OP
       153 天前   1
    @bv 确实,我仔细看了,可能我要做的是把 c.Request = c.Request.WithContext(ctx)改成 c.Request = c.Request.Clone(ctx),这样应该就没问题了
    zihuyishi
        22
    zihuyishi  
    OP
       153 天前
    好像也不太对,因为只要 override 了 c.Request,那么再在 handler 里去解析 multipartForm ,net/server 持有的 request 还是以前的没有 multipartForm 的,所以最终还是应该要我自己去 RemoveAll
    bv
        23
    bv  
       152 天前
    @zihuyishi #22 还确实这样,目前想到的是:在处理完业务程序后,1. 手动 RemoveAll 或者 2: req.MultipartForm = cloneReq.MultipartForm 交给 finishRequest 处理
    guanzhangzhang
        24
    guanzhangzhang  
       152 天前
    应该后端给一个 sts token 给前端,前端去直接上传到 oss 和 s3 的
    zihuyishi
        25
    zihuyishi  
    OP
       152 天前
    @guanzhangzhang 前端的环境很多时候是访问不了 oss 的。尤其我们经常在不同云厂商迁移...
    dextercai
        26
    dextercai  
       152 天前
    合理怀疑你是我隔壁组的同事
    guanzhangzhang
        27
    guanzhangzhang  
       152 天前
    @zihuyishi #24 可以后端返回 oss 的 endpoint ,我好好几个视频网站上传接口都是这样
    eudor
        28
    eudore  
       151 天前
    1 、不用 form 格式,嫌弃数据格式麻烦,直接使用 octet-stream iocopy 写入文件。
    2 、WithContext 方法比 Clone 更好,一些标准库修改 context 就是使用的 WithContext 方法。
    3 、应该是你使用了什么奇奇怪怪的东西,正常情况下在 HandlerFunc 之前不会去解析 body ,建议删除内容和使用 nethttp 差异测试。
    4 、无法复现不是问题。
    bv
        29
    bv  
       151 天前
    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
       151 天前
    @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
       151 天前
    @bv 因为 go 是通过 context 来带上下文,opentelemetry 也是通过这个 context 把链路跟踪信息注入进去的,但是又没有办法把改后的 ctx 传给原来的 Request ,所以只能通过 Request.WithContext(ctx)去新建个 Request ,保证自己注入的 context 能被后续调用访问到。之后例如数据库操作就可以通过 context 把链路链起来了
    zihuyishi
        32
    zihuyishi  
    OP
       151 天前
    @bv https://github.com/open-telemetry/opentelemetry-go-contrib/issues/5946 提了之后发现已经是个历史遗留 bug 了,根本没人管。
    eudore
        33
    eudore  
       151 天前
    @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  
       150 天前
    多思考了一下,我选择方法 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  
       150 天前
    没想好,怎么都不完美,大致思路。

    方法 1:WithContext post 执行 MultipartForm 同步,执行一次 WithContext 后未传递就将导致无效,包含第三方传递逻辑。
    方法 2:在 ParseMultipartForm 后手动执行 RemoveAll ,所有 form 请求方法都需要执行,不仅仅是上传,防止其他 form 请求被攻击爆磁盘。
    方法 3:在 HandlerFunc 之后执行一次 RemoveAll ,写法比较丑陋。
    方法 4:BodyLimt 和 maxMemory 避免使用临时文件,BodyLimt 可能被不靠谱的原因调大,maxMemory 过大影响内存使用。
    方法 5:框架层实现自定义清理。
    bv
        36
    bv  
       150 天前   1
    站内大佬 @lesismal 曾向 go 官方提交过添加 Request.SetContext 方法的 issue ,被拒了。
    https://github.com/golang/go/issues/48811
    eudore
        37
    eudore  
       150 天前
    在中间件上执行 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  
       149 天前
    @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     Solana     2593 人在线   最高记录 6679       Select Language
    创意工作者们的社区
    World is powered by solitude
    VERSION: 3.9.8.5 27ms UTC 11:44 PVG 19:44 LAX 03:44 JFK 06:44
    Do have faith in what you're doing.
    ubao msn snddm index pchome yahoo rakuten mypaper meadowduck bidyahoo youbao zxmzxm asda bnvcg cvbfg dfscv mmhjk xxddc yybgb zznbn ccubao uaitu acv GXCV ET GDG YH FG BCVB FJFH CBRE CBC GDG ET54 WRWR RWER WREW WRWER RWER SDG EW SF DSFSF fbbs ubao fhd dfg ewr dg df ewwr ewwr et ruyut utut dfg fgd gdfgt etg dfgt dfgd ert4 gd fgg wr 235 wer3 we vsdf sdf gdf ert xcv sdf rwer hfd dfg cvb rwf afb dfh jgh bmn lgh rty gfds cxv xcv xcs vdas fdf fgd cv sdf tert sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf shasha9178 shasha9178 shasha9178 shasha9178 shasha9178 liflif2 liflif2 liflif2 liflif2 liflif2 liblib3 liblib3 liblib3 liblib3 liblib3 zhazha444 zhazha444 zhazha444 zhazha444 zhazha444 dende5 dende denden denden2 denden21 fenfen9 fenf619 fen619 fenfe9 fe619 sdf sdf sdf sdf sdf zhazh90 zhazh0 zhaa50 zha90 zh590 zho zhoz zhozh zhozho zhozho2 lislis lls95 lili95 lils5 liss9 sdf0ty987 sdft876 sdft9876 sdf09876 sd0t9876 sdf0ty98 sdf0976 sdf0ty986 sdf0ty96 sdf0t76 sdf0876 df0ty98 sf0t876 sd0ty76 sdy76 sdf76 sdf0t76 sdf0ty9 sdf0ty98 sdf0ty987 sdf0ty98 sdf6676 sdf876 sd876 sd876 sdf6 sdf6 sdf9876 sdf0t sdf06 sdf0ty9776 sdf0ty9776 sdf0ty76 sdf8876 sdf0t sd6 sdf06 s688876 sd688 sdf86