背景

笔者最近在负责公司研发中台的建设,以提升公司整体研发流程的效率,其中很重要的一部分就是解决从代码到运行中服务的部署效率问题。由于公司项目众多,以前都是各个项目各自为政,每个项目都有自己的开发、测试环境,资源利用率极低,浪费严重。并且基本为传统手动部署方式,部署效率低且易出错。经过调研,笔者最终选择了 Kubernetes + GitLab 的组合。无论是开发、测试还是生产环境,各个项目都共享同一个 Kubernetes 集群,复用底层计算和存储资源。长期没有开发任务的项目可从开发和测试环境里下线,以节省资源,需要的时候可快速恢复上线。对于开发、测试环境,每个项目每天都要部署多次,如果项目采用微服务架构,那么部署次数还会随着微服务数量翻倍,势必需要一种自动化的部署方式。通过使用 GitLab CI/CD,能够做到开发人员提交代码后就自动触发 GitLab 流水线(Pipeline)运行,在流水线里可完成构建、测试、打包和部署等工作。

Kubernetes 微服务应用部署

微服务应用部署架构

先来看一下部署在 Kubernetes 集群里的微服务应用的整体架构:

kubernetes-microservice-application-deploy-architecture

为了简化 Kubernetes 集群的管理,我们使用了 Rancher,一个企业级的 Kubernetes 多集群管理系统。无论是云服务商提供的集群,还是自建集群,均可通过 Rancher 来统一管理。如果是自建集群,还可使用 Rancher 提供的工具来快速创建,后面会讲到其具体用法。

为了物理上隔离不同的环境,不同的环境需要使用独立的 Kubernetes 集群。各个集群在内部结构上比较类似,但会有一些细微差别,比如开发和测试环境 MySQL 是独立部署,而生产环境为了高可用会采用主从部署。各个应用公用的 MySQL、Redis、Elasticsearch 等依赖服务放在 default 命名空间里,每个应用都有其自己的命名空间,以防止命名冲突和限制权限。对外暴露的服务需要为其创建对应的 Ingress 对象,然后 Ingress Controller 会按照 Ingress 的要求在 Kubernetes 集群的所有 Worker 节点的某个端口上将服务暴露给外部。Worker 节点一般对外不可访问,并且存在多个,为了解决外部网络访问多个 Worker 节点上的暴露服务,还需要使用 HAProxy 这样的负载均衡器来作为流量入口并转发请求给某个 Worker 节点。

Kubernetes 集群管理

安装 Rancher

Rancher 支持多种方式,最简单的就是使用 Docker 在单个节点上运行 Rancher Server,适合集群节点数量不多的场景,比如开发、测试环境。如果集群节点数量几百上千,那么建议使用一个 Kubernetes 集群来安装 Rancher。这个集群可以使用 Rancher 提供的 K3SRKE 工具来创建,推荐使用 K3S。K3S 是轻量级的 Kubernetes,易于安装,内存消耗只有 Kubernetes 的一半,所有组件打包在一个不到 50MB 的二进制文件中,适合资源较少、功能需求简单的场景。RKE 是经 CNCF 认证过的 Kubernetes 发行版,它大大简化了 Kubernetes 集群安装的复杂性。这里我们选择了最简单的 Docker 单节点安装方式,具体安装步骤可参考官方文档 Installing Rancher

创建 Kubernetes 集群

Rancher 除了支持使用其 RKE 工具来创建 Kubernetes 集群,还支持 Google、Amazon、Azure、Alibaba 等云服务商,甚至还可以把现有的集群导入到 Rancher 里来管理。这里我们选择 RKE 方式,在自定义节点上创建 Kubernetes 集群。

在创建 Kubernetes 集群之前需要先准备好节点。Rancher 支持 Ubuntu、CentOS 等常见 Linux 发行版(64-bit x86,ARM64 还处于试验阶段),并且安装好 Docker,Windows 系统只能用于 Worker 节点,并且需要运行 Docker 企业版。各节点之间网络必须是连通的,如果启用了防火墙,需要开放相关端口。由于开放端口较多,建议将各节点加入同一个安全组,然后对该安全组内的节点开放所有端口。节点角色分为 etcdcontrolplaneworker,单个节点可具备一种或多种角色,最好只具备一种。其中 etcd 角色至少有三个节点,controlplane 和 worker 角色建议至少两个节点。

