Golang里的http request timeout比较简单,但是稍不留心就容易出现错误,最近在kubernetes生产环境中出现了的一个问题让我有机会好好捋一捋golang中关于timeout中的所有相关的东西。

Basic

golang中timeout有关的设置, 资料已经比较多, 其中必须阅读的就是The complete guide to Go net/http timeouts,里面详述了关于http中各个timeou字段及其影响, 写的很详细, 本文就不在重复造轮子了。 所以我们在生产环境中的代码绝对不能傻傻的使用http.Get("www.baidu.com")了, 很容易造成client hang死, 默认的http client的timeout值为0, 也就是没有超时。具体的血泪教训可以参见Don’t use Go’s default HTTP client (in production)。对于http package中default的设置最后还是仔细review一遍再使用。

Advanced

golang http.TimeoutHandler

了解了基本的使用方式后,笔者带领大家解析一下其中的http.TimeoutHandlerTimeoutHandler顾名思义是一个handler wrapper, 用来限制ServeHttp的最大时间,也就是除去读写socket外真正执行服务器逻辑的时间,如果ServeHttp运行时间超过了设定的时间, 将返回一个"503 Service Unavailable" 和一个指定的message。 (golang net中各个结构体中各种timeout的不尽相同,但是并没有直接设置ServeHttp timeout的方法, TimeoutHandler是唯一一个方法)。

我们来一起探究一下他的实现, 首先是函数定义:  

// TimeoutHandler returns a Handler that runs h with the given time limit.
//
// The new Handler calls h.ServeHTTP to handle each request, but if a
// call runs for longer than its time limit, the handler responds with
// a 503 Service Unavailable error and the given message in its body.
// (If msg is empty, a suitable default message will be sent.)
// After such a timeout, writes by h to its ResponseWriter will return
// ErrHandlerTimeout.
//
// TimeoutHandler buffers all Handler writes to memory and does not
// support the Hijacker or Flusher interfaces.
func TimeoutHandler(h Handler, dt time.Duration, msg string) Handler {
return &timeoutHandler{
handler: h,
body: msg,
dt: dt,
}
}

可以看到典型的handler wrapper的函数signature, 接收一个handler并返回一个hander, 返回的timeout handler中ServeHttp方法如下:

func (h *timeoutHandler) ServeHTTP(w ResponseWriter, r *Request) {
ctx := h.testContext
if ctx == nil {
var cancelCtx context.CancelFunc
ctx, cancelCtx = context.WithTimeout(r.Context(), h.dt)
defer cancelCtx()
}
r = r.WithContext(ctx)
done := make(chan struct{})
tw := &timeoutWriter{
w: w,
h: make(Header),
}
panicChan := make(chan interface{}, 1)
go func() {
defer func() {
if p := recover(); p != nil {
panicChan <- p
}
}()
h.handler.ServeHTTP(tw, r)
close(done)
}()
select {
case p := <-panicChan:
panic(p)
case <-done:
tw.mu.Lock()
defer tw.mu.Unlock()
dst := w.Header()
for k, vv := range tw.h {
dst[k] = vv
}
if !tw.wroteHeader {
tw.code = StatusOK
}
w.WriteHeader(tw.code)
w.Write(tw.wbuf.Bytes())
case <-ctx.Done():
tw.mu.Lock()
defer tw.mu.Unlock()
w.WriteHeader(StatusServiceUnavailable)
io.WriteString(w, h.errorBody())
tw.timedOut = true
}
}

整体流程为:

  1. 首先初始化context的timeout
  2. 初始化一个timeoutWriter, 该timeoutWriter实现了http.ResponseWriter接口, 内部结构体中有一个bytes.Buffer, 所有的Write方法都是写入到该buffer中。
  3. 异步goroutine调用serveHttp方法, timeoutWriter作为serveHttp的参数, 所以此时写入的数据并没有发送给用户, 而是缓存到了timeoutWriter的buffer中
  4. 最后select监听各个channel:
    1. 如果子groutine panic,则捕获该panic并在主grouinte中panic进行propagate
    2. 如果请求正常完成则开始写入header并将buffer中的内容写给真正的http writer
    3. 如果请求超时则返回用户503

