使用 k8s 搭建一个小巧但健全的 cicd 系统

引言

本文将以作者的个人小项目“易导游”(e-tour) 为例,介绍作者目前正在使用的 cicd 方案。实现了自动更新部署、自动资源调度、日志系统、告警系统等功能。

总览

image-20240423163826493

架构图如上,我们简单介绍一下图中的关键组件:

  1. git 仓库:使用 github 私有仓库,利用 github action 实现 push 后自动构建镜像并更新部署的功能
  2. 前端:以静态网页的形式托管在阿里云 oss 上。以节省服务器带宽和流量资源。
  3. 镜像仓库:在 infra 服务器自建私有镜像仓库。
  4. k8s:主要目的是充分利用多台低配服务器的内存资源。
  5. 后端:以容器的形式运行在 k8s 集群中。
  6. 中间件:由于 k8s 并不适合运行有状态应用,所以像数据库、loki 这种中间件我放在一台内存足够大的安装了 docker 的服务器上(下文称之为 infra服务器)
  7. 监控告警: 优秀国产开源项目 hertzbeat,当检测到服务下线后通过飞书机器人告警。
  8. 日志:loki + promtail + grafana 可以查看 k8s 和 infra 中所有容器的标准输出日志。

要实现这样的一个 cicd 系统,你至少需要 1 台 4g 内存的服务器运行 k8s,1 台 4g 内存的服务器运行基础设施。为了便于安装各种软件,强烈建议购买非大陆服务器,我现在主要使用野草云,香港服务器网络畅通、价格实惠、性能够用、稳定性好。

接下来我将依次介绍各个组件

git 仓库

github 私有仓库完全能够满足开发者代码托管的需求,而且可以利用 github action 实现丰富的功能,例如在某个分支收到 push 时触发镜像的构建、推送、发送 webhook 通知等。本文要用的 github action 将会在下文介绍。

缺点是需要科学上网和配置 git 代理,不过瑕不掩瑜。

前端

为了减轻后端服务器的带宽和流量压力,我们选择把前端托管在云服务商那里。

cloudflare pages

如果你有 visa 卡,那你就可以注册 cloudflare 账户并免费使用网站托管、对象存储等服务。

其中,pages 可以连接你的 git 仓库并在收到推送后自动构建并更新部署网站,非常简单省心。详见:https://pages.cloudflare.com/

阿里云 oss 静态网页

如果你没有 visa 卡,并且很在意国内访问的速度和稳定性,建议使用这种方案。

这里以一个 vue 工程为例

需要新建一个阿里云 oss 的 bucket ,权限选择公共读私有写

我们主要利用"静态页面"和"域名管理"这两个功能

  1. 静态页面是让oss把这个bucket当成一个网站去对待,而不是单纯地提供对象存储服务。你需要指定默认首页为 index.html
  2. 域名管理是允许我们使用自定义的域名去访问这个网站, 并使用https服务(需要备案域名并导入ssl 证书)

image-20230123194902677

image-20230123195000964

你可以上传一个简单的 index.html 文件到 bucket 中,然后访问你刚才提供的域名,如果能正常显示出网站内容就 ok 了。

配置 github action 构建并推送编译产物

一般我们都会使用 vue、react 等高级框架进行前端开发,我们需要将源代码编译成静态页面(html,css,js)然后推送到阿里云 oss。我们通过以下文件放到项目代码目录中来配置 github action,实现 main 分支每次收到 push 时都构建并推送编译产物到 oss。

.github/workflows/deploy.yml

name: deploy

on:
  push:
    branches:
      - main