打开 Rancher UI,在 Global -> Cluster 页点击 Add Cluster 按钮来创建一个 Kubernetes 集群。首先需要填写集群名称、成员等信息,然后是添加节点。添加节点时需要先选择一到多种角色,然后拷贝页面显示的命令到节点服务器上执行,即可把该节点添加到集群里。创建完成后的集群显示如下:

--2020-04-24-15.51.04
--2020-04-24-15.51.24

安装 Longhorn 服务

应用服务一般是无状态的,它们的状态保存在后端存储服务,比如 MySQL、Elasticsearch 等。为了防止状态丢失,需要将服务状态持久化到磁盘上保存起来。传统部署方式下,计算和存储位于同一个节点,存储为本地磁盘。而在 Kubernetes 里,需要尽量避免使用本地磁盘,因为这样节点就变成有状态的了,导致 Pod 很难在不同节点之间进行调度。Kubernetes 通过 PV(Persistent Volume)和 StorageClass 来抽象 Pod 对存储资源的访问需求。PV 可以使用手动方式预先创建好,也可以通过 StorageClass 来按需动态创建,PV 可以来源于宿主机路径、本地磁盘或者各云服务商提供的块存储服务。为了解决非云服务商环境里的 Kubernetes 存储资源问题,Rancher 发起了一个开源项目 Longhorn,它是一个运行在 Kubernetes 集群内的提供分布式块存储的服务。

Longhorn 对 Kubernetes 版本和节点环境有一些要求,具体可查看 Installation Requirements。如果满足要求,可通过 Rancher UI、Kubectl 或 Helm 三种方式来在 Kubernetes 集群里安装 Longhorn,这里我们采用最简单的 Helm 方式。

git clone https://github.com/longhorn/longhorn && cd longorn
kubectl create namespace longhorn-system
helm install longhorn ./longhorn/chart/ --namespace longhorn-system

Longhorn 尚未发布 1.0 正式版本,因此没有提交到 Helm 官方仓库,需要克隆代码到本地后安装。我们将其安装在一个独立的命名空间 longhorn-system,如果需要修改默认配置,可在安装前先修改 Chart values 文件 longhorn/chart/values.yaml。常见配置有通过 StorageClass 动态创建的 PV 的默认副本数 persistence.defaultClassReplicaCount,通过 Longhorn UI 手动创建的 PV 的默认副本数 defaultSettings.defaultReplicaCount 等。

为了访问 Longhorn UI,还需要创建一个 Ingress 对象。如果使用 Nginx Ingress Controller,可参考如下配置:

apiVersion: networking.k8s.io/v1beta1
kind: Ingress
metadata:
  name: longhorn
  annotations:
    kubernetes.io/ingress.class: "nginx"
spec:
  rules:
  - host: longhorn.jaggerwang.net
    http:
      paths:
      - path: /
        backend:
          serviceName: longhorn-frontend
          servicePort: 80

打开 Longhorn UI 后的界面如下:

--2020-05-06-15.51.50

Longhorn 默认会添加所有 worker 节点的路径 /var/lib/longhorn/ 为磁盘,如果不想这样,可以在安装时设置 Chart values 文件里的 defaultSettings.createDefaultDiskLabeledNodestrue,以便只在带有标签 node.longhorn.io/create-default-disk=true 的节点上才创建默认磁盘。如果已安装,可在 Longhorn UI 的 Settings 里面修改设置。

如果磁盘空间不够用,可在 Longhorn UI 里给节点添加新的磁盘。

--2020-05-08-09.58.13

安装 Longhorn 之后,Kubernetes 集群里会多出一个名为 longhorn 的 StorageClass。需要 PV 的 Pod 可创建如下的 PVC 来申请:

apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: redis
spec:
  storageClassName: longhorn
  accessModes:
    - ReadWriteOnce
  resources:
    requests:
      storage: 5Gi

部署依赖服务

一个应用里除了实现业务逻辑的微服务,还有一些非业务的依赖服务,比如持久化存储、缓存、消息队列、搜索等。这些服务跟具体业务无关,可以在多个应用之间共享,但是要注意从帐号或命名空间上进行隔离。下面以 MySQL 服务为例:

apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: mysql
spec:
  storageClassName: local-storage
  accessModes:
    - ReadWriteOnce
  resources:
    requests:
      storage: 100Gi
  volumeName: mysql-1-data
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: mysql
spec:
  selector:
    matchLabels:
      app: mysql
  strategy:
    type: Recreate
  template:
    metadata:
      labels:
        app: mysql
    spec:
      containers:
      - image: mysql:8.0
        name: mysql
        env:
        - name: TIME_ZONE
          value: Asia/Shanghai
        - name: MYSQL_ROOT_PASSWORD
          valueFrom:
            secretKeyRef:
              name: password
              key: mysql-root
        ports:
        - containerPort: 3306
          name: mysql
        volumeMounts:
        - name: mysql
          mountPath: /var/lib/mysql
          subPath: data
      volumes:
      - name: mysql
        persistentVolumeClaim:
          claimName: mysql