为什么需要先写入buffer, 然后在写给真正的writer呐? 因为我们无法严格意义上的cancel掉一个请求。如果我们已经往一个http writer中写了部分数据(例如已经写了hedaer), 而此时因为某些逻辑处理较慢, 并且发现已经过了timeout阈值, 想要cancel该请求。此时已经没有办法真正意义上取消了,可能对端已经读取了部分数据了。一个典型的场景是HTTP/1.1中的分块传输, 我们先写入header, 然后依次写入各个chunk, 如果后面的chunk还没写已经超时了, 那此时就陷入了两难的情况。

此时就需要使用golang内置的TimeoutHandler了,它提供了两个优势:

  1. 首先是提供了一个buffer, 等到所有的数据写入完成, 如果此时没有超时再统一发送给对端。 并且timeoutWriter在每次Write的时候都会判断此时是否超时, 如果超时就马上返回错误。
  2. 给用户返回一个友好的503提示

实现上述两点的代价就是需要维护一个buffer来缓存所有的数据。有些情况下是这个buffer会导致一定的问题,设想一下对于一个高吞吐的server, 每个请求都维护一个buffer势必是不可接受的, 以kubernete为例, 每次list pods时可能有好几M的数据, 如果每个请求都写缓存势必会占用过多内存, 那kubernetes是如何实现timeout的呐?

kubernetes timeout Handler

kubernetes 为了防止某个请求hang死之后一直占用连接, 所以会对每个请求进行timeout的处理, 这部分逻辑是在一个handler chain中WithTimeoutForNonLongRunningRequests handler实现。其中返回的WithTimeout的实现如下:

// WithTimeout returns an http.Handler that runs h with a timeout
// determined by timeoutFunc. The new http.Handler calls h.ServeHTTP to handle
// each request, but if a call runs for longer than its time limit, the
// handler responds with a 504 Gateway Timeout error and the message
// provided. (If msg is empty, a suitable default message will be sent.) After
// the handler times out, writes by h to its http.ResponseWriter will return
// http.ErrHandlerTimeout. If timeoutFunc returns a nil timeout channel, no
// timeout will be enforced. recordFn is a function that will be invoked whenever
// a timeout happens.
func WithTimeout(h http.Handler, timeoutFunc func(*http.Request) (timeout <-chan time.Time, recordFn func(), err *apierrors.StatusError)) http.Handler {
return &timeoutHandler{h, timeoutFunc}
}

其中主要是timeoutHandler, 实现如下:

type timeoutHandler struct {
handler http.Handler
timeout func(*http.Request) (<-chan time.Time, func(), *apierrors.StatusError)
} func (t *timeoutHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
after, recordFn, err := t.timeout(r)
if after == nil {
t.handler.ServeHTTP(w, r)
return
} result := make(chan interface{})
tw := newTimeoutWriter(w)
go func() {
defer func() {
result <- recover()
}()
t.handler.ServeHTTP(tw, r)
}()
select {
case err := <-result:
if err != nil {
panic(err)
}
return
case <-after:
recordFn()
tw.timeout(err)
}
}

如上, 在ServeHTTP中主要做了几件事情:

  1. 调用timeoutHandler.timeout设置一个timer, 如果timeout时间到到达会通过after这个channel传递过来, 后面会监听该channel
  2. 创建timeoutWriter对象, 该timeoutWriter中有一个timeout方法, 该方法会在超时之后会被调用
  3. 异步调用ServeHTTP并将timeoutWriter传递进去,如果该groutine panic则进行捕获并通过channel传递到调用方groutine, 因为我们不能因为一个groutine panic导致整个进程退出,而且调用方groutine对这些panic信息比较感兴趣,需要传递过去。
  4. 监听定时器channel

如果定时器channel超时会调用timeoutWrite.timeout方法,该方法如下:

func (tw *baseTimeoutWriter) timeout(err *apierrors.StatusError) {
tw.mu.Lock()
defer tw.mu.Unlock() tw.timedOut = true // The timeout writer has not been used by the inner handler.
// We can safely timeout the HTTP request by sending by a timeout
// handler
if !tw.wroteHeader && !tw.hijacked {
tw.w.WriteHeader(http.StatusGatewayTimeout)
enc := json.NewEncoder(tw.w)
enc.Encode(&err.ErrStatus)
} else {
// The timeout writer has been used by the inner handler. There is
// no way to timeout the HTTP request at the point. We have to shutdown
// the connection for HTTP1 or reset stream for HTTP2.
//
// Note from: Brad Fitzpatrick
// if the ServeHTTP goroutine panics, that will do the best possible thing for both
// HTTP/1 and HTTP/2. In HTTP/1, assuming you're replying with at least HTTP/1.1 and
// you've already flushed the headers so it's using HTTP chunking, it'll kill the TCP
// connection immediately without a proper 0-byte EOF chunk, so the peer will recognize
// the response as bogus. In HTTP/2 the server will just RST_STREAM the stream, leaving
// the TCP connection open, but resetting the stream to the peer so it'll have an error,
// like the HTTP/1 case.
panic(errConnKilled)
}
}

