小心 Node.js 的 server.keepAliveTimeout 默认值

Node 8.0引入了一个breaking change——server.keepAliveTimeout按照文档的说法

A value of 0 makes the http server behave similarly to Node.js versions prior to 8.0.0, which did not have a keep-alive timeout.

这一版本之前是没有keep-alive超时的8.0之后有了默认5秒的超时

举个例子如果你用Node.js实现了个HTTP服务器没有手动设置server.keepAliveTimeout那么它运行在Node 8.0和之前的版本会有差异Node 8.0之前的版本运行的时候如果客户端的请求是keep-alive服务器会一直等待客户端的下一个请求直到天荒地老或者客户端主动关闭连接而在Node 8.0及之后的版本运行的时候只要客户端没有在5秒之内发起下一个请求服务端就会主动关闭连接

如果你的网站是用Node.js写的并且前面没有类似Nginx之类的反向代理的话那么这个默认的keep-alive timeout显然太短了你的访客很难在5秒之内发起下一个连接连接也就很难被复用了

更糟糕的是当前面有反向代理而且反向代理支持keep-alive反向代理服务器往往会维护一个连接池保持keep-alive的连接可能被设置成最多64个或者128QPS比较低的时候很难在5秒内使用同一个连接两次这种情况下很有可能反向代理刚拿出某个连接尝试写数据发起请求的时候连接恰好被服务器断开了从而写失败

示例图
示例图

因此在用Node.jsHTTP服务器的时候不论是直接暴露到公网还是经过反向代理都需要注意不要被server.keepAliveTimeout的默认值坑了


h2c 的意义

HTTP2已经被大家所熟知2018年才说自己的站点开始支持HTTP2怕是会误让人以为SpaceX提前十几年实现了殖民火星计划当然至今为止绝大多数支持HTTP2的站点享受的只是HTTP2多路复用特性只有少部分站点享受到了诸如Server Push之类的其他特性

虽然HTTP2规范并没有规定HTTP2必须走TLS甚至刻意区分了走TLSh2和明文传输的h2cHTTP/2 cleartext但是大家几乎把HTTP2h2画上了等号——主流浏览器对h2c置之不理我们又何必去理睬h2c墙倒众人推泛域名证书都免费的年代还不上HTTPS怎么对得起Let's Encrypt

h2c虽然不受浏览器待见但还是有意义的通常用户访问一个流量很大的站点流量首先会打到负载均衡器负载均衡器会将流量转发到某个服务器节点TLS握手和之后的对称加解密都会消耗不少CPU资源为了不给后端服务器额外的压力往往会选择在负载均衡器或者其他反向代理层做TLS termination[1]虽然客户端浏览器和代理层之间使用的是HTTPS协议但是后者与后端服务器之间传输的是明文的HTTP协议

TLS termination

问题在于按照上图做了TLS termination之后HTTP2Server Push就没法用了——后端服务器没法向代理层推送资源因为它们之间使用的往往是不支持Server PushHTTP1协议

为了解决这个问题有些代理层约定使用Link响应头[2]来指定要推送的资源路径比如说如果后端服务器给代理层返回如下响应头

HTTP/1.1 200 OK
Link: </app/style.css>; rel=preload; as=style; nopush
Link: </app/script.js>; rel=preload; as=script

代理层可以理解为后端服务器希望用Server Push推送/app/script.js/app/style.css只作为普通的preload看待从而代理层再次向后端服务器请求/app/script.js将其推送给客户端

这种方案是一种无奈之举Server Push增加了种种局限

要真正解决这一问题可以将代理层与后端服务器之间的协议由HTTP1改为使用h2c这样一来代理层做TLS termination之后可以保持HTTP2协议的原汁原味除了可以做Server Push代理层和后端服务器之间也可以享受HTTP2带来的多路复用从而减少两者之间的连接数

听起来很完美但实际上比较难实现原因在于HTTP2的连接是有状态的比如说streamID一旦变得有状态起来连接池和多路复用就泡汤了要想完全享受多路复用可能需要一些诸如对流ID进行映射之类的额外处理

这篇文章只是分享一些目前的想法可行性还需要在更多夜深人静的时候进行调研和思考


  1. TLS termination proxy: https://en.wikipedia.org/wiki/TLS_termination_proxy ↩︎

  2. Preload: https://www.w3.org/TR/preload/ ↩︎


gometalinter 错误地处理了标准库中的文件

现象

golang:alpineDocker镜像跑gometalinter的时候发现它错误地处理了/usr/local/go/src标准库中的文件

$ gometalinter.v2
../../../usr/local/go/src/net/lookup_unix.go:80:24:warning: undeclared name: cgoLookupHost (maligned)
../../../usr/local/go/src/net/lookup_unix.go:95:24:warning: undeclared name: cgoLookupIP (maligned)
../../../usr/local/go/src/net/lookup_unix.go:80:24:warning: error return value not checked (undeclared name: cgoLookupHost) (errcheck)
../../../usr/local/go/src/net/lookup_unix.go:95:24:warning: error return value not checked (undeclared name: cgoLookupIP) (errcheck)
../../../usr/local/go/src/net/lookup_unix.go:80:24:warning: unused variable or constant undeclared name: cgoLookupHost (varcheck)
../../../usr/local/go/src/net/lookup_unix.go:95:24:warning: unused variable or constant undeclared name: cgoLookupIP (varcheck)
...

原因

