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

20 天前
 zihuyishi

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

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

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

2675 次点击
所在节点    Go 编程语言
38 条回复
zihuyishi
16 天前
@bv 确实,我仔细看了,可能我要做的是把 c.Request = c.Request.WithContext(ctx)改成 c.Request = c.Request.Clone(ctx),这样应该就没问题了
zihuyishi
16 天前
好像也不太对,因为只要 override 了 c.Request,那么再在 handler 里去解析 multipartForm ,net/server 持有的 request 还是以前的没有 multipartForm 的,所以最终还是应该要我自己去 RemoveAll
bv
15 天前
@zihuyishi #22 还确实这样,目前想到的是:在处理完业务程序后,1. 手动 RemoveAll 或者 2: req.MultipartForm = cloneReq.MultipartForm 交给 finishRequest 处理
guanzhangzhang
15 天前
应该后端给一个 sts token 给前端,前端去直接上传到 oss 和 s3 的
zihuyishi
15 天前
@guanzhangzhang 前端的环境很多时候是访问不了 oss 的。尤其我们经常在不同云厂商迁移...
dextercai
15 天前
合理怀疑你是我隔壁组的同事 😆
guanzhangzhang
15 天前
@zihuyishi #24 可以后端返回 oss 的 endpoint ,我好好几个视频网站上传接口都是这样
eudore
15 天前
1 、不用 form 格式,嫌弃数据格式麻烦,直接使用 octet-stream iocopy 写入文件。
2 、WithContext 方法比 Clone 更好,一些标准库修改 context 就是使用的 WithContext 方法。
3 、应该是你使用了什么奇奇怪怪的东西,正常情况下在 HandlerFunc 之前不会去解析 body ,建议删除内容和使用 nethttp 差异测试。
4 、无法复现不是问题。
bv
14 天前
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
14 天前
@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
14 天前
@bv 因为 go 是通过 context 来带上下文,opentelemetry 也是通过这个 context 把链路跟踪信息注入进去的,但是又没有办法把改后的 ctx 传给原来的 Request ,所以只能通过 Request.WithContext(ctx)去新建个 Request ,保证自己注入的 context 能被后续调用访问到。之后例如数据库操作就可以通过 context 把链路链起来了
zihuyishi
14 天前
@bv https://github.com/open-telemetry/opentelemetry-go-contrib/issues/5946 提了之后发现已经是个历史遗留 bug 了,根本没人管。
eudore
14 天前
@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
14 天前
多思考了一下,我选择方法 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
14 天前
没想好,怎么都不完美,大致思路。

方法 1:WithContext post 执行 MultipartForm 同步,执行一次 WithContext 后未传递就将导致无效,包含第三方传递逻辑。
方法 2:在 ParseMultipartForm 后手动执行 RemoveAll ,所有 form 请求方法都需要执行,不仅仅是上传,防止其他 form 请求被攻击爆磁盘。
方法 3:在 HandlerFunc 之后执行一次 RemoveAll ,写法比较丑陋。
方法 4:BodyLimt 和 maxMemory 避免使用临时文件,BodyLimt 可能被不靠谱的原因调大,maxMemory 过大影响内存使用。
方法 5:框架层实现自定义清理。
bv
14 天前
站内大佬 @lesismal 曾向 go 官方提交过添加 Request.SetContext 方法的 issue ,被拒了。
https://github.com/golang/go/issues/48811
eudore
13 天前
在中间件上执行 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
12 天前
@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

这是一个专为移动设备优化的页面(即为了让你能够在 Google 搜索结果里秒开这个页面),如果你希望参与 V2EX 社区的讨论,你可以继续到 V2EX 上打开本讨论主题的完整版本。

https://ex.noerr.eu.org/t/1141198

V2EX 是创意工作者们的社区,是一个分享自己正在做的有趣事物、交流想法,可以遇见新朋友甚至新机会的地方。

V2EX is a community of developers, designers and creative people.

© 2021 V2EX