env:
  BUCKET: 阿里云 oss bucket名,如 e-tour-fe-prod
  ENDPOINT: 阿里云 oss endpoint,如 https://oss-cn-hangzhou.aliyuncs.com

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - name: checkout
        uses: actions/checkout@v4

      - name: install node
        uses: actions/setup-node@v4
        with:
          node-version: "20"

      - name: build dist
        run: |
          npm install
          npm run build(你的构建命令,详见 package.json)
      - name: upload files to OSS
        uses: fangbinwei/aliyun-oss-website-action@v1
        with:
          accessKeyId: ${{ secrets.ACCESS_KEY_ID }}
          accessKeySecret: ${{ secrets.ACCESS_KEY_SECRET }}
          bucket: ${{ env.BUCKET }}
          endpoint: ${{ env.ENDPOINT }}
          folder: 构建产物所在的目录,例如 ./dist

文件中有两个我们没见过的变量:secrets.ACCESS_KEY_ID 和 secrets.ACCESS_KEY_SECRET,这是阿里云访问凭证,你可以理解为账号密码,由于是敏感信息我们不推荐直接写在文件里,我们可以通过 github action secrets 来添加这两个变量,如下图所示:

img

推送你的代码,看一下有没有触发 action,action 完成后你应该可以通过你在 oss 里设定的自定义域名访问到你的网站。

镜像仓库

个人项目的镜像肯定不能往 dockerhub 这种公开仓库上放,这时候我们就需要一个私有的镜像仓库。

阿里云镜像仓库

https://www.aliyun.com/product/acr

个人免费版本完全够用,海外访问可能很慢。

azure 镜像仓库

我的服务器在香港,有一段时间访问阿里云镜像仓库很慢,于是就把目光转向了海外云服务商。

编写此文章时 azure 还在促销,新用户可以免费享受 12 个月的各种云服务(包括 mysql、镜像仓库、虚拟机等)详见:https://azure.microsoft.com/zh-cn/products/container-registry

自建镜像仓库

如果买不到便宜的镜像仓库服务,也可以自己建,但是要考虑一下带宽、流量、存储的消耗。

官方文档:https://docs.docker.com/registry/

我们使用 nginx proxy manager (下文简称 npm)来实现镜像仓库的 https 和鉴权(其实就是 basic auth)

npm 的使用可以参考我的另一篇文章:轻松配置 https:Let‘s Encrypt 介绍及 Nginx Proxy Manager 实用操作教程

我们可以为镜像仓库安装一个 ui 来便捷地查看镜像,详见:https://github.com/Joxit/docker-registry-ui

k8s

安装

我选择使用轻量级的 k8s:k3s

推荐一个 k8s 可视化操作工具:lens,可以免费使用。相比于一长串的 kubectl 命令,要简单很多。

默认内网互通的机器才能加入同一个集群,而我们的服务器一般都是嫖的各个厂商的优惠机,为了实现跨云集群,需要对控制平面和数据平面的安装命令进行一些调整,详见:https://docs.k3s.io/zh/installation/network-options#%E5%B5%8C%E5%85%A5%E5%BC%8F-k3s-%E5%A4%9A%E4%BA%91%E8%A7%A3%E5%86%B3%E6%96%B9%E6%A1%88

如果安装控制平面后你发现你的 coredns pod 一直启动失败,可以尝试修改一下安装命令,例如:curl -sfL https://get.k3s.io | sh -s - --resolv-conf=/run/systemd/resolve/resolv.conf,来指定 dns 服务器,避免本地无限循环,详见:https://github.com/coredns/coredns/issues/2087

当控制平面和数据平面都安装好了以后,可以使用以下 crd 文件来部署一个 whoami 服务,这个简单的 http 服务的 “/” 接口可以返回容器自身的一些信息,我们可以用来测试 pod 是否能够正常在集群中调度、访问。

apiVersion: apps/v1
kind: Deployment
metadata:
  name: whoami-deployment
spec:
  replicas: 5
  selector:
    matchLabels:
      app: whoami
  template:
    metadata:
      labels:
        app: whoami
    spec:
      containers:
        - name: whoami
          image: traefik/whoami
          ports:
            - containerPort: 80
---
apiVersion: v1
kind: Service
metadata:
  name: whoami-service
spec:
  selector:
    app: whoami
  ports:
    - protocol: TCP
      port: 80
      targetPort: 80

配置私有镜像仓库

