golang http2长链接问题

前几天项目中遇到了一个长链接假死问题,服务端和client端采用的是h2c长连接。服务端作为sidecar部署在k8s的pod里面,当滚动升级pod的时候,client端和老的pod的连接一直存在,即使老的pod已经被删除了。(client和网关是一个东西)

发现问题

突然有一天前端同事说调用全部503(内部服务不可用),赶紧去环境上查看log,发现网关发送到后段的请求全部超时。用netstat查看连接状态也是没问题的。

网关端状态图

去新建的pod上查看,发现并没有连接,因此网关端的连接并不是和新建的pod建立的。

服务端状态图

解决问题

1.查看网关端代码

首先看一下网关测的代码:

    func NewH2cClient() *http.Client {
    client := &http.Client{
        Transport: &http2.Transport{
            AllowHTTP: true,
            DialTLS:   dialH2cTimeout,
        },
        Timeout: 10 * time.Second,
        }
        return client
    }

    func dialH2cTimeout(network, addr string, cfg *tls.Config) (net.Conn, error) {
       tylog.Infof("connect to remote address: %+v:", addr)
       return net.Dial(network, addr)
    }

client端的实现很简单,复用http.Client对象。在这里设置了TimeOut为10s,这个是发送超时时间。通过log也可以看到10s超时后返回了error,但连接并没有终端,查阅了一些net/http的文档服务端对于超时的控制还是比较细腻的。

服务端超时控制

但client端的控制就比较简陋(其实应该是net/http封装的东西太多,导致用户可直接操作的东西太少)

client端超时控制

从图中可以看出,client端不像服务端有读写超时设置,client端就一个Timeout,是从建立链接到接收到body的时间段。因此我们在client端可以做的东西貌似并不多。

2.回归问题

查阅了部分资料后,回到问题的所在点:

为什么服务端已经被删除,但长链接还存在??

因此我们需要看的就是client端的conn什么时候应该断开。http包中的读和写是分开的,分别对应的是两个goroutine,我们主要应该看的是读操作。

      func (cc *ClientConn) readLoop() {
         rl := &clientConnReadLoop{cc: cc}
         defer rl.cleanup() //这个会清理链接
         cc.readerErr = rl.run()
         if ce, ok := cc.readerErr.(ConnectionError); ok {
              cc.wmu.Lock()
              cc.fr.WriteGoAway(0, ErrCode(ce), nil)
              cc.wmu.Unlock()
         }
     }

上述代码是read的入口,我们看一下run这个函数的实现

func (rl *clientConnReadLoop) run() error {
    cc := rl.cc
    rl.closeWhenIdle = cc.t.disableKeepAlives() || cc.singleUse
    gotReply := false // ever saw a HEADERS reply
    gotSettings := false
    for {
        f, err := cc.fr.ReadFrame()
        if err != nil {
            cc.vlogf("http2: Transport readFrame error on conn %p: (%T) %v", cc, err, err)
        }
        if se, ok := err.(StreamError); ok {
            if cs := cc.streamByID(se.StreamID, false); cs != nil {
                cs.cc.writeStreamReset(cs.ID, se.Code, err)
                cs.cc.forgetStreamID(cs.ID)
                if se.Cause == nil {
                    se.Cause = cc.fr.errDetail
                }
                rl.endStreamError(cs, se)
            }
            continue
        } else if err != nil {
            return err
        }
        if VerboseLogs {
            cc.vlogf("http2: Transport received %s", summarizeFrame(f))
        }
        if !gotSettings {
            if _, ok := f.(*SettingsFrame); !ok {
                cc.logf("protocol error: received %T before a SETTINGS frame", f)
                return ConnectionError(ErrCodeProtocol)
            }
            gotSettings = true
        }
        maybeIdle := false // whether frame might transition us to idle

        switch f := f.(type) {
        case *MetaHeadersFrame:
            err = rl.processHeaders(f)
            maybeIdle = true
            gotReply = true
        case *DataFrame:
            err = rl.processData(f)
            maybeIdle = true
        case *GoAwayFrame:
            err = rl.processGoAway(f)
            maybeIdle = true
        case *RSTStreamFrame:
            err = rl.processResetStream(f)
            maybeIdle = true
        case *SettingsFrame:
            err = rl.processSettings(f)
        case *PushPromiseFrame:
            err = rl.processPushPromise(f)
        case *WindowUpdateFrame:
            err = rl.processWindowUpdate(f)
        case *PingFrame:
            err = rl.processPing(f)
        default:
            cc.logf("Transport: unhandled response frame type %T", f)
        }
        if err != nil {
            if VerboseLogs {
                cc.vlogf("http2: Transport conn %p received error from processing frame %v: %v", cc, summarizeFrame(f), err)
            }
            return err
        }
        if rl.closeWhenIdle && gotReply && maybeIdle {
            cc.closeIfIdle()
        }
    }
}