---
apiVersion: v1
kind: Service
metadata:
  name: mysql
spec:
  ports:
  - port: 3306
  selector:
    app: mysql
  clusterIP: None

首先创建一个 PVC(PersistentVolumeClaim)对象 mysql,考虑到 MySQL 服务磁盘 IO 比较频繁,这里通过 volumeName 显示指定了要绑定的 PV。该 PV 类型为 local-storage,也就是本地存储,性能比 Longhorn 提供的网络磁盘性能要好。

然后创建一个 Deployment 对象来部署 MySQL 8.0 服务,采用的是单节点方式,root 密码从 Secret 对象 password 里获取,通过 PVC 获取到的 PV 的子目录 data 被挂载到 MySQL 容器里的路径 /var/lib/mysql。这样即便 Deployment 被重新创建,MySQL 服务的状态也能够从 PV 里恢复。

最后创建一个 Service 对象来为集群里的其它服务提供 MySQL 服务,其它服务可使用服务名 mysql 来访问它。这里把 clusterIP 指定为了 None,这是因为 MySQL 服务只有一个节点,不需要通过 clusterIP 来做负载均衡,通过服务名直接访问到对应的 Pod 即可。

部署和暴露微服务

应用微服务一般没有状态,不需要创建 PVC 对象和挂载 PV,因此部署比较简单,后面讲 GitLab 流水线的时候会举具体例子。

如果某个微服务需要对外暴露,需要为其创建 Ingress 对象,具体可参考前面 Longhorn 的 Ingress 对象。对于有使用网关的微服务应用,只需要暴露网关微服务,其它微服务可通过网关访问到,这样可避免创建过多 Ingress 对象。

通过 Ingress 暴露的微服务只能通过 Worker 节点访问,如果 Worker 节点对外不可见,那么还需要在负载均衡器上配置转发。以 HAProxy 为例:

frontend http
    bind *:80
    default_backend kubernetes

backend kubernetes
    balance roundrobin
    server dev-kubeworker-1 dev-kubeworker-1:80 check
    server dev-kubeworker-2 dev-kubeworker-2:80 check
    server dev-kubeworker-3 dev-kubeworker-3:80 check

本地开发环境访问 Kubernetes 开发集群服务

微服务架构虽然有很多好处,但同时也会造成一些麻烦,其中一个就是会增加开发人员搭建本地开发环境的难度。为了开发和调试一个微服务,需要同时启动一堆依赖的其它微服务和数据库、缓存等服务。为了简化本地开发环境搭建,可以让本地正在开发的微服务直接访问 Kubernetes 开发环境里的服务,这样本地只需启动一个服务。

前面我们已经使用 Ingress 来暴露 HTTP 服务,如果是 MySQL 这样的 TCP 服务,Nginx Ingress Controller 也仍然支持。只需要在 Nginx Ingress Controller 所在的命名空间 ingress-nginx 里创建如下的 ConfigMap 对象即可:

apiVersion: v1
kind: ConfigMap
metadata:
  name: tcp-services
data:
  3306: "default/mysql:3306"

这样就可以访问任意 Worker 节点的 3306 端口来访问 MySQL 服务。如果 Worker 节点对外不可见,那么可以在 HAProxy 负载均衡器上配置如下转发:

listen tcp
    mode tcp
    balance roundrobin
    bind *:3306
    server dev-kubeworker-1 dev-kubeworker-1 check
    server dev-kubeworker-2 dev-kubeworker-2 check
    server dev-kubeworker-3 dev-kubeworker-3 check

上面的方式需要为每一个服务去配置 Nginx Ingress Controller 和 HAProxy,只适合数量较少的公共服务。对于应用微服务,由于数量众多,这种方式就不合适了,这种时候可以利用 HAProxy 的端口范围转发来简化配置。

首先配置一个端口范围转发,将 Kubernetes 集群的 NodePort 端口范围转发给 Worker 节点。

listen tcp
    mode tcp
    balance roundrobin
    bind *:30000-32767
    server dev-kubeworker-1 dev-kubeworker-1 check
    server dev-kubeworker-2 dev-kubeworker-2 check
    server dev-kubeworker-3 dev-kubeworker-3 check

