背景
在最近一直在准备k8s 发布20000 ops吞吐量图片识别服务,在微服务架构支持和方便开发者更新发布,设计必须考虑到更新时候实现高可用,零用户请求丢失,更高要求实现少内部请求丢失。上一篇文章外部 nginx 自动发现 k8s 服务 POD - 2通过502错误自动剔除upstream懒惰更新取得一定成效,流量大时候出现一定量重试,如果流量不大情况串行重试可能出请求连续重试导致失败。这些必须通过k8s实现优雅关闭,处理所有nginx已经提交请求才退出。更新实例时候,nginx错误日志不会出现大量请求reset错误。目的
- 让 lua nginx 通过dns及时知道关闭实例,有一段缓冲时间把流量导入其他实例上。
-
优雅关闭思路
HTTP对外接口在k8s实现优雅关闭思路,
提供
/healthCheck
http检查接口,正常情况下返回http状态码200
, 问题时候返回http状态码503
。- 使用HTTP检测原因: tcp只有在关闭端口以后,才会有检测失败。
分为下面3个阶段:
- 等待均衡器剔除实例: 提供等待k8s集群把关闭的
pod
从service endpoints删除等待时间,以及ingress nginx / 其他nginx自动更新实例列表时间。 这段时间HTTP接口有下面状态/healthCheck
返回503- 业务请求正常处理
- 目的: 让k8s知道
pod
不健康,从service endpoints删除. 等待Ingress nginx, 其他通过dns列表动态更新upstream, nginx, 不再向此pod发送请求数据。
- 等待HTTP连接处理完成: 这个时间等待接受HTTP请求返回,这个时间不处理接受新连接。
nginx
到pod
一般使用长连接,需要库支持长连接请求是否处理完成。如果超时强行推开所有连接。 - 关闭app和善后: 上传最后时间监控指标,计费统计上传等。
以 nodejs
部署在k8s应用为例子,说一说各个步骤实现思路。
等待负载均衡器剔除实例
应用支持 healthCheck
路由接口,平时返回http状态码 200
,等待应用关闭时候返回http状态码 503
,k8s deployment 容器 readinessProbe
配置http探测
Deployment配置
为了降低切换时候错误请求,程序等待时间和k8s http探针检测间隔时间适配。建议:
APP等待关闭时间和k8s readnessProbe 检测间隔建议:
例如程序等待时间15s, 等待时间必须探测时间2倍以上,http探测时间6s ~ 7s;
nginx 失败重试次数大于k8s滚动更新
maxUnavailable
平时建议 `maxUnavailable` 建议1~2之间, nginx重试次数为3
apiVersion: apps/v1beta1 kind: Deployment metadata: {{ if .Values.rollout }} name: {{ include "mychart.fullname" . }}-rollout {{ else }} name: {{ include "mychart.fullname" . }}-{{ .Values.gitlab.CI_BUILD_STAGE }} {{ end }} labels: app.kubernetes.io/name: {{ include "mychart.name" . }} app.kubernetes.io/module: {{ .Values.app.module }} gitlab.stage: {{ .Values.gitlab.CI_BUILD_STAGE }} spec: replicas: {{ .Values.replicaCount }} #is an optional field that specifies the number of old ReplicaSets to retain to allow rollback. Its ideal value depends on the frequency and stability of new Deployments revisionHistoryLimit: 5 #is an optional field that specifies the minimum number of seconds for which a newly created Pod should be ready without any of its containers crashing minReadySeconds: 10 strategy: type: RollingUpdate rollingUpdate: maxSurge: 4 maxUnavailable: 2 template: # create pods using pod definition in this template metadata: labels: app.kubernetes.io/name: {{ include "mychart.name" . }} app.kubernetes.io/module: {{ .Values.app.module }} app: {{ .Values.gitlab.CI_ENVIRONMENT_SLUG }} gitlab.stage: {{ .Values.gitlab.CI_BUILD_STAGE }} {{ if .Values.rollout }} rollout: "true" {{ else }} rollout: "false" {{ end }} spec: {{- with .Values.nodeSelector }} nodeSelector: {{ toYaml . | indent 8 }} {{- end }} {{- with .Values.affinity }} affinity: {{ toYaml . | indent 8 }} {{- end }} {{- with .Values.tolerations }} tolerations: {{ toYaml . | indent 8 }} {{- end }} dnsPolicy: {{ .Values.dnsPolicy }} containers: - name: {{ .Chart.Name }} image: "{{ .Values.image.full }}" imagePullPolicy: {{ .Values.image.pullPolicy }} env: - name: NODE_ENV value: {{ .Values.app.env.NODE_ENV }} - name: K8S_NODE_IP valueFrom: fieldRef: fieldPath: status.hostIP - name: K8S_POD_NAME valueFrom: fieldRef: fieldPath: metadata.name ports: - containerPort: {{.Values.app.port }} {{ if .Values.app.probeEnabled }} # defines the health checking readinessProbe: httpGet: path: /healthCheck port: {{.Values.app.port}} initialDelaySeconds: 10 periodSeconds: 6 timeoutSeconds: 5 # 只要失败一次在endpoints剔除 failureThreshold: 1 # defines the health checking livenessProbe: httpGet: path: /healthCheck port: {{.Values.app.port}} initialDelaySeconds: 10 periodSeconds: 60 failureThreshold: 5 timeoutSeconds: 10 {{ end }} command: ["npm", "run", "{{ .Values.app.npmCommand }}"] volumeMounts: - name: logs mountPath: /app/logs/ resources: {{ toYaml .Values.resources | indent 10 }} volumes: - name: logs {{ if .Values.app.logOnHost }} hostPath: path: /k8s_local_pv/logs/node/{{ include "mychart.name" . }}/ type: DirectoryOrCreate {{ else }} emptyDir: {} {{ end }}
DNS TTL
其次dns更新ttl间隔响应改变,ttl也需要配置5~6s。这里是外部dns缓存k8s-dns数据。
.:53 { errors whoami log # k8s dns proxy cluster.local xx.xx.xx.xx proxy . 183.60.83.19 183.60.83.98 cache 30 { success 5000 30 6 } }
Nginx 动态更新实例间隔
外部 nginx 自动发现 k8s 服务 POD - 2 nginx dns 更新频率6s刷新一次
# worker 进程执行
init_worker_by_lua_block {
local cjson = require("cjson")
local handler = function()
local resolver = require("resty.dns.resolver")
local resty_roundrobin = require("resty.roundrobin")
local r, err = resolver:new{
-- 配置腾讯云 dns cache
nameservers = { "10.xx.xx.xx", "10.xx.xx.xx", },
retrans = 5, -- 5 retransmissions on receive timeout
timeout = 2000, -- 2 sec
}
if not r then
ngx.log(ngx.ERR, "failed to instantiate the resolver: ", err)
return
end
-- 遍历需要更新实例服务名称
local keys = ngx.shared._upstreams_dns:get_keys()
for idx, v in ipairs(keys) do
ngx.log(ngx.NOTICE, "query:"..v)
local answers, err, tries = r:query(v, nil, {})
if not answers then
ngx.log(ngx.ERR, "failed to query the DNS server: ", err)
return
end
if answers.errcode then
ngx.log(ngx.ERR, "server returned error code: ",answers.errcode,": ",answers.errstr)
end
local endpoints = {}
ngx.log(ngx.NOTICE, v, ", instance count:", #answers)
if (#answers) then
for i, ans in ipairs(answers) do
endpoints[ans.address] = 1
ngx.log(ngx.NOTICE, v, ", resvoled:", ans.address)
end
-- 用新实例重建balancer, 支持指定服务表中
local upstream_rr_balancer = resty_roundrobin:new(endpoints)
-- package.loaded worker 里面使用
package.loaded.rr_balancers[v] = upstream_rr_balancer
-- ngx.shared._upstreams_dns:set(v, upstream_rr_balancer)
end
end
end
ngx.timer.at(0, handler)
-- 每 6 秒更新一次
local ok, err = ngx.timer.every(6, handler)
if not ok then
ngx.log(ngx.ERR, "failed to create the timer:", err)
end
}
Node 优雅关闭库
@godaddy/terminus
优雅关闭库,提供注册添加 Kubernetes readiness / liveness checks 接口配置,提供健康检查,当接受关闭信号以后,这个接口返回 503
http状态码,主要提供事件回调:
- beforeShutdown: 关闭http端口前事件,用于等待负载均衡器剔除实例,当回调接口时候健康检查接口返回状态是
503
。一般执行休眠一段时间,例如15s - onShutdown: 关闭app。已经处理完成之前所有http请求/等待超时,用于关闭前工作,例如上传所有统计数据等等…
使用KOA
function beforeShutdown() {
// sleep 15s
const timeWaitMs = 15 *1000
logger.info(`Wait for 15000ms to shutdown server ......`)
// wait for k8s delete pod ip from service endpoint
return new P.Promise((resolve) => {
setTimeout(resolve, timeWaitMs)
})
}
function async onShutdown() {
logger.info('Try to flush all metrics ......')
.....
logger.info('Server shutdown now ......')
}
function healthCheck() {}
const server = http.createServer(app.callback())
createTerminus(server, {
beforeShutdown: beforeShutdown,
signals: ['SIGINT', 'SIGTERM'],
onShutdown: onShutdown),
// timeout stop connection
timeout: 10 * 1000,
healthChecks: {
'/healthCheck': healthCheck,
verbatim: true // [optional = false] use object returned from /healthCheck verbatim in response
},
logger: console.log
})
等待请求处理完成
由于HTTP 1.1 支持长连接,请求处理完成以后,连接不一定断开,可以处理下一个请求,例如nginx,提高效率一般和upstream保持一段时间长连接。不同语言有写不同,HTTP主要有下面几个事件:
- onConnection: http连接创建时候触发的,对于长连接,不是每个请求都触发,新创建连接请求才触发。
- onRequest: 发送http请求时候触发,无论长连接/短连接, 每个请求都会触发一次。
- onFinish: 每个请求处理完毕以后触发一次。
- onClose: 连接关闭以后才触发。使用长连接时候,不是每个请求都会触发close事件,只有当连接关闭时候触发。
@godaddy/terminus
底层使用 stoppable
根据这个库,等待所有请求正常完成基本思路是
统计各个连接pending请求数目
- 当连接创建
onConnection
时, 在哈希表插入这个socket统计信息, - 接收
onClose
事件,删除对应socket统计信息。 - 触发
onRequest
时候,对应Socket的pending值加1 - 触发
onFinish
时候,对应Socket pending值减1
如上图:
pending为0为空闲连接,pending为1的为非空闲连接,请求处理没有完成。
关键代码如下:
....
const reqsPerSocket = new Map()
if (server instanceof https.Server) {
server.on('secureConnection', onConnection)
} else {
server.on('connection', onConnection)
}
server.on('request', onRequest)
server._pendingSockets = reqsPerSocket
return server
function onConnection (socket) {
reqsPerSocket.set(socket, 0)
socket.once('close', () => reqsPerSocket.delete(socket))
}
function onRequest (req, res) {
// 接收请求时候 pending 值加1
reqsPerSocket.set(req.socket, reqsPerSocket.get(req.socket) + 1)
// 注册finish事件, 只运行一次防止长连接背多次注册
res.once('finish', () => {
// 请求处理完成 pending 值减1
const pending = reqsPerSocket.get(req.socket) - 1
reqsPerSocket.set(req.socket, pending)
// 程序接受关闭事件,正在关闭状态,连接空闲,立刻关闭
if (stopped && pending === 0) {
req.socket.end()
}
})
}
...
详细代码: https://github.com/hunterloftis/stoppable/blob/master/lib/stoppable.js
**
关闭空闲连接
Nodejs http.server库执行close以后,不会接受新连接, 但是等待所有连接关闭以后执行回调,回调完成以后APP 才能退出。但是长连接是在超时时间内不会主动关闭的,需要代码里面主动关闭。需要通过上面Socket哈希统计哪些连接是空闲,哪些需要等待处理结束的。
- 主动关闭空闲连接: 接收信号以后,主动关闭pending为0空闲连接 ```javascript … function endIfIdle (requests, socket) { if (requests === 0) socket.end() }
reqsPerSocket.forEach(endIfIdle) …
- 非空闲连接: 处理完成以后立刻关闭
```javascript
function onRequest (req, res) {
reqsPerSocket.set(req.socket, reqsPerSocket.get(req.socket) + 1)
res.once('finish', () => {
const pending = reqsPerSocket.get(req.socket) - 1
reqsPerSocket.set(req.socket, pending)
// 程序接受关闭事件,正在关闭状态,连接空闲,立刻关闭
if (stopped && pending === 0) {
req.socket.end()
}
})
一旦非空闲连接处理完成,就会立刻关闭。所有非空闲都关闭,那么HTTP服务模块就顺利退出。
- 等待超时,强行关闭所有连接
使用信号回调以后,HTTP服务没有关闭,程序是退出不了的。超过一定时间以后连接请求还没有完成,强制把所有连接都关闭以后,HTTP服务才可以退出。
function stop (callback) {
// allow request handlers to update state before we act on that state
setImmediate(() => {
stopped = true
if (grace < Infinity) {
// 配置定时器去关闭
setTimeout(destroyAll, grace).unref()
}
server.close(e => {
if (callback) {
callback(e, gracefully)
}
})
...
})
}
....
function destroyAll () {
gracefully = false
reqsPerSocket.forEach((reqs, socket) => socket.end())
setImmediate(() => {
reqsPerSocket.forEach((reqs, socket) => socket.destroy())
})
}
应用安全关闭
应用安全关闭是,就是http.server关闭的回调,这个时间服务器全部处理完毕/或者超时没有处理,剩下工作主要是
- 上传API监控统计信息,平时是定时传的,现在最后十几秒统计信息上传
-
Nodejs 优雅关闭遇到坑
多个退出库联合使用
两个个退出处理库一起使用导致, 等待15s或者待所有连接退出不起效果,表现一收到信息,回调没有完成就退出了。当时候和async-exit-hook使用,由于这个库把注册到他里面任务都执行完毕以后,执行下面代码 ```javascript function doExit() {
if (doExitDone) { return; } doExitDone = true; if (exit === true) { // All handlers should be called even if the exit-hook handler was registered first process.nextTick(process.exit.bind(null, code)); }
}
// Async hook callback, decrements waiting counter function stepTowardExit() { process.nextTick(() => { if (—waitingFor === 0) { doExit(); } }); }
详细可以参考: [https://github.com/Tapppi/async-exit-hook/blob/master/index.js](https://github.com/Tapppi/async-exit-hook/blob/master/index.js)
<a name="Ll4eV"></a>
### PM2 方式启动
如果使用PM2启动nodejs程序,需要配置 `kill_timeout` , 默认是1600(ms), 可以修改为16000,否则发送关闭信号1.6s以后程序被pm2-docker 杀掉。
<a name="S3pzq"></a>
### NPM 方式启动
NPM script默认情况下通过 `sh` 再去启动,发送关闭信号以后,不等待应用退出他就退出了。还有 `yarn` 这个工具也是收到关闭信号不等app退出退出了。
package.json
```json
{
"name": "xxx",
"version": "1.x.x",
"script": {
"startInK8s": "exec pm2-runtime pm2/pm2-k8s.json" // 启动脚本添加exec,否则后面命令通过sh -c 'xxx' 启动的
}
}
参考:
优雅http服务优雅关闭库:https://github.com/hunterloftis/stoppable
k8s nodejs app优雅关闭库: https://github.com/godaddy/terminus
Exit hook: https://github.com/Tapppi/async-exit-hook