我用的 gin 库,发现如果上传文件较大(大于 10mb ),就会往系统/tmp 目录写文件,然而由于是运行在容器里,一般也不会去清理/tmp ,导致容器占用空间越来越大。写了好几年 golang 了,第一次注意到这个问题。 我查了下文档,基本上需要自己去主动调用 MultipartForm 的 RemoveAll 方法
if c.Request.MultipartForm != nil {
defer c.Request.MultipartForm.RemoveAll()
}
主要想吐槽的是,写了这么久 go ,好像从来没在任何教程或者 example 里看到有人调用这个方法..而且 gin 为啥自己不去主动调用呢,还是说其实有更优雅的写法,但是我不知道?
1
julyclyde 17 天前
定期重启一下
|
![]() |
2
wunonglin PRO 服务本身不处理,丢给 s3 去存
|
3
hingle 17 天前
|
4
zihuyishi OP @wunonglin 本来就是传给 s3 的,但是 golang 的 multipart-form 在文件大于一个值时默认行为就是往/tmp 目录下写文件。如果没主动去调用 RemeveAll 他居然就留着/tmp 下文件不管了...
|
6
hingle 17 天前
|
![]() |
8
nicoljiang 17 天前
不考虑直传给 s3 吗(中转各方面也不划算)?
|
![]() |
9
zsj1029 17 天前
流处理可不可以,哦对了,form 表单好像不行
|
![]() |
10
gaeco 16 天前
python 也这样吧
|
11
FrankAdler 16 天前
如果你能预估出来上传的文件大小范围,而且内存充足,可以调大 MaxMultipartMemory ,可以减少临时文件使用,默认是 32MB 。
|
![]() |
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 |
13
FrankAdler 16 天前 ![]() 我在 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 |
![]() |
14
bv 16 天前
@FrankAdler #13 你这个方向靠谱
|
15
DefoliationM 15 天前
不要用自带的,自己用 mutilpart form 解析就不会写临时文件了。
|
16
zihuyishi OP @FrankAdler 好像还真是这个,我为了加 opentelemetry 自己替换了一个 request. 所以我还是要自己主动在替换掉的地方处理一下?
|
17
FrankAdler 13 天前
@zihuyishi #16 知道根因了,你自己想办法解决吧,无非就是去掉替换 Req 或者主动调用 RemoveAll 了,或者加一个 middleware 在最后也来个 req.MultipartForm != nil
|
18
zihuyishi OP @FrankAdler 再加一个 middleware 去最后调用 RemoveAll 好像也不是太稳妥,因为可能最后拿到的 Request 不是持有 MultipartForm 的 Request ,可能还是原地改 Request 的去调用比较好?
|
![]() |
19
bv 13 天前
@zihuyishi #16 我没用过 opentelemetry ,不太理解为什么加 opentelemetry 要替换原来的 Request ,看看有没有什么写法:再不替换原来的 Request 情况下加入 opentelemetry 。
|
![]() |
20
bv 13 天前
@bv #19 替换 Request 容易给自己埋坑,你发现了 File 没删除你需要打补丁去删除 MultipartFile ,过几天又发现 req.Body 没有自动 Close ,是不是还要打补丁
![]() |
21
zihuyishi OP ![]() @bv 确实,我仔细看了,可能我要做的是把 c.Request = c.Request.WithContext(ctx)改成 c.Request = c.Request.Clone(ctx),这样应该就没问题了
|
22
zihuyishi OP 好像也不太对,因为只要 override 了 c.Request,那么再在 handler 里去解析 multipartForm ,net/server 持有的 request 还是以前的没有 multipartForm 的,所以最终还是应该要我自己去 RemoveAll
|
![]() |
23
bv 12 天前
@zihuyishi #22 还确实这样,目前想到的是:在处理完业务程序后,1. 手动 RemoveAll 或者 2: req.MultipartForm = cloneReq.MultipartForm 交给 finishRequest 处理
|
![]() |
24
guanzhangzhang 12 天前
应该后端给一个 sts token 给前端,前端去直接上传到 oss 和 s3 的
|
25
zihuyishi OP @guanzhangzhang 前端的环境很多时候是访问不了 oss 的。尤其我们经常在不同云厂商迁移...
|
26
dextercai 12 天前
合理怀疑你是我隔壁组的同事 😆
|
![]() |
27
guanzhangzhang 12 天前
@zihuyishi #24 可以后端返回 oss 的 endpoint ,我好好几个视频网站上传接口都是这样
|
![]() |
28
eudore 12 天前
1 、不用 form 格式,嫌弃数据格式麻烦,直接使用 octet-stream iocopy 写入文件。
2 、WithContext 方法比 Clone 更好,一些标准库修改 context 就是使用的 WithContext 方法。 3 、应该是你使用了什么奇奇怪怪的东西,正常情况下在 HandlerFunc 之前不会去解析 body ,建议删除内容和使用 nethttp 差异测试。 4 、无法复现不是问题。 |
![]() |
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 |
30
zihuyishi OP @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 |
31
zihuyishi OP @bv 因为 go 是通过 context 来带上下文,opentelemetry 也是通过这个 context 把链路跟踪信息注入进去的,但是又没有办法把改后的 ctx 传给原来的 Request ,所以只能通过 Request.WithContext(ctx)去新建个 Request ,保证自己注入的 context 能被后续调用访问到。之后例如数据库操作就可以通过 context 把链路链起来了
|
32
zihuyishi OP @bv https://github.com/open-telemetry/opentelemetry-go-contrib/issues/5946 提了之后发现已经是个历史遗留 bug 了,根本没人管。
|
![]() |
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 } }) ``` |
![]() |
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() } ``` |
![]() |
35
eudore 11 天前
没想好,怎么都不完美,大致思路。
方法 1:WithContext post 执行 MultipartForm 同步,执行一次 WithContext 后未传递就将导致无效,包含第三方传递逻辑。 方法 2:在 ParseMultipartForm 后手动执行 RemoveAll ,所有 form 请求方法都需要执行,不仅仅是上传,防止其他 form 请求被攻击爆磁盘。 方法 3:在 HandlerFunc 之后执行一次 RemoveAll ,写法比较丑陋。 方法 4:BodyLimt 和 maxMemory 避免使用临时文件,BodyLimt 可能被不靠谱的原因调大,maxMemory 过大影响内存使用。 方法 5:框架层实现自定义清理。 |
![]() |
36
bv 11 天前 ![]() 站内大佬 @lesismal 曾向 go 官方提交过添加 Request.SetContext 方法的 issue ,被拒了。
https://github.com/golang/go/issues/48811 |
![]() |
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 |
![]() |
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 |