然后为要暴露的微服务创建一个 NodePort 类型的 Service 对象。

apiVersion: v1
kind: Service
metadata:
  name: user
spec:
  type: NodePort
  ports:
    - port: 8080
      nodePort: 30010
  selector:
    app: user

这样就可通过负载均衡器 IP 加 30010 端口访问到 user 这个微服务。

GitLab CI/CD

介绍

GitLab 从开源 SCM(Source Code Management)开始,但很快发展成为完整的 DevOps 解决方案,提供的功能包括项目管理、私有容器注册和构建环境(包括 Kubernetes)。GitLab CI/CD 由 GitLab Runner 驱动,在自包含的环境中执行 CI/CD 流水线中的每个步骤。可以通过 gitlab-ci.yml 清单完成 CI/CD 配置,该清单支持一些高级配置,包括逻辑条件运算和导入其他清单,或者使用 Auto DevOps,无需配置即可自动创建流水线。

创建 Runner

为了能够执行流水线中的任务(Job),需要先创建至少一个 Runner。Runner 可以是所有项目共享的(Shared),也可以为某个组(Group)或某个项目(Project)创建。

首先,在要运行 Runner 的机器上安装 gitlab-runner 工具,支持多种方式,推荐 Repositories 方式,具体可参考 Install GitLab Runner

其次,为所有项目,或某个组,或某个项目创建一个 Runner,创建前请在系统、组或项目的 Settings -> CI/CD 页里获取授权 Token。Runner 的类型可以是 ssh、docker、kubernetes 等,我们这里选择 docker,相比于 ssh 隔离性更好。

配置流水线

下面我们以自动部署一个 Java Spring Boot 服务为例来讲解如何配置流水线。在项目根目录下创建一个名为 .gitlab-ci.yml 的文件,内容如下(每个任务的具体内容后面会讲到):

stages:
  - build
  - package
  - deploy

maven-build:
  ...

docker-build:
  ...

.kubernetes-deploy:
  ...

kubernetes-deploy-development:
  ...

kubernetes-deploy-testing:
  ...

kubernetes-deploy-production:
  ...

其中 stages 定义本流水线将顺序执行 build(构建)、package(打包) 和 deploy(部署) 三个阶段(Stage),每个阶段可并行执行多个任务(Job)。

在流水线配置文件中可通过 $ 来引用变量,变量名通常为大写,单词之间以 _ 分隔。变量可在流水线中通过 variables 指令来定义,也可在组或项目的 Settings -> CI/CD 页里定义,这样就不用暴露在配置文件中。GitLab 预定义了许多环境变量,具体可查阅 Predefined environment variables reference

构建