由于我们的后端镜像要存放在私有镜像仓库中,k8s 必须要知道镜像仓库的账号密码才能拉取镜像,配置方式详见:https://docs.k3s.io/zh/installation/private-registry

反向代理

要在 k8s 中实现反向代理,我们不需要像 nginx proxy manager 那样对流量的走向掌握的那么清晰,k8s 声明式编程的设计理念使得我们可以着眼于目的而非具体手段。

我们只需要做两件事:

  1. 证书:告诉 k8s let’s encrpt 需要的一些具体参数
  2. 路由:告诉 k8s 哪个域名要路由到哪个 service

具体操作如下:

证书

安装 helm 和 cert manager

如果在使用 helm 安装cert manager 的时候报错:Error: INSTALLATION FAILED: Kubernetes cluster unreachable: Get "http://localhost:8080/version": dial tcp 127.0.0.1:8080: connect: connection refused

是因为helm默认使用k8s的配置文件,默认位置为 ~/.kube/config.yml, 因为是k3s所以配置文件要使用环境变量来指定。执行export KUBECONFIG=/etc/rancher/k3s/k3s.yaml即可。详见:https://docs.k3s.io/zh/cluster-access?_highlight=export&_highlight=kubeconfig%3D%2Fetc%2Francher%2Fk3s%2Fk3s.yaml

然后配置一个 ClusterIssuer 资源对象用于跨命名空间地签发证书

apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
  name: letsencrypt-prod
spec:
  acme:
    # The ACME server URL
    server: https://acme-v02.api.letsencrypt.org/directory
    # Email address used for ACME registration
    email: 你的邮箱
    # Name of a secret used to store the ACME account private key
    privateKeySecretRef:
      name: letsencrypt-prod
    solvers:
      - http01:
          ingress:
            class: traefik

如果你想了解一下 let’s encrypt 的话,可以看一下我的另一篇文章:轻松配置 https:Let‘s Encrypt 介绍及 Nginx Proxy Manager 实用操作教程

路由

创建一个 ingress 资源对象,用于管理域名和服务之间的路由关系。下文用我的域名举例。

apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: traefik-ingress
  annotations:
    cert-manager.io/cluster-issuer: "letsencrypt-prod"

spec:
  tls:
    - hosts:
      - whoami.be.baizhukui.com
      secretName: quickstart-example-tls
  rules:
    - host: whoami.be.baizhukui.com
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: whoami-service
                port:
                  number: 80

几分钟后证书就绪,你就可以通过你指定的域名从外界访问 whoami 服务了。

触发 pod 更新

我并没有找到适用于 k8s 的像 docker watchtower 那样监听镜像更新并自动更新容器的软件。keel 能做类似的事情,但是更新需要手动通过 webhook 触发。

为了获得 basic auth等功能,我们使用完整的 crd 安装:

---
apiVersion: v1
kind: Namespace
metadata:
  name: keel
---
apiVersion: v1
kind: ServiceAccount
metadata:
  name: keel
  namespace: keel
  labels:
    app: keel
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
  name: keel
rules:
  - apiGroups:
      - ""
    resources:
      - namespaces
    verbs:
      - watch
      - list
  - apiGroups:
      - ""
    resources:
      - secrets
    verbs:
      - get
      - watch
      - list
  - apiGroups:
      - ""
      - extensions
      - apps
      - batch
    resources:
      - pods
      - replicasets
      - replicationcontrollers
      - statefulsets
      - deployments
      - daemonsets
      - jobs
      - cronjobs
    verbs:
      - get
      - delete # required to delete pods during force upgrade of the same tag
      - watch
      - list
      - update
  - apiGroups:
      - ""
    resources:
      - configmaps
      - pods/portforward
    verbs:
      - get
      - create
      - update
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
  name: keel
roleRef:
  apiGroup: rbac.authorization.k8s.io
  kind: ClusterRole
  name: keel
subjects:
  - kind: ServiceAccount
    name: keel
    namespace: keel
