前情提要

我好几年前,将自己的 eggjs 项目(https://github.com/Jeff-Tian/alpha),部署在了 Heroku 上,运行得非常好。部署过程也非常丝滑,只需要添加一个 Procfile,内部写上:web: egg-scripts start就可以了。但是,Heroku 不再免费了(《Free Arch: Bye-bye to Heroku - Jeff Tian的文章 - 知乎 》),做为免费架构的拥趸,我必须找一个替代方案。

在线演示

原来的 heroku 站点是: https://uniheart.pa-ca.me/,这个站点本来可以一直访问。后来知名度越来越高,导致访问额度用得比较快,导致月末会打不开,因为提前把额度用完了。最近我发现月中就打不开了……

替代方案是: https://alpha-jeff-tian.cloud.okteto.net/,为了这个实现这个替代方案,需要对原有项目做一些改造,这正是本文接下来要详细讨论的。

为什么这次不是 Serverless?

之前有过将 NestJs 服务部署到 AWS Lambda 的经历:《一顿操作猛如虎,部署一个万能 BFF - Jeff Tian的文章 - 知乎 》;又有将 koa 服务部署到 Vercel Function 中的经历:《Free Arch:将 Koa 服务部署到 Vercel - Jeff Tian的文章 - 知乎 》。所以这一次如果要将 eggjs 部署成 Serverless,那是很连贯的。不料,网上已经有教程了,并且正好是将 eggjs 部署到阿里函数计算,有兴趣可以参考:《https://www.alibabacloud.com/help/en/function-compute/latest/deploy-an-egg-js-application-to-function-compute》。将这三篇连起来看,不仅尝试了 nodejs 中不同的 web 框架,还体验了不同的云厂商提供的 Serverless 服务,可谓爽哉!

起步

这是一个典型的 eggjs 项目(https://github.com/Jeff-Tian/alpha),当你看到这篇文章时,它已经被容器化了。但是前不久,它还是一个很久没有更新了的代码库,可以说这一次又是一个复活老项目的例子。

在容器化前,只要使用 nodejs 15.4.0,有 docker desktop 软件,下载到本地后,可以直接 yarn dev运行起来。当然现在仍然也是如此,总之,起步条件是该项目依赖 nodejs 15.4.0,依赖 mysql 数据库、以及 redis。

容器化

这一次没有采用 Serverless,而是准备将 eggjs 项目容器化,再部署到 k8s 集群中。

Dockerfile

容器化的第一步,就是写一个 Dockerfile 出来。参考了 eggjs 官方的 docker,做了一些调整。因为我的 eggjs 项目,使用了 TypeScript 语言,所以要多一个构建过程。 dockerfile FROM node:15.4.0-alpine

ENV TIME_ZONE=Asia/Shanghai

RUN mkdir -p /usr/src/app && apk add --no-cache tzdata && echo ${TIME_ZONE} > /etc/timezone && ln -sf /usr/share/zoneinfo/${TIME_ZONE} /etc/localtime

WORKDIR /usr/src/app

RUN npm i --registry=https://registry.npm.taobao.org

COPY . /usr/src/app

RUN yarn && yarn build

EXPOSE 7001

CMD yarn eggstart

注意,最后一行还使用了 yarn eggstart命令,这也是新加的,原本的项目中没有这个命令。因为 eggjs 默认是集群方式以守护进程模式运行,但是我们的目标是部署到 k8s 集群中,不需要集群模式,也不需要守护进程,并且希望在 pod 中保持一个实例就好,于是新加了 yarn eggstart专用在 k8s 集群中,它本质上是以下命令的快捷方式,定义在 package.json 文件中:

json eggstart: NODE_ENV=k8s EGG_SERVER_ENV=k8s eggctl start --workers=1 --no-daemon,

小提示

在参考官方 dockerfile 时,发现官方的 Dockerfile 也有一些不太合理的地方,顺便提了个 PR 以改进: https://github.com/eggjs/docker/pull/3。看到一些有改进空间的地方,顺手改一下,不仅方便他人;如果得到采纳,还能混个 Contributor 啥的。eggjs 算是 nodejs 生态中比较火的框架了,我就是一边使用一边在碰到问题时提出改进的 PR,就混了个 eggjs 的贡献者身份:
image.png

混到一些知名开源项目的贡献者,是有实际好处的,比如,可以得到 Copilot 的免费使用权:
image.png

如果没有免费特权,也极为推荐付费使用,实现编程自由(《Copilot 与 ChatGPT,让程序员如虎添翼 —— 让 AI 们为我们打工! - Jeff Tian的文章 - 知乎 》)。

构建脚本

有了 Dockerfile,就需要在每次的 CICD 过程中,构建它,测试它,并上传。以便最终在 k8s 集群中拉取上传的镜像,为此,可以写一个脚本文件: shell docker build -t jefftian/alpha:$1 . docker images docker run --network host -e CI=true -d -p 127.0.0.1:7001:7001 --name alpha:$1 jefftian/alpha docker ps | grep -q alpha docker ps -aqf name=alpha$ docker push jefftian/alpha:$1 docker logs $(docker ps -aqf name=alpha$) curl localhost:7001 || docker logs $(docker ps -aqf name=alpha$) docker kill alpha || echo alpha killed docker rm alpha || echo alpha removed

不妨给该脚本文件起个名字叫 dockerize.sh。注意,它接受一个参数,是为了给镜像打 tag 用。它不仅可以在 CICD 过程中跑,如果需要在本地测试一下,也是可以的: shell sh ./dockerize.sh test-tag

SOPS

安装 SOPS,通过 SOPS 加密它后再保存在代码库中。本地可以通过 brew install sops之类的方式来安装它,但是我样在 CICD 过程中,也需要用 SOPS 来解密,所以还需要在 CICD 流水线中安装它,命令如下,后面会用到: yaml

在安装了 SOPS 后,要在项目中启用,还需要在项目根目录创建一个 .sops.yaml 文件,来定义规则,比如对哪个文件进行保护等等:

yaml creation_rules:

If assuming roles for another account use arn+role_arn.

See Advanced usage

  • path_regex: k8s/secrets.yaml$ kms: arn:aws:kms:us-east-1:443862765029:key/b1739688-ec15-407d-895d-d05ca1217a2f aws_profile: lambda-doc-rotary

以上配置定义了对 k8s/secrets.yaml 文件进行加密保护,并指定了采用 AWS KMS 的名为 lambda-doc-rotary 的秘钥进行加解密。为了使用该秘钥,还需要记下对应的 aws access_key 和 secret_key,并保存在 ~/.aws/config文件中: yaml [lambda-doc-rotary] aws_access_key_id = xxx aws_secret_access_key = yyy

为了在 CICD 过程中成功连接 AWS KMS,可以使用命令行动态生成上述文件,比如:

yaml

  • run: mkdir ${HOME}/.aws
  • run: echo -e [lambda-doc-rotary]naws_access_key_id = ${{secrets.AWS_ACCESS_KEY}}naws_secret_access_key = ${{secrets.AWS_SECRET_KEY}}n > ~/.aws/config

配置好了 AWS KMS,加密文件只需要:

shell sops -e -i k8s/secrets.yaml --aws-profile lambda-doc-rotary

解密文件只需要:

shell sops -d -i k8s/secrets.yaml --aws-profile lambda-doc-rotary

配置 GitHub Actions Secrets

我们准备使用 GitHub Actions 来做 CICD。在 CICD 过程中需要连接一些使用密码的服务,这些密码,我们保存在 GitHub Actions 的 Secrets 里。对于我们要做的,将 eggjs 部署到 k8s 中,需要使用到如下的秘密值:

image.png

其中 AWS_ACCESS_KEY 和 AWS_SECRET_KEY 是给 sops 加解密用的,而 DOCKER_USERNAME 和 DOCKER_PASSWORD 是用来推镜像使用。GH_TOKEN 后面会再次介绍,这是为了拉取我的私人仓库用的,这个仓库里保存了 k8s 集群的信息。

配置 CICD 流水线

容器化完成后,就可以在 CICD 流水线中使用它了。先看一下最终效果:
image.png

可以看到这个流水线配置了 3 个步骤,第一步是验证项目的功能正常,其中包括了测试和构建项目;如果这一步通过,那么就进行容器镜像的构建。容器构建完毕,会上传到 Docker Hub:

image.png

最后,该镜像会在部署到 k8s 集群时被拉取。

准备 k8s 声明文件

创建一个 k8s 文件夹,用来存放所有 k8s 声明文件。

准备秘密文件

该项目依赖 MySQL 数据库和 REDIS,其连接信息要通过环境变量传入运行时,所以我们创建一个 secrets.yaml 文件,放在项目中新建的 k8s 目录下: yaml apiVersion: v1 kind: Secret metadata: name: alpha-secrets labels: branch: main type: Opaque stringData: MYSQL_HOST: alpha.xxxx.rds.cn-northwest-1.amazonaws.com.cn MYSQL_PORT: 3306 MYSQL_USERNAME: admin MYSQL_PASSWORD: yyyy MYSQL_DATABASE: alpha REDIS_URI: redis://username:password@host:port

这样的秘密文件,显然不能明文存储在代码库中,于是我们需要加密它。这就要用到前面提到的 sops,后面还会再次提到。

准备 kustomization.yaml

yaml apiVersion: kustomize.config.k8s.io/v1beta1 kind: Kustomization

bases: [] resources:

  • deployment.yaml
  • service.yaml

准备 service.yaml

yaml apiVersion: v1 kind: Service metadata: name: alpha annotations: dev.okteto.com/auto-ingress: true spec: type: ClusterIP ports: - name: tcp port: 7001 protocol: TCP targetPort: 7001 selector: app: alpha tier: backend

准备 deployment.yaml

shell apiVersion: apps/v1 kind: Deployment metadata: labels: app: alpha tier: backend deployedBy: deploy-node-app name: alpha spec: minReadySeconds: 5 progressDeadlineSeconds: 600 replicas: 2 revisionHistoryLimit: 10 selector: matchLabels: app: alpha tier: backend strategy: rollingUpdate: maxSurge: 1 maxUnavailable: 0 type: RollingUpdate template: metadata: labels: app: alpha tier: backend deployedBy: deploy-node-app spec: containers: - image: jefftian/alpha imagePullPolicy: Always name: alpha ports: - containerPort: 7001 name: http protocol: TCP resources: limits: cpu: 500m memory: 512Mi requests: cpu: 250m memory: 256Mi envFrom: - secretRef: name: alpha-secrets restartPolicy: Always terminationGracePeriodSeconds: 30

流水线第一步:验证项目

这一步是先安装依赖,再跑测试,最后验证构建。即 yarn install、yarn test、yarn build。

流水线第二步:构建容器镜像

本质上是调用前面的容器化脚本,只不过,这里使用了 github.sha 做为参数传递给这个脚本。

yaml build-docker-image: runs-on: ubuntu-latest needs: build steps: - uses: actions/checkout@v3 - run: echo ${{secrets.DOCKER_PASSWORD}} | docker login -u ${{secrets.DOCKER_USERNAME}} --password-stdin - run: git_hash=$(git rev-parse ${{ github.sha }}) - run: sh .github/dockerize.sh ${{ github.sha }}

流水线第三步:部署到 k8s 集群

按《Free Arch: 使用 OAM 摆脱厂商锁定 - Jeff Tian的文章 - 知乎 》提到的,可以同时部署到多个 k8s 集群。步骤是一样的,只是连接信息不同。这里再次以 Okteto 为例。

本质上这里先使用 SOPS 解密秘密文件,并应用到 k8s secrets;然后,应用 k8s kustomization;最后,如果只是更新镜像,可以使用 kubectl set image deployment alpha alpha=jefftian/alpha:新Tag。

yaml deploy-okteto: runs-on: ubuntu-latest needs: build-docker-image steps: - uses: actions/checkout@v3

- run: mkdir ${HOME}/.aws
- run: echo -e [lambda-doc-rotary]naws_access_key_id = ${{secrets.AWS_ACCESS_KEY}}naws_secret_access_key = ${{secrets.AWS_SECRET_KEY}}n > ~/.aws/config

- run: wget https://github.com/mozilla/sops/releases/download/v3.7.3/sops-v3.7.3.linux.amd64
- run: sudo cp sops-v3.7.3.linux.amd64 /usr/local/bin/sops
- run: sudo chmod +x /usr/local/bin/sops

- run: curl -LO https://storage.googleapis.com/kubernetes-release/release/$(curl -s https://storage.googleapis.com/kubernetes-release/release/stable.txt)/bin/linux/amd64/kubectl
- run: chmod +x ./kubectl
- run: sudo mv ./kubectl /usr/local/bin/kubectl
- run: mkdir ${HOME}/.kube
- run: npm i -g k8ss
- run: echo -e machine github.comn  login ${{secrets.GH_TOKEN}} > ~/.netrc
- run: git clone https://github.com/Jeff-Tian/k8s-config.git ${HOME}/k8s-config
- run: k8ss switch --cluster=okteto --namespace=jeff-tian
- run: sops -d k8s/secrets.yaml --aws-profile lambda-doc-rotary | kubectl apply -f -
- run: kubectl apply -k k8s
- run: kubectl set image deployment alpha alpha=jefftian/alpha:${{ github.sha }}

完整的 CICD 流水线代码详见: https://github.com/Jeff-Tian/alpha/blob/master/.github/workflows/nodejs.yml

image.png