可以看到, 如果此时还没有写入任何数据, 则直接返回504状态码, 否则直接panic。 上面有一大段注释说明为什么panic, 这段注释的出处在kubernetes issue:

API server panics when writing response #29001
。 引用的是golang http包作者 Brad Fitzpatrick的话, 意思是: 如果我们已经往一个writer中写入了部分数据,我们是没有办法timeout, 此时goroutine panic或许是最好的选择, 无论是对于HTTP/1.1还是HTTP/2.0, 如果是HTTP/1.1, 他不会发送任何数据,直接断开tcp连接, 此时对端就能够识别出来server异常,如果是HTTP/2.0 此时srever会RST_STREAM该stream, 并且不会影响connnection, 对端也能够很好的处理。 这部分代码还是很有意思的, 很难想象kubernetes会以panic掉groutine的方式来处理一个request的超时。

panic掉一个groutine, 如果你上层没有任何recover机制的话, 整个程序都会退出, 对于kubenernetes apiserver肯定是不能接受的, kubernetes在每个request的handler chain中会有一个genericfilters.WithPanicRecovery进行捕获这样的panic, 避免整个进程崩溃。

Other

谈完TimeoutHandler, 再回到golang timeout,有时虽然我们正常timeout返回, 但并不意味整个groutine就正常返回了。此时调用返回也只是上层返回了, 异步调用的底层逻辑没有办法撤回的。 因为我们没办法cancel掉另一个grouine,只能是groutine主动退出, 主动退出的实现思路大部分是通过传递一个context或者close channel给该groutine, 该groutine监听到退出信号就终止, 但是目前很多调用是不支持接收一个context或close channle作为参数的。

例如下面这段代码:因为在主逻辑中sleep了4s是没有办法中断的, 即时此时request已经返回,但是server端该groutine还是没有被释放, 所以golang timeout这块还是非常容易leak grouine的, 使用的时候需要小心。

package main

import (
"fmt"
"net/http"
"runtime"
"time"
) func main() {
go func() {
for {
time.Sleep(time.Second)
fmt.Printf("groutine num: %d\n", runtime.NumGoroutine())
}
}() handleFunc := func(w http.ResponseWriter, r *http.Request) {
fmt.Printf("request %v\n", r.URL)
time.Sleep(4 * time.Second)
_, err := fmt.Fprintln(w, "ok")
if err != nil {
fmt.Printf("write err: %v\n", err)
}
}
err := http.ListenAndServe("localhost:9999", http.TimeoutHandler(http.HandlerFunc(handleFunc), 2*time.Second, "err: timeout"))
if err != nil {
fmt.Printf("%v", err)
}
}

写在最后

golang timeout 简单但是比较繁琐,只有明白其原理才能真正防患于未然

2020/4/13 更新: 上述代码存在资源泄露的问题,已经被社区修复,参加 http://likakuli.com/post/2019/12/06/apiserver_goroutine_leak/

