wxsm's space

Oct 21, 2020

自动化部署: 从脚本到 K8s

k8-logo

如果公司有专业运维,项目的部署上线过程一般来说开发者都不会接触到。但是很不幸,我所在的团队没有独立的运维团队,所以一切都得靠自己(与同事)。

以下都只是工作中逐步优化得到的经验总结,并且只以 Node.js 程序部署为例。

# 部署上线的原始版本

# 流程图

举例:服务器使用 PM2 管理部署。纯手工操作:

# 总结

整个过程耗时 10~30 分钟不等。

优点:

  1. 不依赖任何工具/系统
  2. 适用于任何分支

缺点:

  1. 麻烦、耗时
  2. 易出错
  3. 无法持续部署
  4. 多节点怎么办?

# 基于 CI 系统的全自动版本

一般来说企业都会有一套 CI 系统,可能是传统的 Jenkins,也可能是 Gitlab CI / Github Actions / Travis CI / Circle CI 等等。它们之间大同小异,都是通过某种方式写好若干份配置文件,当某些操作(如 git push)触发时以及满足某种条件(如当前分支为发布分支,或提交了 tag 等)执行某些任务。

这里使用 Gitlab CI 举例,CI/CD 通过 Gitlab Runner 完成,服务器使用 PM2 管理部署。

# 流程图

# 技术细节

配置文件 .gitlab-ci.yml:

image: node

before_script:
  - yarn --frozen-lockfile

stages:
  - test
  - build
  - deploy

# 代码测试
test:
  stage: test
  script:
    - npm run lint
    - npm run test
  tags:
    - node

# 前端代码构建测试
build-frontend:
  stage: build
  script:
    - npm run build
  tags:
    - node

# 发布 dev 环境,其他环境略
deploy-dev:
  stage: deploy
  # 仅在 release-dev 分支上执行改任务
  only:
    - release-dev 
  script:
    - npm run build:dev
    - scp -r . user@host:~/path/to/deploy
    - ssh user@@host "
        pm2 delete -s frontend || true &&
        pm2 delete -s server || true &&
        pm2 serve /path/to/deploy/frontend/ 8080 --name frontend &&
        yarn --cwd /path/to/deploy/server/ &&
        pm2 start /path/to/deploy/server/server.js --name server"
  tags:
    - node

# ...

# 总结

优点:

  1. 全过程仅依赖 Gitlab 与 Gitlab Runner (基于或不基于容器)
  2. 全自动测试,提交到发布分支则全自动部署,测试失败的代码不会被部署

缺点:

  1. 需要在 Gitlab 上配置远程机器的登录凭证(账号/密码),或在 Runner 机器上配置 ssh key
  2. Runner 会拥有部署机器的访问权限
  3. 多节点?运维?

# Gitlab 与 Agent 平台结合的半自动版本

为了解决上述 Runner 机器权限过高的问题,这个版本引入了 Agent 平台的概念。每个企业使用的平台可能有所区别,有可能是自研的(如我司),也有可能是外部提供的的(如「宝塔」)。但大体功能基本一致。

该版本中:

  1. CI 通过 Gitlab Runner 完成。任务完成后会将代码打包,并放置于服务器上的某个位置,该位置通过 Nginx 暴露(仅对内)
  2. CD 通过 Agent 平台完成。Agent 从上一步暴露的地址中下载代码,解压缩并放置到指定位置,重启 PM2 服务

# 流程图

# 总结

这一个版本中,CI 系统的配置简化了,去除部署部分的任务即可。至于 Agent 平台的配置方式,可能是一个完整的 bash 脚本,也可能是其它配置,就不在此展开了。

优点:

  1. CI 过程全自动
  2. CI/CD 权限解耦
  3. 适用于各种分支

缺点:

  1. 非线上发布过程也需要手动完成,麻烦
  2. 严重依赖 Agent 平台
  3. 运维?

# Gitlab 与 k8s 结合的全自动版本 v1

k8s (kubernetes) 是一个容器集群部署管理系统。

容器基础知识:

  1. 镜像 Image (opens new window)
  2. 容器 Container (opens new window)

k8s 基础知识:

  1. 工作单元 pod (opens new window)
  2. 服务 service (opens new window)
  3. 节点 node (opens new window)
  4. Kustomize (opens new window)

# 流程图

# 技术细节

Dockerfile:

FROM alpine

RUN apk add --no-cache --update nodejs nodejs-npm yarn
RUN adduser -u 1000 -D app -h /data

USER app

COPY --chown=app start.sh /data/start.sh

WORKDIR /data

EXPOSE 8000
ENTRYPOINT [ "sh", "/data/start.sh" ]

start.sh:

#!/usr/bin/env sh

# 从文件服务器获取该版本包
VERSION_DEPLOY_HTTPCODE=`curl -s "https://xxx/${VERSION}" -o pkg.tgz -w "%{http_code}"`
if [ "$VERSION_DEPLOY_HTTPCODE" == "200" ]; then
    echo "using version: ${VERSION}"
    tar zxf pkg.tgz
