Golang:Go 网络包默认值下的陷阱

Go 网络包默认值下的陷阱

超时时间

设置它们!

网络不可靠,并且标准库默认客户端和服务器未设置其主要超时,并且所有这些都将零值解释为引导的无穷大。超时取决于用例,Go核心团队已避免进行任何笼统的概括。

注意:这也包括对包级别便利功能的所有使用: http.Get客户端朋友http.ListenAndServe和服务器朋友。

一个必然的结果是,您实际上应该始终有一个定制的 http.Client和/或http.Server生产中的Go服务。

客户端超时

对于客户端,您通常只需要配置主超时。它涵盖了E2E交换,并且很可能是您的RPC思维模型的工作方式:

c := &http.Client{
    
    
	Timeout: 5 * time.Second,
}

此超时包括任何HTTP3xx重定向持续时间,响应正文的读取以及连接和握手时间(除非重新使用了连接)。我发现我通常在这里完成有关客户的工作。

但是,要对这些单个属性以及更多属性进行精细控制,您需要降低到基础传输:

c := &http.Client{
    
    
	Timeout: 5 * time.Second,
	Transport: &http.Transport{
    
    
		DialContext: (&net.Dialer{
    
    
			// This is the TCP connect timeout in this instance.
			Timeout: 2500 * time.Millisecond,
		}).DialContext,
		TLSHandshakeTimeout: 2500 * time.Millisecond,
	},
}

注意:由于在返回客户端方法后将读取响应主体,因此time.Timer如果要强制执行读取时间限制,则需要使用a 。

我从来没有需要的传输上有更多超时,例如ResponseHeaderTimeout((在请求写入结束后等待响应头的ExpectContinueTimeout时间)和(100-Continue 如果使用HTTP Expect头则等待的时间)。

还有一些与重用相关的设置,例如传输 IdleConnTimeout和拨号程序的KeepAlive设置。这些都应该放在自己的部分

服务器超时

与您不希望服务器因为没有超时而使客户端的请求成为人质的意图相同,在编写Go HTTP服务器时,您要考虑相反的问题:您不希望行为不佳或滞后的客户端将服务器的文件描述符作为人质。

扫描二维码关注公众号,回复: 13123909 查看本文章

为了避免这种情况,您应该始终有一个自定义http.Server实例:

s := &http.Server{
    
    
	ReadTimeout:  2500 * time.Millisecond,
	WriteTimeout: 5 * time.Second,
}

ReadTimeout这里涵盖了读取请求标头和可选主体所花费的时间,并WriteTimeout涵盖了响应写入结束之前的持续时间。

但是,如果服务器正在处理TLS,则WriteTimeout报价器实际上在读取TLS握手的第一个字节后即开始。在实践中,这意味着您应该考虑整体因素ReadTimeout,然后再考虑要接受的写入内容。

与主要http.Client.Timeout值类似,这是您应该考虑的适当情况值的两个主要服务器超时,但是还有一些其他情况可以提供更精细的控制(例如分别读取和写入标头的时间)。再说一次,我从来不需要使用它们。


这些超时覆盖行为不佳的客户。但是对于服务器,您还应该考虑愿意接受多长时间作为请求处理持续时间。我在上面提到了客户端超时的心理模型;我认为这是服务器端版本,当您考虑时会立即想到:“服务器超时”

使用Go’s,http.Server您可以在处理程序函数本身中使用上下文标记实现这些超时。您还可以使用TimeoutHandler辅助包装器:

func TimeoutHandler(h Handler, dt time.Duration, msg string) Handler

用这种方式包装意味着按惯例,直到dt违反为止,这时将503向下写入带有可选主体的客户端管道msg

HTTP响应主体

关上他们!

作为客户,您可能不在乎响应正文的内容,或者您可能正在期待空的响应。无论哪种方式,您都应该关闭它们。标准库不会代表您执行此操作,这可能会阻止客户端池中的连接,从而阻止重用(即,如果使用HTTP / 1.x保持活动)或更糟的是耗尽主机文件句柄。

但是,即使在没有主体的情况下或具有零长度的主体的情况下,标准库仍保证响应主体为非零。因此,为了安全地关闭事物,需要满足以下条件:

res, err := client.Do(req)
if err != nil {
    
    
	return err
}
defer res.Body.Close()
...

如果您不打算对身体做任何事情,那么完整阅读它仍然很重要。否则会影响重用的可能性,尤其是在服务器正在推送大量数据的情况下。用以下方法冲洗身体:

_, err := io.Copy(ioutil.Discard, res.Body)

注意:视情况而定,可能会尝试重用连接,但是如果服务器推送过多的数据,则关闭连接会更有效。io.LimitedReader在这里可以提供帮助。

HTTP / 1.x保持活动

说到重用,保持活动是Go的默认设置,但有时您不希望使用它们。举例来说,几年前,我曾担任过Webhook发送器的服务。它需要向许多不同的上游目标发出请求(几乎从未如此)。

关闭默认行为的最简单方法是将自定义传输连接到客户端(由于该字段注释中的其他一些原因,我仍然总是这样做):

client := &http.Client{
    
    
    &http.Transport{
    
    
        DisableKeepAlives: true
    }
}

但是,您也可以通过告诉Go客户端为您关闭该请求,以根据每个请求执行此操作:

req.Close = true

或者以其他方式向行为良好的服务器发送信号,以添加Connection: close 响应标头,Go客户端将知道该响应标头该怎么做。

req.Header.Add("Connection", "close")

连接池

继续以重用为主题。在微型SOA中,我发现自己要在上面构建Webhook发射机服务的可能性要比需要高频集成但只集成到少数上游(例如云数据存储/队列)的服务低得多以及一个或两个相关的API)。