golang timeoutHandler解析及kubernetes中的变种的更多相关文章

  1. golang json解析到map中

    package main import ( "fmt" "encoding/json" ) type ItemMessage struct { ItemType ...

  2. 深入解析kubernetes中的选举机制

    Overview 在 Kubernetes的 kube-controller-manager , kube-scheduler, 以及使用 Operator 的底层实现 controller-rumt ...

  3. 如何发现 Kubernetes 中服务和工作负载的异常

    大家好,我是来自阿里云的李煌东,今天由我为大家分享 Kubernetes 监控公开课的第二节内容:如何发现 Kubernetes 中服务和工作负载的异常. 本次分享由三个部分组成: 一.Kuberne ...

  4. kubernetes中的Pause容器如何理解?

    前几篇文章都是讲的Kubernetes集群和相关组件的部署,但是部署只是入门的第一步,得理解其中的一些知识才行.今天给大家分享下Kubernets的pause容器的作用. Pause容器 全称infr ...

  5. Kubernetes中的网络

    一.引子 既然Kubernetes中将容器的联网通过插件的方式来实现,那么该如何解决这个的联网问题呢? 如果你在本地单台机器上运行docker容器的话注意到所有容器都会处在docker0网桥自动分配的 ...

  6. Golang配置文件解析-oozgconf

    代码地址如下:http://www.demodashi.com/demo/14411.html 简介 oozgconf基于Golang开发,用于项目中配置文件的读取以及加载,是一个轻量级的配置文件工具 ...

  7. 从零开始入门 | Kubernetes 中的服务发现与负载均衡

    作者 | 阿里巴巴技术专家  溪恒 一.需求来源 为什么需要服务发现 在 K8s 集群里面会通过 pod 去部署应用,与传统的应用部署不同,传统应用部署在给定的机器上面去部署,我们知道怎么去调用别的机 ...

  8. Kubernetes 中的服务发现与负载均衡

    原文:https://www.infoq.cn/article/rEzx9X598W60svbli9aK (本文转载自阿里巴巴云原生微信公众号(ID:Alicloudnative)) 一.需求来源 为 ...

  9. 概念验证:在Kubernetes中部署ABAP

    对于将SAP ABAP应用服务器组件容器化和在Kubernetes中部署它们,我们在SPA LinuxLab中做了概念验证(PoC),本文将介绍一些我们的发现和经验.本文会也会指出这项工作的一些潜在的 ...

随机推荐

  1. [Usaco2007 Open]Fliptile 翻格子游戏题解

    问题 B: [Usaco2007 Open]Fliptile 翻格子游戏 时间限制: 5 Sec  内存限制: 128 MB 题目描述 Farmer John knows that an intell ...

  2. Spring Boot 整合 Shiro实现认证及授权管理

    Spring Boot Shiro 本示例要内容 基于RBAC,授权.认证 加密.解密 统一异常处理 redis session支持 介绍 Apache Shiro 是一个功能强大且易于使用的Java ...

  3. SQLite的一些体会

    SQLite遵循sql语法,所以如果接触过数据库,使用它进行增删改查几乎没障碍.在.net中,它与Mysql.sql server的类也相似,比如连接类名字是SQLiteConnection,不过它S ...

  4. SpringBoot工程热部署

    SpringBoot工程热部署 1.在pom文件中添加热部署依赖 <!-- 热部署配置 --> <dependency> <groupId>org.springfr ...

  5. 写给后端同学的vue

    安装环境 安装vue-cli 脚手架 1. 安装nodejs环境 下载地址: (nodejs)[https://nodejs.org/zh-cn/download/] 安装(略) 2. 安装vue-c ...

  6. Git的使用和配置小白必看都是干货,为您解惑

    Git安装 首先下载git这个软件,然后打开码云新建仓库 在本地选择一个路径作为本地仓库 点新建仓库然后输入邮箱和密码,然后进行配置 在要作为本地仓库的地方新建一个文件夹,保存后关闭,在文件夹空白处鼠 ...

  7. kubernetes二进制高可用部署实战

    环境: 192.168.30.20 VIP(虚拟) 192.168.30.21 master1 192.168.30.22 master2 192.168.30.23 node1 192.168.30 ...

  8. Linux/UNIX编程:使用C语言实现简单的 ls 命令

    刚好把 Linux/UNIX 编程中的文件和IO部分学完了,就想编写个 ls 命令练习一下,本以为很简单,调用个 stat 就完事了,没想到前前后后弄了七八个小时,90%的时间都用在格式化(像 ls ...

  9. HZOJ 单

    两个子任务真的是坑……考试的时候想到了60分的算法,然而只拿到了20分(各种沙雕错,没救了……). 算法1: 对于测试点1,直接n遍dfs即可求出答案,复杂度O(n^2),然而还是有好多同学跑LCA/ ...

  10. TCP传输协议如何进行拥塞控制?

    拥塞控制 拥塞现象是指到达通信子网中某一部分的分组数量过多,使得该部分网络来不及处理,以致引起这部分乃至整个网络性能下降的现象,严重时甚至会导致网络通信业务陷入停顿,即出现死锁现象.这种现象跟公路网中 ...