这是 Cloudflare 的 Filippo Valsorda 2016 年发表在 Gopher Academy 的一篇 文章(link:https://blog.gopheracademy.com/advent-2016/exposing-go-on-the-internet/) , 虽然过去两年了,但是依然很有意义。
先前 crypto/tls 太慢而 net/http 也很年轻, 所以对于 Go web server 来说, 通常我们明智的做法把它放在反向代理的后面, 如 nginx 等,现在不需要了。在 Cloudflare 我们最近试验了直接暴漏纯 Go 的服务作为主机。 Go 1.8 的 net/http 和 crypto/tls 提供了稳定的、高性能并且灵活的功能。
然后,需要做一些调优的工作,本文我们将展示怎么去调优和使 web 服务器更稳定。
2016 年了,你不会再运行一个不加密的 HTTP Server,所以你需要 crypto/tls 。好消息使这个库已经非常 快(link:https://blog.cloudflare.com/go-crypto-bridging-the-performance-gap/) 了(我们的 测试(link:https://blog.gopheracademy.com/advent-2016/tls-termination-bench/) ),目前他的安全攻击追踪也很优秀。
缺省配置是使用 Mozilla 参考(link:https://wiki.mozilla.org/Security/Server_Side_TLS) 中的中级推荐配置,但是 你仍然应该设置 PreferServerCipherSuites 以确保采用更快更安全的密码库, CurvePreferences 避免未优化的曲线。 客户端如果使用 CurveP384 算法回导致我们的机器多达 1 秒的 cpu 消耗。
- &tls.Config{
- // Causes servers to use Go's default ciphersuite preferences,
- // which are tuned to avoid attacks. Does nothing on clients.
- PreferServerCipherSuites: true,
- // Only use curves which have assembly implementations
- CurvePreferences: []tls.CurveID{
- tls.CurveP256,
- tls.X25519, // Go 1.8 only
- },
- }
如果你想配置兼容性, 你可以设置 MinVersion 和 CipherSuites 。
- MinVersion: tls.VersionTLS12,
- CipherSuites: []uint16{
- tls.TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384,
- tls.TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384,
- tls.TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305, // Go 1.8 only
- tls.TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305, // Go 1.8 only
- tls.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256,
- tls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256,
- // Best disabled, as they don't provide Forward Secrecy,
- // but might be necessary for some clients
- // tls.TLS_RSA_WITH_AES_256_GCM_SHA384,
- // tls.TLS_RSA_WITH_AES_128_GCM_SHA256,
- },
注意 Go 的 CBC 加密套件的实现(上面我们禁用了)很容易收到 Lucky13 攻击(link:https://www.imperialviolet.org/2013/02/04/luckythirteen.html) , 即使 Go 1.8 实现了部分的 处理(link:https://github.com/golang/go/commit/f28cf8346c4ce7cb74bf97c7c69da21c43a78034) 。
最后需要注意的是, 所有这些建议仅适用 amd64 架构因为它可以实现快速的常数级的 加密原语(link:https://blog.cloudflare.com/go-crypto-bridging-the-performance-gap/) (AES-GCM, ChaCha20-Poly1305, P256), 其它架构可能不适合产品级应用。既然是服务要暴漏带互联网上, 它需要一个公开的可信的证书。通过 Let’s Encrypt 很容易申请, 可以使用 golang.org/x/crypto/acme/autocert 的 GetCertificate 函数。
不要忘了将 HTTP 重定向到 HTTPS, 如果你的客户端是浏览器的话,可以考虑 HSTS(link:https://www.owasp.org/index.php/HTTP_Strict_Transport_Security_Cheat_Sheet) 。
- srv := &http.Server{
- ReadTimeout: 5 * time.Second,
- WriteTimeout: 5 * time.Second,
- Handler: http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
- w.Header().Set("Connection", "close")
- url := "https://" + req.Host + req.URL.String()
- http.Redirect(w, req, url, http.StatusMovedPermanently)
- }),
- }
- go func() { log.Fatal(srv.ListenAndServe()) }()
你可以使用 SSL Labs test(link:https://www.ssllabs.com/ssltest/) 检查配置是否正确。
net/http 包含 HTTP/1.1 和 HTTP/2 。你一定已经熟悉了 Handler 的开发,所以本文不讨论它。我们讨论服务器端背后的一些场景。
超时可能是最容易忽略的危险的场景。你的服务可能在受控网络中幸免于难,但是在互联网上就不会那么幸运了, 特别是(不仅仅)受到恶意攻击。有一系列的资源需要超时控制。尽管 goroutine 消耗很少,但文件描述符总是有限的。卡住的连接、不工作的连接甚至恶意断掉的连接不应该消耗它们。
一个超过最大文件符的服务器总是不能接受新的连接, 会报下面的失败:
- http: Accept error: accept tcp [::]:80: accept: too many open files; retrying in 1s
一个缺省的 http.Server , 、就像包文档中的例子 http.ListenAndServe 和 http.ListenAndServeTLS , 没有设置任何超时控制, 你肯定不是你想要的。
在 http.Server 有三个参数控制 timeout: ReadTimeout , WriteTimeout 和 IdleTimeout ,你可以显示地设置它们:
- srv := &http.Server{
- ReadTimeout: 5 * time.Second,
- WriteTimeout: 10 * time.Second,
- IdleTimeout: 120 * time.Second,
- TLSConfig: tlsConfig,
- Handler: serveMux,
- }
- log.Println(srv.ListenAndServeTLS("", ""))
ReadTimeout 的时间范围起自连接备接受,止于请求的 body 完全读出。在 net/http 的实现中它在连接 Accept 后通过 SetReadDeadline 设置(link:https://github.com/golang/go/blob/3ba31558d1bca8ae6d2f03209b4cae55381175b3/src/net/http/server.go#L750) 。
ReadTimeout 最大的问题它不允许服务器给客户端更多的时间去请求的 body stream。 go 1.8 新引入了一个参数 ReadHeaderTimeout ,它止于读完请求头。然后一直有一些不清楚的方式去设置读超时,相关的设计讨论可以参考 #16100(link:https://golang.org/issue/16100) 。
WriteTimeout 超时正常起自读完请求头, 止于 response 写完(也就是 ServeHTTP 的生命周期), 通过 readRequest 的 结尾(link:https://github.com/golang/go/blob/3ba31558d1bca8ae6d2f03209b4cae55381175b3/src/net/http/server.go#L753-L755) 处的 SetWriteDeadline 设置。
然后,当通过 HTTPS 连接时, SetWriteDeadline 在 Accept 后立即设置, 所以它也包含 TLS 握手的 packet 的写。讨厌的是,这意味着 WriteTimeout 包含 http 头的读以及第一个字节的等待。
ReadTimeout 和 WriteTimeout 是绝对值,无法在 Handler 中更改它( #16100(link:https://golang.org/issue/16100) )。
Go 1.8 还新引入了 IdleTimeout 参数, 用来限制服务端 Keep-Alive 连接在重用前 idle 的数量。
Go 1.8 之前的版本, ReadTimeout 在请求完成后又立即开始滴答(tick),这对 Keep-Alive 连接是不合适的: idle time 会消耗客户端允许发送请求的时间,导致一些快的客户端会有不期望的超时。
对于不可信的客户端和网络,你应该设置 Read , Write 和 Idle 超时, 这样一个读或者写很慢的客户端不会长时间占用一个连接。
对于 go 1.8 之前的 HTTP/1.1 超时的背景知识, 你可以参考 Cloudflare 的 博客(link:https://blog.cloudflare.com/the-complete-guide-to-golang-net-http-timeouts/) 。
HTTP/2 在 Go 1.6+中回自动启用, 只要它满足下面的条件:
HTTP/2 和 HTTP/1.1 有些不同,因为同一个连接同时会服务多个请求,但是 Go 抽象了统一的超时控制接口。
遗憾的是, Go 1.7 中的 ReadTimeout 会打断 HTTP/2 连接,它不会为每一个连接重置,而是在连接初次建立时就设置而不会重置,当超时后就会断掉 HTTP/2 连接。 Go 1.8 修复了这个 问题(link:https://github.com/golang/go/issues/16450) 。
基于此和 ReadTimeout 的 idle time 问题,我强烈建议你尽快升级到 1.8。
如果你使用 ListenAndServe (与传入 net.Listener 给 Serve 不同,这个方法使用缺省值提供了零保护措施), 3 分钟的 TCP Keep-Alive 会 自动设置(link:https://github.com/golang/go/blob/61db2e4efa2a8f558fd3557958d1c86dbbe7d3cc/src/net/http/server.go#L3023-L3039) ,它会让彻底消失的 client 有机会放弃连接, 我的经验是不要完全相信它, 无论如何也要设置超时。
首先, 3 分钟太长了,你可以使用你自己的 tcpKeepAliveListener 调整它。
更重要的是, Keep-Alive 只是保证 client 还活着,但不会设置连接存活的上限。恶意攻击的客户端会打开非常多的连接,导致你的服务器打开很多文件描述符, 通过未完成的请求, 会导致你的服务拒绝服务。
最后,我的经验是连接往往会导致泄漏,知道 超时起作用(link:https://github.com/FiloSottile/Heartbleed/commit/4a3332ca1dc07aedf24b8540857792f72624cdf7) 。
包级别的 http.Handle[Func] (和你的 web 框架)注册 handler 到全局的 http.DefaultServeMux , 如果 Server.Handler 是 nil 的话, 你应该避免这样做。
任何你输入的包,不管是直接的还是间接的,都可以访问 http.DefaultServeMux ,可能会注册你不期望的 route。例如,包依赖中有任何一个库导入了 net/http/pprof ,客户端都能得到你的应用的 CPU 的 profile。 你可以使用 net/http/pprof 手工注册。
正确的是, 初始化你自己的 http.ServeMux ,把 handler 注册到它的上面, 设置它为 Server.Handler , 或者设置你自己的 web 框架为 Server.Handler 。
net/http 在调用你的 handler 之前做了大量的工作, 比如 接受连接(link:https://colobu.com/2018/07/25/exposing-go-on-the-internet/) https://github.com/golang/go/blob/1106512db54fc2736c7a9a67dd553fc9e1fca742/src/net/http/server.go#L2631-L2653, TLS 握手(link:https://github.com/golang/go/blob/1106512db54fc2736c7a9a67dd553fc9e1fca742/src/net/http/server.go#L1718-L1728) 等等……
当任何一个步骤出错,它会写一行日志到 Server.ErrorLog 。其中一些错误, 比如超时和连接重置, 在互联网上是正常的。你可以连接大部分错误并把它们加入到 metric 中,这要归功于这个保证:
Each logging operation makes a single call to the Writer’s Write method.
如果在 handler 中你不想输出堆栈 log, 你可以使用 panic(nil) 或者使用 Go 1.8 的 panic(http.ErrAbortHandler) 。
metric 可以帮助你监控打开的文件描述符。 Prometheus 使用 proc 文件系统来帮助你完成这些(link:https://github.com/prometheus/client_golang/blob/575f371f7862609249a1be4c9145f429fe065e32/prometheus/process_collector.go) 。
如果你需要调研泄漏问题, 你可以使用 Server.ConnState 钩子来得到更多的连接的细节 metric。注意,不保持 state 就没有方式能保持一个正确的 StateActive 数量,所以你需要维护一个 map[net.Conn]ConnState 。
使用 Nginx 做 Go 服务前端的日志一去不复返了, 但是面对互联网你仍然需要做一些额外的防护措施, 可能需要升级到新的 Go 1.8 版本。