maven-build:
  stage: build
  only:
    refs:
      - dev
      - test
      - master
  image: maven:3.6-jdk-8
  variables:
    MAVEN_OPTS: "-Dmaven.repo.local=.m2/repository -Dmaven.test.skip=true "
    MAVEN_CLI_OPTS: "-s .m2/settings.xml --batch-mode"
  script:
    - mvn $MAVEN_CLI_OPTS package
  artifacts:
    paths:
      - target/*.jar
  cache:
    paths:
      - .m2/repository/

任务 maven-build 属于阶段 build,它使用 maven 镜像来编译 Java 代码并打包为 Jar 包。由于是非本地构建,不能再使用本地 Maven 设置,因此需使用项目 Maven 设置 .m2/settings.xml,并将下载的依赖包保存到项目内的 .m2/repository/ 目录。构建出来的 Jar 包会作为工件(Artifact)上传到 GitLab 服务器,以便后续阶段使用。为了避免每次构建都重新去下载所有依赖包,指定了要被缓存的目录 .m2/repository/

打包

docker-build:
  stage: package
  image: docker:19.03
  services:
    - name: docker:19.03-dind
  variables:
    DOCKER_IMAGE_NAME: jaggerwang.net/scip-user
  script:
    - echo $DOCKER_REGISTRY_PASSWORD | docker login -u $DOCKER_REGISTRY_USERNAME --password-stdin $DOCKER_REGISTRY
    - docker pull $DOCKER_REGISTRY/$DOCKER_IMAGE_NAME:latest || true
    - docker build --cache-from $DOCKER_REGISTRY/$DOCKER_IMAGE_NAME:latest -t $DOCKER_REGISTRY/$DOCKER_IMAGE_NAME:$CI_COMMIT_SHORT_SHA -t $DOCKER_REGISTRY/$DOCKER_IMAGE_NAME:$CI_COMMIT_BRANCH -t $DOCKER_REGISTRY/$DOCKER_IMAGE_NAME:latest .
    - docker push $DOCKER_REGISTRY/$DOCKER_IMAGE_NAME:$CI_COMMIT_SHORT_SHA
    - docker push $DOCKER_REGISTRY/$DOCKER_IMAGE_NAME:$CI_COMMIT_BRANCH
    - docker push $DOCKER_REGISTRY/$DOCKER_IMAGE_NAME:latest

任务 docker-build 属于阶段 package,它负责将构建阶段得到的 Jar 包打包为 Docker 镜像。这里使用 docker:19.03 镜像来提供 docker 命令,并使用 docker:19.03-dind(dind 表示 Docker in Docker)镜像来提供构建镜像需要的 Docker 服务。如果使用私有 Registry,需要先执行 docker login 命令来登录,其中 DOCKER_REGISTRYDOCKER_REGISTRY_USERNAMEDOCKER_REGISTRY_PASSWORD 这些变量需在组或项目上定义。为了避免每次构建都去下载基础镜像,这里利用了上一次构建来作为缓存。

Docker 构建所用的 Dockerfile 位于项目根目录下,内容如下:

FROM openjdk:8-jre
VOLUME /tmp
ADD target/user-1.0-SNAPSHOT.jar app.jar
EXPOSE 8080
ENTRYPOINT ["java", "-Djava.security.egd=file:/dev/./urandom", "-jar", "app.jar"]

部署

.kubernetes-deploy:
  stage: deploy
  image: google/cloud-sdk:289.0.0
  script:
    - sed -i 's/$CI_COMMIT_SHORT_SHA/'"$CI_COMMIT_SHORT_SHA"'/' deployment.yml
    - kubectl apply -f deployment.yml -n scip

kubernetes-deploy-development:
  extends: .kubernetes-deploy
  only:
    refs:
      - dev
  before_script:
    - cat $KUBERNETES_DEVELOPMENT_CLUSTER_CONFIG >~/.kube/config

kubernetes-deploy-testing:
  extends: .kubernetes-deploy
  only:
    refs:
      - test
  script:
    - cat $KUBERNETES_TESTING_CLUSTER_CONFIG >~/.kube/config

kubernetes-deploy-production:
  extends: .kubernetes-deploy
  only:
    refs:
      - master
  when: manual
  script:
    - cat $KUBERNETES_PRODUCTION_CLUSTER_CONFIG >~/.kube/config

部署阶段包含三个任务,不过三个任务同时只会执行一个,根据当前提交代码的分支来决定要执行的任务,从而发布到对应的 Kubernetes 集群。通过隐藏任务 .kubernetes-deploy 将三个任务的公共部分提取了出来,以避免重复,三个任务的不同之处在于触发分支和要部署到的 Kubernetes 集群。其中环境变量KUBERNETES_DEVELOPMENT_CLUSTER_CONFIGKUBERNETES_TESTING_CLUSTER_CONFIGKUBERNETES_PRODUCTION_CLUSTER_CONFIG 保存了访问对应 Kubernetes 集群所需的凭证,需要在组或项目上配置好,类型为 File,因为其内容比较多,不适合通过环境变量值直接传递。

Kubernetes 部署文件 deployment.yml 的内容如下:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: user
spec:
  replicas: 1
  selector:
    matchLabels:
      app: user
  template:
    metadata:
      labels:
        app: user
    spec:
      imagePullSecrets:
        - name: docker-registry
      containers:
        - image: jaggerwang.net/scip-user:$CI_COMMIT_SHORT_SHA
          name: user
          imagePullPolicy: Always
          env:
            - name: TIME_ZONE
              value: Asia/Shanghai
          ports:
            - containerPort: 8080
              name: user
---
apiVersion: v1
kind: Service
metadata:
  name: user
spec:
  ports:
    - port: 8080
  selector:
    app: user

其中用到了 GitLab 预定义的环境变量 CI_COMMIT_SHORT_SHA,需要我们自己使用 sed 命令来替换为对应的变量值。如果是私有 Registry,还需创建存放访问 Registry 的帐号密码的 Kubernetes Secret 对象 docker-registry,否则拉取镜像会失败。

下面是 GitLab 流水线的实际执行效果:

--2020-05-06-15.55.23
--2020-05-06-15.56.06
--2020-05-06-15.56.26
--2020-05-06-15.56.43