在这种更常见的情况下,我认为Gohttp.Client违约对您不利。

我的意思是,客户端的默认传输在连接池方面表现出一些属性,您应该始终注意以下几点:

var DefaultTransport RoundTripper = &Transport{
    
    
	MaxIdleConns:          100,
	IdleConnTimeout:       90 * time.Second,
}
...
const DefaultMaxIdleConnsPerHost = 2

这三个设置之间的关系可以概括如下:保留大小为100的连接池,但每个目标主机仅保留2个连接池;如果90秒钟内未使用任何连接,它将被删除并关闭。

因此,以100个goroutine共享相同或默认值http.Client 以向相同的上游依赖项发出请求的情况为例(如果您自己的客户端本身也是较大的微服务生态系统的服务器部分,则对于每个接收到的请求派生例程)也并非如此。这100个连接中的98个 立即关闭

首先,这意味着您的服务正在更加努力。建立连接有无数成本:内核网络堆栈的处理和分配;DNS查找,其中可能有很多(resolv.conf(5):ndots:n 特别是在运行Kubernetes集群时,请阅读);以及要通过的TCP和TLS握手。

这当然不是最佳选择,但是过去还有另一笔隐性成本困扰着我,使整个主机变得无用:closed!= closed(无论如何在Linux中)。

内核实际上将套接字转换为一种TIME_WAIT状态,其目的主要是为了防止来自某个连接的延迟数据包被后续连接接受。内核会将它们保留大约60秒钟(按照RFC793的要求,在Linux中很难更改)。

TIME_WAIT套接字的堆积可能会对繁忙的主机的资源产生不利影响。

一方面,要有额外的CPU和内存来维护内核中的套接字结构,但最关键的是,连接表中有插槽。正在使用的插槽意味着不存在具有相同四联体的另一个连接(源addr:port,dest addr:port),这反过来可能导致短暂的端口耗尽-令人恐惧EADDRNOTAVAIL

验证URI

这是一个很小的方法,但是就我而言,该url.Parse方法本质上是绝对可靠的,它使我流连忘返。您几乎总是想要url.ParseRequestURI,然后进一步检查是否要过滤掉相对URL。

