Gin 社区超时中间件的坑:导致线上 Pod 异常重启
在最近的项目中,我们遇到了因为 Gin 超时中间件(timeout
) 引发的生产事故:Pod 异常退出并重启。
问题现场
pod无故重启,抓取标准输出日志,问题指向超时中间件
堆栈报错信息如下
为什么会并发写入呢? 报错指向Go社区的超时中间件,社区搜索相关issue, 果然有相关问题 ttps://github.com/gin-contrib/timeout/pull/55
我们的代码封装
func timeoutMiddleWare(timeoutInt int) gin.HandlerFunc {
return timeout.New(
timeout.WithTimeout(time.Duration(timeoutInt)*time.Second),
timeout.WithResponse(func(c *gin.Context) {
c.JSON(http.StatusGatewayTimeout, response.Failed(http.StatusGatewayTimeout, nil))
}),
)
}
问题复现与成因
先说原因:
超时中间件额外开了一个协程去执行业务逻辑,超时中间件的逻辑在另外的的协程中,当请求超时发生时会出现了两个 goroutine 同时对响应进行写操作,而gin的源码响应中有写入map的操作,这会导致 重复写入,并触发 map 并发写 错误(Go 的 map 在并发写时会直接 panic), 从而导致Pod 异常退出,K8s 会立刻重启容器。
源码分析:
// github.com/gin-contrib/timeout v1.0.1
var bufPool *BufferPool
const (
defaultTimeout = 5 * time.Second
)
// New wraps a handler and aborts the process of the handler if the timeout is reached
func New(opts ...Option) gin.HandlerFunc {
t := &Timeout{
timeout: defaultTimeout,
handler: nil,
response: defaultResponse,
}
// Loop through each option
for _, opt := range opts {
if opt == nil {
panic("timeout Option not be nil")
}
// Call the option giving the instantiated
opt(t)
}
if t.timeout <= 0 {
return t.handler
}
bufPool = &BufferPool{}
return func(c *gin.Context) {
finish := make(chan struct{}, 1)
panicChan := make(chan interface{}, 1)
w := c.Writer
buffer := bufPool.Get()
tw := NewWriter(w, buffer)
c.Writer = tw
buffer.Reset()
// 这里开了一个协程去执行业务逻辑
go func() {
defer func() {
if p := recover(); p != nil {
panicChan <- p
}
}()
t.handler(c)
finish <- struct{}{}
}()
select {
case p := <-panicChan:
tw.FreeBuffer()
c.Writer = w
panic(p)
case <-finish:
c.Next()
tw.mu.Lock()
defer tw.mu.Unlock()
dst := tw.ResponseWriter.Header()
for k, vv := range tw.Header() {
dst[k] = vv
}
tw.ResponseWriter.WriteHeader(tw.code)
if _, err := tw.ResponseWriter.Write(buffer.Bytes()); err != nil {
panic(err)
}
tw.FreeBuffer()
bufPool.Put(buffer)
case <-time.After(t.timeout):
c.Abort()
tw.mu.Lock()
defer tw.mu.Unlock()
tw.timeout = true
tw.FreeBuffer()
bufPool.Put(buffer)
// v1.0.1 报错的代码
c.Writer = w
t.response(c)
c.Writer = tw
// v1.1.0 修复后的PR代码
cc := c.Copy() // 重新拷贝了一份gin.Context进行响应
cc.Writer = w
t.response(cc)
}
}
// t.response 实际是调用gin.Context.String()
func defaultResponse(c *gin.Context) {
c.String(http.StatusRequestTimeout, http.StatusText(http.StatusRequestTimeout))
}
// gin源码 v1.10.0:
func (c *Context) String(code int, format string, values ...interface{}) {
c.Render(code, render.String{Format: format, Data: values})
}
// Render writes the response headers and calls render.Render to render data.
func (c *Context) Render(code int, r render.Render) {
c.Status(code)
if !bodyAllowedForStatus(code) {
// 关键在这里
r.WriteContentType(c.Writer)
c.Writer.WriteHeaderNow()
return
}
if err := r.Render(c.Writer); err != nil {
panic(err)
}
}
// WriteContentType (JSON) writes JSON ContentType.
func (r JSON) WriteContentType(w http.ResponseWriter) {
writeContentType(w, jsonContentType)
}
// !!!WriteContentType 最终会往header(map)中写入值,引发并发问题 !!!
func writeContentType(w http.ResponseWriter, value []string) {
header := w.Header()
if val := header["Content-Type"]; len(val) == 0 {
header["Content-Type"] = value
}
}
社区修复
修复详情相关 PR:fix(response): conflict when handler completed and concurrent map writes by demouth · Pull Request #
解决办法
所有使用 go get github.com/gin-contrib/timeout
的项目需要升级:
- Go 版本 ≥ 1.23.0
- 拉取最新包:
go get github.com/gin-contrib/timeout@v1.1.0