---
apiVersion: v1
kind: Service
metadata:
  name: keel
  namespace: keel
  labels:
    app: keel
spec:
  type: LoadBalancer
  ports:
    - port: 9300
      targetPort: 9300
      protocol: TCP
      name: keel
  selector:
    app: keel
  sessionAffinity: None
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: keel
  namespace: keel
  labels:
    app: keel
spec:
  replicas: 1
  selector:
    matchLabels:
      app: keel
  template:
    metadata:
      labels:
        app: keel
    spec:
      serviceAccountName: keel
      containers:
        - name: keel
          # Note that we use appVersion to get images tag.
          image: "keelhq/keel:latest"
          imagePullPolicy: Always
          command: ["/bin/keel"]
          env:
            - name: NAMESPACE
              valueFrom:
                fieldRef:
                  fieldPath: metadata.namespace
            # Basic auth (to enable UI/API)
            - name: BASIC_AUTH_USER
              value: 用户名
            - name: BASIC_AUTH_PASSWORD
              value: 密码
            - name: AUTHENTICATED_WEBHOOKS
              value: "true"
          ports:
            - containerPort: 9300
          livenessProbe:
            httpGet:
              path: /healthz
              port: 9300
            initialDelaySeconds: 30
            timeoutSeconds: 10
          resources:
            limits:
              cpu: 100m
              memory: 128Mi
            requests:
              cpu: 50m
              memory: 64Mi
---
# Source: keel/templates/pod-disruption-budget.yaml

apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
  name: keel
  namespace: keel
spec:
  maxUnavailable: 1
  selector:
    matchLabels:
      app: keel
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: traefik-ingress
  namespace: keel
  annotations:
    cert-manager.io/cluster-issuer: "letsencrypt-prod"

spec:
  tls:
    - hosts:
      - keel.be.baizhukui.com
      secretName: tls-secret-keel
  rules:
    - host: keel.be.baizhukui.com
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: keel
                port:
                  number: 9300

你可以在上述的配置文件中填写 basic auth 的用户名和密码,保护 keel api。

我们把 keel 单独安装在一个命名空间中,因为这个命名空间没有 ingress,所以我们声明了一个新的ingress。

issuer 就使用我们之前创建的可以跨命名空间的 ClusterIssuer。

但是 secret 的名字要使用新的,不能重复(详见:https://cert-manager.io/docs/usage/ingress/#generate-multiple-certificates-with-multiple-ingresses)

需要被 keel 更新的服务,crd 文件要比刚才 whoami 的要多 2 行配置,分别是keel.sh/policy: forceimagePullPolicy: Always,例如我的 e-tour:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: e-tour-be-prod-deployment
  annotations:
    keel.sh/policy: force
spec:
  replicas: 1
  selector:
    matchLabels:
      app: e-tour-be-prod
  template:
    metadata:
      labels:
        app: e-tour-be-prod
    spec:
      containers:
        - name: e-tour-be-prod
          image: registry.infra.baizhukui.com/baizhukui/e-tour-be-prod:latest
          imagePullPolicy: Always
          ports:
            - containerPort: 8080
          # 使用北京时区
          env:
            - name: TZ
              value: "Asia/Shanghai"
---
apiVersion: v1
kind: Service
metadata:
  name: e-tour-be-prod-service
spec:
  selector:
    app: e-tour-be-prod
  ports:
    - protocol: TCP
      port: 8080
      targetPort: 8080

keel 提供了多种触发更新的方法,这里我们使用 webhook 触发方式,你可以通过向 keel 发送一个 post 请求,指定要更新的 pod 的镜像和tag(可以恒为 latest),然后触发 pod 更新。这件事会在后端的 github action 中通过 curl 实现(详见下文)。

后端

首先要根据你语言、框架的类型编写对应的 Dockerfile。

然后编写 github action 实现每次 main 分支收到 push 后都自动构建镜像、推送镜像、更新pod。简单起见,tag 总是使用“latest”

.github/workflows/deploy.yml

name: deploy

on:
  push:
    branches:
      - main

env:
  REGISTRY: 你的镜像仓库地址,例如 registry.infra.baizhukui.com
  NAMESPACE: 命名空间,例如 baizhukui
  IMAGE: 镜像名,例如 e-tour-be-prod

jobs:
  docker:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Docker meta
        id: meta
        uses: docker/metadata-action@v5
        with:
          images: |
            "${{ env.REGISTRY }}/${{ env.NAMESPACE }}/${{ env.IMAGE }}"
          tags: |
            type=schedule
            type=ref,event=branch
            type=ref,event=pr
            type=semver,pattern={{version}}
            type=semver,pattern={{major}}.{{minor}}
            type=semver,pattern={{major}}
            type=sha
            type=raw,value=latest

      - name: Set up QEMU
        uses: docker/setup-qemu-action@v3

      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3

      # https://github.com/docker/login-action
      - name: Login to ACR
        uses: docker/login-action@v3
        with:
          registry: ${{ env.REGISTRY }}
          username: ${{ secrets.REGISTRY_USERNAME }}
          password: ${{ secrets.REGISTRY_PASSWORD }}

      - name: Build and push
        uses: docker/build-push-action@v5
        with:
          context: ./
          push: ${{ github.event_name != 'pull_request' }}
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}
          file: "./Dockerfile"

      - name: Notify keel to update pod
        run: |
          curl -X POST -H 'Content-Type: application/json' https://你的 keel地址/v1/webhooks/native -d '{"name":"镜像全名","tag":"latest"}' -u ${{ secrets.KEEL_USERNAME }}:${{ secrets.KEEL_PASSWORD }}