DNS缓存

与JVM不同,Go标准运行时中没有内置的DNS缓存。这是一把双刃剑。我个人对这个默认值表示感谢,因为该默认值在前世被该JVM缓存烧毁了无数次。同时,在尝试生成优化的Go服务时始终要意识到这一点。

Go核心团队的立场是,您应该通过诸如dnsmasq之类的方式依赖底层主机平台来支持DNS缓存需求。但是,值得指出的是,您并不总是能控制这种情况。例如,AWS Lambda的运行时沙箱在其中包含单个远程Route53地址,/etc/resolv.conf并且不提供沙箱本地缓存服务器。

在这种情况下,您还有另一个选择是覆盖DialContexton http.Transport(似乎是本主题的一般主题)并插入内存中的缓存。我可以为此目的推荐dnscache

注意:net.DefaultResolver如果您不能完全控制客户端的传输,则可能还需要考虑覆盖程序包单例。

如果您对延迟敏感的服务仅具有几个上游依赖项,则可以考虑使用这些选项之一。否则,默认情况下,这些服务将需要连续拨打相同的不变域名。

我之所以说“默认”,是因为您可能有一组经过良好调整的重用连接(也许甚至要感谢有关连接池的部分)。如果是这样的话,那么请告诫-我还有另一本电子书供您参考。

在我以前的客户中,我遇到一个问题,即大量(作为部分)充当反向代理的基于Go的服务一直在代理相同的后端后端端点。这是因为后端目标使用DNS来进行新版本的蓝色/绿色部署,并且尽管主机具有TTL依赖的DNS缓存,但仍在发生。

问题在于该服务如此迅速地重用了连接,以致从未中断空闲超时。

从Go 1.16开始,默认运行时中的任何操作都不会强制关闭那些已建立的连接,并因此获得主机名的已更新解析IP,从而迫使您使用一个单独的goroutine进行创意,以transport.CloseIdleConnections()在不理想的时间间隔内进行调用 。尽管在Go中编写反向代理非常容易,但是我在这里的错误并没有屈服于更专用和端点感知的东西(例如出色的 Envoy代理)。

伪装的DualStacknet.Dial()错误

这一个是细致入微,但是是一个belter如果它击中,它可以面(因为它为我做的),即使你不积极使用IPv6。

以一个库存的Amazon EKS工人节点为例。在撰写本文时,EKS优化的AMI具有以下默认特征:

  1. Docker守护程序未启用实验性IPv6标志,因此不会在容器虚拟网络接口上配置IPv6地址。
  2. 但是内核确实启用了IPv6支持,这意味着/proc/net获取了IPv6构造(即使在容器名称空间中),并且从那里推断出其他一些东西,最关键的是,/etc/hosts接收了两个默认的回送条目:127.0.0.1::1

现在,假设您有一个Go服务,它通过环回调用了另一个服务,例如流程sidecar,但是您使用localhost主机名幼稚地解析了环回地址。

TL; DR:这是您出错的地方。避免麻烦,在这里停止并使用,127.0.0.1或者::1取决于您的目标堆栈,除非您有充分的理由不这样做。继续阅读以了解您为什么要这样做。

您在开发过程中没有注意到,但是在大规模运行的集成环境中,您会偶尔看到::1 cannot assign requested address 拨号程序重复发出的消息。但是,请检查主机和临时端口是否 完全正常。有理论。

但是,该::1IPv6地址族错误是怎么回事?令人担忧的是,从上面的AMI特性来看,我们知道解析该地址的客户端在连接时间上很困难,因为实际上没有绑定到该客户端的网络接口。但话又说回来,如果这是真的,为什么它没有失败所有的时间?

好吧,可能是Go拨号程序伪装了真正的错误**-IPv4错误**!