else
    echo "version package not exist: ${VERSION}"
    exit 1
fi

sh deploy.sh

kill 实现:

// koa
router.all('/api/kill', async (ctx, next) => {
  if (!IS_PROD) {
    ctx.body = 'ok'
    process.exit(0)
  } else {
    next()
  }
})

# 总结

优点:

  1. CI 过程全自动
  2. 非线上环境 CD 全自动,线上环境 CD 手动指定版本,兼顾方便与安全
  3. 无需配置远程机器权限

缺点:

  1. k8s 使用原始镜像启动 pod,拉取代码与安装依赖的过程非常耗时(每次启动都是全新镜像,无缓存)。
  2. CD 过程不确定性较多,存在代码文件服务器故障、依赖安装故障等风险。
  3. kill 指令发出后服务会暂时不可用。
  4. 多节点?

# Gitlab 与 k8s 结合的全自动版本 v2

为了解决上面的问题 3/4,引入 Consul 对 CI/CD 过程做出了改进。

Consul 是为基础设施提供服务发现和服务配置的工具,包含多种功能,这次用到了其中两个功能:

  1. 服务发现
  2. 健康检查

# 流程图

# 技术细节

这个流程里面涉及到几个问题:

# 关于“逐个发送 kill 指令”

虽然通过 Consul 可以获取到所有运行中 pod 的 ip 及端口,但是如果集中发送 kill 命令仍然会造成服务不可用。目前我司服务端的 CI 就有这个问题,他们虽然每个 kill 会有一段固定时间的 sleep 间隔,但无法保证下一个 kill 发出时上个服务时候已重启完毕。

为了解决这个问题,我写了一个脚本。

流程:

代码:

#! /usr/bin/env node

/**
 * 此脚本在gitlab-runner中作为CI的最后一步执行
 * 在非线上环境中可以对容器进行逐个重启,尽量减少downtime
 */

// 确保线上环境不执行此脚本
if (process.env.NODE_ENV === 'production') {
  return
}

const http = require('http')

function request (host, port, path) {
  return new Promise(((resolve, reject) => {
    const req = http.request({
      hostname: host,
      port: port,
      path: path,
      method: 'GET',
      timeout: 1,
      headers: {
        'Content-Type': 'application/json'
      }
    }, function (res) {
      res.setEncoding('utf8')
      let data = ''
      res.on('data', (chunk) => {
        data += chunk
      })
      res.on('end', () => {
        resolve({
          status: res.statusCode,
          data
        })
      })
    })
    req.on('error', e => {
      console.log(e.message)
      resolve()
    })
    req.end()
  }))
}

async function sleep (time) {
  return new Promise((resolve) => {
    setTimeout(resolve, time)
  })
}

(async () => {
  // 各环境的consul请求地址
  // 具体使用时需要填入指定 host 与 port
  const apiMap = {
    development: ['host', 'port'],
    qa: ['host', 'port'],
    pp: ['host', 'port']
  }
  // 从consul获取已注册pod列表
  const api = apiMap[process.env.NODE_ENV]
  const res = await request(api[0], api[1], api[2])
  if (!res || !res.data) {
    console.log('consul find service failed, exiting...')
    return
  }
  console.log('consul raw:', JSON.stringify(res.data))
  const pods = JSON.parse(res.data).map(v => [v.Service.Address, v.Service.Port])
  console.log('consul services:', JSON.stringify(pods))
  // 循环杀进程
  for (let i = 0; i < pods.length; i++) {
    const pod = pods[i]
    console.log('-------------')
    console.log(pod[0], pod[1])
    // 重启一个pod
    const killRes = await request(pod[0], pod[1], `/api/kill`)
    if (killRes && killRes.status === 200) {
      // kill返回成功,这个节点原本是活着的才继续监测它的状态
      console.log(pod[0], 'killed.')
      if (i === pods.length - 1) {
        // 已经杀完了最后一个pod,无需继续等待重启,直接退出
        process.exit(0)
      }
      let isServerUp = false
      // let isFrontendUp = false
      // 每个pod最多检测12次(2分钟),超过时间则放弃,直接重启下一个pod
      let tryTimes = 12
      // 循环检测是否重启成功
      do {
        console.log(pod[0], 'waiting for pod up...', tryTimes)
        // 每10秒检测一次
        await sleep(10 * 1000)
        console.log(pod[0], 'check if pod up...')
        // 如果返回200表示已服务已重新启动
        const serverRes = await request(pod[0], pod[1], `/api/health-check`)
        isServerUp = !!(serverRes && serverRes.status === 200)
        console.log(pod[0], 'pod server up:', isServerUp)
        // 等待 k8s readinessProbe 开始,节点被认为已存活,则可以对外访问
        if (isServerUp) {
          const waitSecondsStr = process.env.WAIT_FOR_PROBE_SECONDS || '10'
          const waitSeconds = parseInt(waitSecondsStr)
          if (isNaN(waitSeconds) || waitSeconds >= 120 || waitSeconds < 0) {
            // 最大等待120秒,超过视为参数错误
            console.log(pod[0], `WAIT_FOR_PROBE_SECONDS is invalid, skip waiting for prob.`)
          } else {
            console.log(pod[0], `pod is up internally, but need to wait for live probe (${waitSeconds}s)...`)
            await sleep(waitSeconds * 1000)
          }
        }
      } while (!isServerUp && --tryTimes > 0)
    } else {
      console.log(pod[0], 'kill failed, skip.')
    }
  }
})()