许多linter使用了golang.org/x/tools/go/loader这个库而它在默认情况下会调用go tool cgo预处理cgo文件除非设置环境变量CGO_ENABLED=0或者Config.Build.CgoEnabled[1]

golang:alpine因为需要控制镜像体积未安装GCCC标准库

解决方案

安装GCCC标准库

$ apk add --no-cache \
    gcc \
    musl-dev

如果没有用到CGO也可以通过环境变量禁用CGO

$ CGO_ENABLED=0 gometalinter.v2

  1. https://godoc.org/golang.org/x/tools/go/loader#Config ↩︎


Memcached 反射攻击

前几天利用memcached进行流量放大攻击的新闻引发热议从原理上来看不复杂也不是什么新颖的攻击方式但在此之前我并没有见到过有关这类攻击的介绍这次事件之所以能够吸引人的注意主要是因为

  1. Memcached被广泛使用其中有许多暴露在公网
  2. 放大倍数非常可观

之所以说是反射攻击是因为攻击者并非直接将大量的流量打向目标而是利用memcached将流量放大并打向目标

下图是正常用户请求memcached的示意图

正常用户的请求

UDP协议本身设计得比较简单没有握手过程很容易伪造来源地址不过由于伪造来源地址后收不到服务器端的响应一般没有实际用途然而当响应体大小远大于请求体的时候UDP服务便成了天然的流量放大器

下图演示了攻击者将来源地址伪造成被攻击者的地址引导memcached向被攻击者发送报文的示意图

攻击者伪造地址,引导 memcached 向被攻击者发送报文

如果基于UDP的应用层协议设计不当比如这里的memcached又恰好被广泛使用暴露于公网那么很容易被攻击者利用上演一出借刀杀人

要利用TCP服务器就没有容易了建立TCP连接需要经历三次握手其核心在于服务器端的初始序列号Initial Sequence Number, ISN客户端只有获知ISN才能与服务器端正式建立TCP连接从而发送真正的请求体——而一旦伪造了来源地址便无法收到ISN

RFC 76124提到ISN的生成器与一个32位的虚拟时钟相绑定时钟大约每4微秒增加一因此ISN大约每4.55小时循环一次然而这种与时间相关的ISN生成器生成出的ISN是比较容易预测的从而引发off-path attackRFC 65284建议使用与四元组相关的伪随机算法来产生ISN

回归这件事本身memcached或者类似的反射攻击或许还会持续——只要公网上还暴露着能被利用的memcached或者其他类似带有设计缺陷的服务攻击就不会停止而对于开发者在设计暴露于公网基于UDP的应用层协议的时候可能需要多操一份心了


Prerender 服务

对前端渲染的项目而言搜索引擎优化或许是令许多开发者望而却步的原因即便目前一些搜索引擎会在抓取网页时启用JavaScript但其渲染的结果是不可预知的——一旦因为某些原因没有渲染出正确的结果搜索引擎将对我们的网站一无所知更靠谱的做法是当搜索引擎的爬虫访问时直接将渲染后的结果丢给它

服务端渲染是一种方案然而它要求服务端与客户端的渲染层是同构的只有这样服务端才能使用客户端的代码渲染出类似的结果在这方面JavaScript具有天然的优势不依赖于BOMDOM的前端代码几乎都可以直接在Node.js中执行ReactVue都引入了虚拟DOM的概念使用者在绝大多数情况下可以避免直接与DOM打交道这也让服务端渲染变得更简单

另一种方案是使用Prerender对页面进行预渲染其原理比较简单大致是启动一个支持JavaScript的浏览器来访问想要预渲染的页面在渲染完毕后读取最终的HTML代码如果写成表达式大致是

new XMLSerializer().serializeToString(document.doctype)
  + document.documentElement.outerHTML

对比服务端渲染和Prerender其实Prerender反而更适合用于搜索引擎优化

一方面是因为从架构上来看Prerender是一层单独的服务普遍适用于各种类型的前端渲染页面而服务端渲染从某种意义上而言是耦合在前端代码之中的要求渲染层同构另一方面是因为服务端渲染的某些步骤对搜索引擎而言没有那么重要——搜索引擎只需要拿到页面渲染后的静态HTML代码能够分析出页面上的链接就可以正常收录页面了而服务端渲染为确保客户端与其渲染后的状态能够同步还做了一些额外的工作甚至为了避免爬虫执行JavaScript代码Prerender默认会移除渲染后的HTML代码中的所有<script>标签

当然Prerender也是有不足的作为服务Prerender跟前端代码是低耦合的最明显的问题就是难以知晓页面什么时候渲染完成Prerender是根据网络请求来判断的如果一段时间内都没有任何请求和AJAX调用就判定页面渲染完成默认情况下Prerender会等待500毫秒这会导致搜索引擎在请求页面时响应比较缓慢建议适当调低等待时间并且开启缓存

Prerender底层使用的是PhantomJS如果需要针对Prerender做一些特殊处理判断navigator.userAgent即可比如Prerender渲染时跳过代码高亮的步骤返回干净的代码

const isPrerender = /\bPhantomJS\b/.test(navigator.userAgent)

const renderer = new marked.Renderer()

if (!isPrerender) {
  renderer.code = (code, language) => {
    return `<pre><code class="hljs ${language}">${hljs.highlightAuto(code).value}</code></pre>`
  }
}

marked.setOptions({ renderer })

另外PhantomJS 2.5才开始支持ES2015目前需要使用一些polyfills包括绝大多数现代浏览器都已经支持了的Promise