背景

在最近一直在准备k8s 发布20000 ops吞吐量图片识别服务,在微服务架构支持和方便开发者更新发布,设计必须考虑到更新时候实现高可用,零用户请求丢失,更高要求实现少内部请求丢失。上一篇文章外部 nginx 自动发现 k8s 服务 POD - 2通过502错误自动剔除upstream懒惰更新取得一定成效,流量大时候出现一定量重试,如果流量不大情况串行重试可能出请求连续重试导致失败。这些必须通过k8s实现优雅关闭,处理所有nginx已经提交请求才退出。更新实例时候,nginx错误日志不会出现大量请求reset错误。目的

  • 让 lua nginx 通过dns及时知道关闭实例,有一段缓冲时间把流量导入其他实例上。
  • 对于已经发送请求,需要有99.99%已经可以正常返回。

    优雅关闭思路

    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请求返回,这个时间不处理接受新连接。nginxpod 一般使用长连接,需要库支持长连接请求是否处理完成。如果超时强行推开所有连接。
  • 关闭app和善后: 上传最后时间监控指标,计费统计上传等。

graceful-shutdown-flow.png

nodejs 部署在k8s应用为例子,说一说各个步骤实现思路。

等待负载均衡器剔除实例

应用支持 healthCheck 路由接口,平时返回http状态码 200 ,等待应用关闭时候返回http状态码 503 ,k8s deployment 容器 readinessProbe 配置http探测

Deployment配置

为了降低切换时候错误请求,程序等待时间和k8s http探针检测间隔时间适配。建议:

  • APP等待关闭时间和k8s readnessProbe 检测间隔建议:

    1. 例如程序等待时间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

socket-status-table.png

如上图:
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