这个文件中用到了一些变量,我将逐个讲解。

  1. secrets.REGISTRY_USERNAME:镜像仓库的用户名,由于是敏感信息不适合写在代码里,我们放到 github secret 里,上文讲述前端 github action 时提到过。
  2. secrets.REGISTRY_PASSWORD:镜像仓库的密码,同理。
  3. secrets.KEEL_USERNAME:keel api 的 basic auth 用户名,同理
  4. secrets.KEEL_PASSWORD:keel api 的 basic auth 密码,同理

push 后,你应该能再 github 看到一个 action 正在运行,几分钟后运行完毕,你将可以在镜像仓库ui中看到出现了一个新镜像。

仿照前面的 deploy、service、ingress 的写法,你就可以把你的后端部署在 k8s 上了。

以后每次再 push,都会生成并推送新镜像,然后收到通知的 keel 会自动更新 pod。

中间件

由于阿里云的 MySQL 价格并不贵,而且可靠性要比自建的要好,所以 MySQL 我选择使用阿里云的 rds。

其他中间件如 redis、es、loki,要么云厂商卖的太贵,要么压根没有,这种建议买一台内存大一点的服务器自己用 docker 搭建,然后用 npm 配置反向代理。

至于为什么不放进 k8s 里,我认为这篇文章说的很有道理:数据库应该放入K8S里吗?

如果你在安装软件时,看到容器日志报错,对某个目录没有访问权限,可以在宿主机通过 chmod 777 数据卷目录 来解决。

监控告警

由于我对项目的各种性能指标并不是很关心,只想要一个服务下线告警能让我及时发现问题,我就使用了 hertzbeat ,它提供了开箱即用的告警通知,可以很方便地把告警消息通过飞书机器人发送到我手里。相比于 ”prometheus + alertmanager + 各种 webhook 适配软件"这一套简单多了。

日志

简单浏览线上日志的话,k8s 用 lens,docker 用 docker logs 就够了。

如果你想搞得高级一点的话,可以试试 loki + promtail + grafana 这一套,我在另一篇博客介绍了部署方法。

总结

本文介绍了作者目前在使用的 cicd 系统,逐个介绍了系统中各个组件的功能和搭建。

文章作者: 白烛魁
本文链接:
版权声明: 本站所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议。转载请注明来自 白烛魁的小站
运维 Docker k8s
喜欢就支持一下吧