之所以会发生这种情况,是因为有一些微妙的默认设置:

  1. 首先,当Go拨号程序为同一主机名提供多个地址时,它会根据RFC6724进行排序和选择地址,重要的是,该RFC首先概述了IPv6的选项。
  2. 然后,由于Go的默认网络传输也具有 RFC6555支持(又名“快乐眼球” /“双堆栈”),它将尝试并行拨打两个地址系列,但使主IPv6提前300毫秒。如果主节点快速失败(由于不存在IPv6接口地址,在这种情况下通常会发生故障),则将取消起始,并立即尝试IPv4。到目前为止一切都很好。但是,如果两个地址都无法拨号,则仅返回主要(即IPv6)错误

因此,如果您的IPv4地址无法偶发拨号(例如,上游不畅导致偶发的连接超时),则所显示的错误将是无关紧要的,并且总是无法拨打IPv6::1 cannot assign requested address而不是更有用的IPv4 connect timeout

net.IP 是可变的

尽管我做一些愚蠢的事情,但这仍然使我有点困扰。不要被欺骗而认为net.IP是一个不变的数据结构。实际上,它是别名为的透明类型[]byte。您传递给它的任何东西都可能使它变异,而Sod / Murphy定律说它会改变。

奖励:GOMAXPROCS,容器和CFS

关于默认行为的奖励条目,虽然它与net软件包没有特别关系,但肯定会间接影响其性能。

Go将使用GOMAXPROCSinit的运行时变量值来确定有多少实际OS线程来复用所有用户级别的Go例程。默认情况下,它设置为在主机环境中发现的逻辑CPU的数量,我想这是Go核心团队需要确定合理默认值的另一个示例,即,在通用上下文中,逻辑CPU的数量是提供最高数量的原因。表现。

不幸的是,Go运行时也恰好没有意识到CFS配额,并且多租户容器编排平台(如Kubernetes)将默认使用它们在运行容器的cgroup上强制执行其各自的CPU限制概念。

注意:CFS是Linux内核的完全公平调度程序—一种按比例分配的调度程序,用于在cgroup之间划分可用的CPU带宽。

继续以Kubernetes为例,默认GOMAXPROCS值与CFS不知道配额的运行时混合Pod在一起意味着您的Go服务,加上经过仔细考虑的CPU资源限制和请求规范,可能会发现自己受到CFS的积极和过度限制

为什么是这样?可以说,例如,您的Go服务Pod规范的CPU资源限制为300m(millicpu),并且采用Linux内核的现有CFS配额窗口为100ms,这使Go服务的每个窗口的CPU时间为30ms。如果它尝试使用更多的资源,它将被CFS限制直到下一个窗口。这很好,可以预期,因为您在本地对服务流程进行了科学基准测试,并首先满足了300m的要求(对吗?)。

但是,要记住的关键是实际上30ms实际上在GOMAXPROCSOS线程之间进一步细分,因此如果您Pod碰巧落在大型商用主机VM上,为简单起见,它具有15个逻辑CPU(这在binpack风格的Kubernetes集群拓扑中很常见) ),那么您的Go服务就会天真地设置GOMAXPROCS为15,从而使每个生成的OS线程每个调度窗口的CPU时间可能只有2 毫秒,然后才能被抢占!

结果是Go运行时调度程序浪费了所有在上下文切换上分配的CPU时间,并且没有完成任何有用的工作,因为它认为自己可以访问15个逻辑CPU,而实际上却只能访问小数0.3。

注意:要查看这是否影响您负责的Kubernetes服务,请使用kubelet cAdvisor指标container_cpu_cfs_throttled_periods_total

GOMAXPROCS是一个整数,因此在上面的示例中,您唯一的方法就是将其硬编码为1。这可以通过使用export GOMAXPROCS=1或使用func包在环境中完成runtime.GOMAXPROCS(1)

不过,更笼统地说,建议使用Uber的automaxprocs插件使Go运行时支持CFS。对于Kubernetes运营商,如果您愿意,也可以在kubelet级别禁用CFS配额强制。

猜你喜欢

转载自blog.csdn.net/qq_41257365/article/details/115362788