再看一下ReadFrame函数:

// ReadFrame reads a single frame. The returned Frame is only valid
// until the next call to ReadFrame.
//
// If the frame is larger than previously set with SetMaxReadFrameSize, the
// returned error is ErrFrameTooLarge. Other errors may be of type
// ConnectionError, StreamError, or anything else from the underlying
// reader.
func (fr *Framer) ReadFrame() (Frame, error) {
    fr.errDetail = nil
    if fr.lastFrame != nil {
        fr.lastFrame.invalidate()
    }
    fh, err := readFrameHeader(fr.headerBuf[:], fr.r)
    if err != nil {
        return nil, err
    }
    if fh.Length > fr.maxReadSize {
        return nil, ErrFrameTooLarge
    }
    payload := fr.getReadBuf(fh.Length)
    if _, err := io.ReadFull(fr.r, payload); err != nil {
        return nil, err
    }
    f, err := typeFrameParser(fh.Type)(fr.frameCache, fh, payload)
    if err != nil {
        if ce, ok := err.(connError); ok {
            return nil, fr.connError(ce.Code, ce.Reason)
        }
        return nil, err
    }
    if err := fr.checkFrameOrder(f); err != nil {
        return nil, err
    }
    if fr.logReads {
        fr.debugReadLoggerf("http2: Framer %p: read %v", fr, summarizeFrame(f))
    }
    if fh.Type == FrameHeaders && fr.ReadMetaHeaders != nil {
        return fr.readMetaFrame(f.(*HeadersFrame))
    }
    return f, nil
}

其实就是一直阻塞读取数据,只有在收到reset或者fin包(EOF)的时候才会退出,然后会执行前面的清理函数:

 defer rl.cleanup()

该函数会调用MarkDead函数清理conn关联的map

func (p *clientConnPool) MarkDead(cc *ClientConn) {
    p.mu.Lock()
    defer p.mu.Unlock()
    for _, key := range p.keys[cc] {
        vv, ok := p.conns[key]
        if !ok {
            continue
        }
        newList := filterOutClientConn(vv, cc)
        if len(newList) > 0 {
            p.conns[key] = newList
        } else {
            delete(p.conns, key)
        }
    }
    delete(p.keys, cc)
}

看到这基本就知道问题的所在了:

  1. 删除pod的时候服务端没有发送Fin包
  2. 服务端发送了Fin包,但网关没收到

3.验证问题所在

使用了tcpdump抓包,发现client端的确没有收到Fin包

client端抓包

从抓包信息看的确没有收到Fin包,服务端抓也没抓到,于是感觉像k8s升级的时候直接删掉了老的pod(类似直接delete),并没有发送kill命令。但集群权限是拿不到的,不太好认证这个猜想。

4.解决方案

因为服务端也是自己写的,因此可以设置deployment来设置pod更新时的动作,比如:

"lifecycle": {
              "preStop": {
                "httpGet": {
                  "path": "/hc.do?a=offline",
                  "scheme": "HTTP",
                  "port": 8809
                }
              }
            },

让服务监听8809端口,k8s在删除pod前会访问 http127.0.0.1:8809/hc.do?a=offline, 这时候在服务里面做优雅下线,关掉tcp链接,这个问题就不会发生了。

5.总结

net/http包虽然很容易实现http服务,但对于client端的确不够友好,至少我没找到什么方式可以直接去控制超时时间,或者管理conn连接池,当然我也可以在它基础上实现一个自己的连接池(ClientConnPool接口)但成本不低。所以很多人都会自己去实现。

对于pod删除时信号量的问题,因为没有权限,所以没法看pod滚动升级时,kublet是怎么删除老的节点的,按照我的理解应该是先给容器发kill -15, 如果没退出再kill -9, 但就目前情况来看服务端并没有捕捉到信号量(我加了捕捉信号量,但并未打印出来log),后面有机会再去深入研究一下k8s的那块代码。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 204,684评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 87,143评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 151,214评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,788评论 1 277
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,796评论 5 368
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,665评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,027评论 3 399
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,679评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 41,346评论 1 299
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,664评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,766评论 1 331
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,412评论 4 321
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,015评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,974评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,203评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,073评论 2 350
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,501评论 2 343