在 gitlab-ci 的最后一步执行此脚本:

.gitlab-ci.yml:

# ...
deploy-dev:
  stage: deploy
  only:
    - release-dev
  script:
    - ./build.sh
    - NODE_ENV=development SERVICE_NAME=some-name npm_config_registry=http://private.registry.com npx restart-project-via-consul-script@latest
  tags:
    - node
# ...

# 关于“解注册所有同名服务”与“注册自己”

项目内部使用 https://www.npmjs.com/package/consul (opens new window) 来与 Consul 通信。

需要“解注册所有同名服务”的原因:

Consul 在注册服务时并没有类似“主键”的概念,一个 Consul 有多个 Agent,也就是说相同 ip、相同 id 的服务可能会在不同 Agent 上被注册多次,并且由于 pod 的 ip 是短暂的,每次重启 pod 获得的 ip 可能会有差异,因此如果不进行解注册就会导致从 Consul 上获得的服务与现实正在运行的不一致。

流程:

# 总结

优点:

  1. CI 过程全自动
  2. 非线上环境 CD 全自动,线上环境CD手动指定版本,兼顾方便与安全
  3. 无需配置远程机器权限
  4. 支持多节点
  5. 高可用性的重启

缺点:

  1. k8s 使用原始镜像启动 pod,拉取代码与安装依赖的过程非常耗时(每次启动都是全新镜像,无缓存)。
  2. CD 过程不确定性较多,存在代码文件服务器故障、依赖安装故障等风险。

为什么会做成这个样子呢,因为我司的服务端使用 golang 是走的这一套流程,但是使用 golang 打包编译出的是一个二进制文件,镜像直接拉取就可以启动,不需要依赖安装等步骤。因此他们使用这种方式的缺点并不明显。但是对于 Nodejs 程序来说,这依然是一个较大缺陷。

# Gitlab 与 k8s 结合的全自动版本 v3

为了解决上面的问题 1/2,这个版本更改了代码部署到 k8s 的方式:使用完整的预构建镜像,而不是空白镜像。

# 流程图

# 技术细节

build.sh:

echo "Process: 构建 Docker 镜像..."
docker build -t wohx-${PROJECT_NAME}:${COMMIT_SHA} .
docker tag wohx-${PROJECT_NAME}:${COMMIT_SHA} private.registry.com/${PROJECT_NAME}:${COMMIT_SHA}
docker tag wohx-${PROJECT_NAME}:${COMMIT_SHA} private.registry.com/${PROJECT_NAME}:${BRANCH}-latest

echo "Process: 上传 Docker 镜像..."
docker push private.registry.com/${PROJECT_NAME}:${COMMIT_SHA}
docker push private.registry.com/${PROJECT_NAME}:${BRANCH}-latest

Dockerfile:

FROM alpine
RUN apk add --no-cache --update nodejs nodejs-npm yarn
RUN adduser -u 1000 -D app -h /data
USER app
WORKDIR /data
EXPOSE 8000
# server 确保在 yarn.lock 与 package.json 没有改变的情况下,此层能被缓存
# frontend 的 node_modules 已在 .dockerignore 中忽略,无需关注
RUN mkdir ./server && mkdir -p ./frontend/dist
COPY --chown=app server/yarn.lock server/package.json ./server/
RUN yarn --ignore-engines --cwd /data/server/
# 启动脚本
COPY --chown=app ./start.sh ./
# server 源代码层
COPY --chown=app ./server ./server/
# frontend dist 层
COPY --chown=app ./frontend/dist ./frontend/dist
# entry
# CMD [ "node", "./server/server.js" ]
# start.sh 允许 k8s 自定义启动逻辑
ENTRYPOINT [ "sh", "/data/start.sh" ]

# 总结

优点:

  1. CI过程全自动
  2. 非线上环境CD全自动,线上环境CD手动指定版本,兼顾方便与安全
  3. 无需配置远程机器权限
  4. 支持多节点
  5. 高可用性的重启
  6. 预构建的镜像,CD 阶段开箱即用,无任何依赖

缺点:

  1. 为了使每个 pod 能够获得一个稳定的名称(用于在 Consul 中注册、解注册),部署类型使用了 Statefulset,因此带来了一些本来不需要的特性。

Last Updated: 19 days ago