node应用迁移至k8s实践

1,328 阅读8分钟

作者 | 周周酱

本文写于2020年10月。首发于周周酱个人博客,转载请注明出处。

本季度有一项工作是完成几个node应用到k8s的迁移工作,并完成原有两台node ecs的释放,本文主要记录我在node服务迁移中理解 k8s 部署的一些过程。

了解基本知识

Kubernetes名词解释

  1. Cluster
    • Cluster(集群) 是计算、存储和网络资源的集合,是一组用于运行容器化应用的节点计算机。如果你正在运行 Kubernetes ,那么你运行的其实就是集群。
    • 最简单的 Cluster 可以只有一台主机(它既是 Mater 也是 Node)
  2. Master
    • 是 Cluster 的大脑,它的主要职责是调度,即决定将应用放在哪里运行。
  3. Node
    • Node的职责是运行容器应用。
    • Node 由 Master 管理,Node 负责监控并汇报容器的状态,并根据 Master 的要求管理容器的生命周期。
  4. Pod
    • 被部署在单个节点上且包含一个或多个容器的容器组,是 Kubernetes 的最小工作单元。
    • 每个 Pod 包含一个或多个容器。Pod 中的容器会作为一个整体被 Master 调度到一个 Node 上运行。
    • 相同 Pod 中的任何容器都将共享相同的名称空间和本地网络,可以使用 localhost 互相通信。
    • Pod 很可能会被频繁地销毁和重启,它们的 IP 会发生变化。
  5. ReplicaSet
    • 确保 Pod 以你指定的副本数运行,即如果有容器异常退出,会自动创建新的 Pod 来替代,而异常多出来的容器也会自动回收,实现了集群的高可用性。
    • Kubernetes 官方强烈建议避免直接使用 ReplicaSet ,而应该通过 Deployment 来创建 ReplicaSet 和 Pod 。
  6. Service
    • Pod 的IP地址由网络插件动态随机分配,为了可以给客户端一个固定的访问端点,因此需要在客户端和Pod之间添加一个中间层,这个中间层称之为 Service 。
    • Kubernetes Service 定义了这样一种抽象:一个 Pod 的逻辑分组,一种可以访问它们的策略 —— 通常称为微服务。
    • 主要作用是提供负载均衡和服务自动发现。
  7. Deployment
    • Deployment 为 Pod 和 ReplicaSet 提供声明式更新,只需要在 Deployment 中描述想要的目标状态是什么,Deployment controller就会将 Pod 和 ReplicaSet 的实际状态改变到目标状态。

本次迁移在代码仓库层面的改动点,是增加了三个文件,dockerfile,构建脚本 deploy.sh,以及k8s yaml 配置文件,接下来逐个看。

1.dockerfile

# -- deps --
FROM node:lts AS deps
RUN set -ex && \
    npm set progress=false && \
    npm config set registry https://registry.npm.taobao.org
WORKDIR /var/app

# -- installer --
FROM deps AS installer
ARG TINI_URL=https://github.com/krallin/tini/releases/download/v0.18.0/tini-amd64
RUN set -ex && \
    sed -i 's#http://\(deb\|security\).debian.org/#http://mirrors.aliyun.com/#' /etc/apt/sources.list && \
    curl -sL $TINI_URL -o /sbin/tini && \
    chmod +x /sbin/tini
COPY package*.json ./
RUN npm i --production

# -- runtime --
FROM node:lts-slim
LABEL maintainer="huijuan.zhou <huijuan.zhou@xiaobao100.com>"
WORKDIR /var/app
COPY --from=installer /sbin/tini /sbin/tini
COPY --from=installer /var/app/node_modules ./node_modules
COPY --from=installer /var/app/package*.json ./
COPY . .
ENV NODE_ENV=production PORT=9000
EXPOSE 9000
ENTRYPOINT ["/sbin/tini", "--"]
CMD ["node", "./bin/www"]

将构建分为了三个阶段

  1. 设置npm配置,镜像地址为后面的安装依赖项做准备
  2. 安装tini(收割“僵尸进程”),安装npm依赖项
  3. 将项目代码以及第二步安装的node_modules和tini拷贝到最终的镜像里,暴露端口和执行的命令

【关键知识】

Dockerfile 多阶段构建

每一条 FROM 指令都是一个构建阶段,多条 FROM 就是多阶段构建,最后生成的镜像是最后一个阶段的结果,之前的 FROM 会被抛弃,但是,能够将前置阶段中的文件拷贝到后边的阶段中,使得多阶段构建有很大的意义。 最大的使用场景是将编译环境和运行环境分离。举个例子,我们在项目中使用了ts,我们最终只运行编译后的代码,编译过程中依赖的工具,npm包,都是不需要的,如果都打包到镜像中,最后生成镜像会非常大。所以我们常常将编译过程分离出来,只在最后一步将运行程序的最小依赖拷贝到镜像中。

2.构建脚本 deploy.sh

这是部署应用的入口文件,在这个文件中,将镜像的打包,推送,k8部署都封装成方法供外部调用,由外部传入部署的参数,那么这份脚本也可以作为多个应用的统一通用化的部署脚本。最终我们将和持续集成工具 Jenkins 结合起来使用。

#!/bin/bash

set -ex
K8S_NAME=coupon

# build_image <image> <tag>
build_image()
{
    echo "--building image \"$1:$2\"--"
    local docker_ver=$(docker version --format "{{.Client.Version}}")
    local major_ver=$(echo $docker_ver | awk '{print $1}' FS=.)
    if [ $major_ver -lt "17" ]; then
        echo "docker version $docker_ver is not supported"
        exit 1
    fi
    docker build -t "$1:$2" .
}

# push_image <image> <tag>
push_image()
{
    echo "--pushing image \"$1:$2\"--"
    docker push "$1:$2"
}

# bump_image <image> <tag>
bump_image()
{
    echo "--bumping image \"$1:$2\"--"
    docker tag "$1:$2" "$1:latest"
    docker push "$1:latest"
}

# deploy_app -n <namespace> -i <image:tag> -c <configfile> [-r <replicas>] [-h <host>] [-l <name>] [-s <secret>]
deploy_app()
{
    echo "--deploying app--"
    local kube_version=$(kubectl version --client --short | awk '{print $3}')
    local major_ver=$(echo $kube_version | awk '{print $2}' FS=.)
    if [ $major_ver -lt "12" ]; then
    	echo "kubectl version $kube_version is not supported"
        exit 1
    fi
    while getopts ':n:i:c:r:h:l:s:' opt; do
        case $opt in
            n)
            K8S_NAMESPACE=$OPTARG
            ;;
            i)
            K8S_IMAGE=$OPTARG
            ;;
            c)
            K8S_CONFIG=$OPTARG
            ;;
            r)
            K8S_REPLICAS=$OPTARG
            ;;
            h)
            K8S_HOST=$OPTARG
            ;;
            l)
            K8S_NAME=$OPTARG
            ;;
            s)
            TLS_SECRET=$OPTARG
            ;;
            \?)
            echo "Invalid option: $OPTARG" 1>&2
            exit 1
            ;;
            :)
            echo "Invalid option: $OPTARG requires an argument" 1>&2
            exit 1
            ;;
        esac
    done
    shift $((OPTIND -1))
    local files=('./deploy.yaml')
    K8S_TYPE='NodePort'
    kubectl -n "$K8S_NAMESPACE" create cm $K8S_NAME --from-file=config="$K8S_CONFIG" --dry-run -o yaml | kubectl apply -f -
    K8S_CONFIG_HASH=$(kubectl -n "$K8S_NAMESPACE" get cm/$K8S_NAME -o yaml | sha256sum | head -c 64)
    cat ${files[@]} | \
    sed -e "s#\$K8S_NAME\b#$K8S_NAME#g" | \
    sed -e "s#\$K8S_NAMESPACE\b#$K8S_NAMESPACE#g" | \
    sed -e "s#\$K8S_IMAGE\b#$K8S_IMAGE#g" | \
    sed -e "s#\$K8S_CONFIG_HASH\b#$K8S_CONFIG_HASH#g" | \
    sed -e "s#\$K8S_REPLICAS\b#${K8S_REPLICAS:-1}#g" | \
    sed -e "s#\$K8S_TYPE\b#$K8S_TYPE#g" | \
    sed -e "s#\$K8S_HOST\b#$K8S_HOST#g" | \
    sed -e "s#\$TLS_SECRET\b#${TLS_SECRET:-xiaobaoxiussl}#g" | \
    kubectl apply -o yaml -f -
}

# get_apps <namespace> [name]
get_apps()
{
    kubectl -n "$1" get all -l app=${2:-$K8S_NAME}
}

# patch_app <namespace> <content>
patch_app()
{
    kubectl -n "$1" patch deployment $K8S_NAME -p "$2"
}

if [ ! "$1" ]; then
    exit 1
fi

case "$1" in
build)
    build_image ${@:2}
    ;;
push)
    push_image ${@:2}
    ;;
bump)
    bump_image ${@:2}
    ;;
deploy)
    deploy_app ${@:2}
    ;;
patch)
    patch_app ${@:2}
    ;;
info)
    get_apps ${@:2}
    ;;
*)
    echo "unknown command $1"
    exit 1
    ;;
esac

关键看deploy_app部分,是部署k8应用的核心脚本

获取外部传入的构建参数:

  • K8S_NAMESPACE: 命名空间,不同团队的资源隔离
  • K8S_IMAGE: 当前应用的镜像地址
  • K8S_CONFIG: 当前应用的配置文件路径
  • K8S_REPLICAS:Pod 副本数量,如果有容器异常退出,会自动创建新的 Pod 来替代
  • K8S_HOST:服务的访问域名,在后面 Ingress 配置中的路由规则将对该域名生效,该域名会作为服务的统一访问入口
  • K8S_NAME: 应用名称
  • TLS_SECRET:使用ingress将服务以https暴露到集群外时,需要的ssl证书

挂载应用配置

kubectl -n "$K8S_NAMESPACE" create cm $K8S_NAME --from-file=config="$K8S_CONFIG" --dry-run -o yaml | kubectl apply -f -

【关键知识】

kubectl

kubectl是Kubernetes集群的命令行工具,通过 kubectl 能够对集群本身进行管理,并能够在集群上进行容器化应用的安装部署,配置集群访问信息的文件叫作 kubeconfig ,可以通过kubectl config view 命令来查看。 示例如下

apiVersion: v1
clusters:
- cluster:
    certificate-authority-data: xxx
    server: https://xxx:6443
  name: cluster1
contexts:
- context:
    cluster: cluster1
    user: admin
  name: cluster1-context
current-context: cluster1-context
kind: Config
preferences: {}
users:
- name: admin
  user:
    client-certificate-data: xxx
    client-key-data: xxx

在这个优惠券迁移的例子里,我们在jenkins机器上安装了kubectl,并配置了集群访问信息,可对集群进行管理。

配置挂载ConfigMap

kubernetes通过 ConfigMap 来实现对容器中应用的配置管理,将配置文件从容器镜像中解耦,从而增强容器应用的可移植性。ConfigMap 以一个或者多个key:value的形式保存在k8s系统中供应用使用,既可以用于表示一个变量的值,也可以用于表示一个完整配置文件的内容。

(1) 可以使用kubectl create configmap命令,可以根据目录, 文件或字符串创建 ConfigMap

kubectl create configmap <map-name><data-source>

代表ConfigMap的名字,代表目录、文件或者字符串。

(2) 通过yaml文件创建 ConfigMap

apiVersion: v1
kind: ConfigMap
metadata:
  name: env-config
  namespace: default
data:
  log_level: INFO
kubectl apply -f config.yaml

部署应用 kubernetes除了可以使用kubectl create命令来创建资源,也可以使用kubectl apply命令将配置应用于资源,kubectl apply更适合正式的、跨环境的、规模化部署,需要将资源的属性写在配置文件中,文件格式为YAML。在deploy.sh后面的步骤里,读取本项目的./deploy.yaml文件(将在后面附上),将参数部分替换为前面获取的构建参数,最终执行kubectl apply -o yaml -f -对 kubernetes 资源进行创建或更新,完成部署。

local files=('./deploy.yaml')
K8S_CONFIG_HASH=$(kubectl -n "$K8S_NAMESPACE" get cm/$K8S_NAME -o yaml | sha256sum | head -c 64)
    cat ${files[@]} | \
    sed -e "s#\$K8S_NAME\b#$K8S_NAME#g" | \
    sed -e "s#\$K8S_NAMESPACE\b#$K8S_NAMESPACE#g" | \
    sed -e "s#\$K8S_IMAGE\b#$K8S_IMAGE#g" | \
    sed -e "s#\$K8S_CONFIG_HASH\b#$K8S_CONFIG_HASH#g" | \
    sed -e "s#\$K8S_REPLICAS\b#${K8S_REPLICAS:-1}#g" | \
    sed -e "s#\$K8S_TYPE\b#$K8S_TYPE#g" | \
    sed -e "s#\$K8S_HOST\b#$K8S_HOST#g" | \
    sed -e "s#\$TLS_SECRET\b#${TLS_SECRET:-xiaobaoxiussl}#g" | \
    kubectl apply -o yaml -f -

使用YAML进行 kubernetes 定义优势明显,包括:

  • 方便:不再需要将所有参数都添加到命令行中
  • 可维护: YAML文件可以添加到源代码版本控制仓库中,可以跟踪文件的修改
  • 灵活性:通过YAML,能够创建比在命令行上更为复杂的结构

3.deploy.yaml

deploy.yaml定义了我们需要创建的 kubernetes 资源,以及资源的描述。

在这里会定义三个 kubernetes 资源

  • Deployment
  • Service
  • Ingress

Deployment是用来创建 ReplicaSet 和 Pod 的。

以下片段所示:

apiVersion: apps/v1  # 指定api版本,此值必须在kubectl api-versions中  
kind: Deployment # 指定创建资源的角色/类型  
metadata: # 资源的元数据/属性
  labels:  # 设定资源的标签
    app: $K8S_NAME
  name: $K8S_NAME
  namespace: $K8S_NAMESPACE
spec: # 资源规范字段
  replicas: $K8S_REPLICAS # 声明副本数目
  selector: # 选择器
    matchLabels: # 匹配标签
      app: $K8S_NAME
  template: # 模版
    metadata: # 资源的元数据/属性 
      labels: # 设定资源的标签
        app: $K8S_NAME
      annotations:
        configHash: $K8S_CONFIG_HASH
    spec: # 资源规范字段
      containers:
        - image: $K8S_IMAGE # 容器使用的镜像地址 
          imagePullPolicy: Always  # 每次Pod启动拉取镜像策略,三个选择 Always、Never、IfNotPresent
                                   # Always,每次都检查;Never,每次都不检查(不管本地是否有);IfNotPresent,如果本地有就不检查,如果没有就拉取 
          name: $K8S_NAME # 容器的名字
          ports:
            - containerPort: 9000 # 容器开发对外的端口 
              protocol: TCP # 协议
          volumeMounts:
            - name: tzdata
              mountPath: /etc/localtime
            - name: config
              mountPath: /var/app/config
          env:
            - name: TZ
              value: Asia/Shanghai
          readinessProbe: # Pod 准备服务健康检查设置
            httpGet:
              path: /Api/Microshop/SiteCheck
              port: 9000
            initialDelaySeconds: 3
            periodSeconds: 10
          livenessProbe: # pod 内部健康检查的设置
            httpGet:
              path: /Api/Microshop/SiteCheckTest
              port: 9000
            initialDelaySeconds: 10
            periodSeconds: 10
      imagePullSecrets: # 镜像仓库拉取密钥
        - name: registrykey-1
      volumes: # 数据卷
        - name: tzdata
          hostPath:
            path: /usr/share/zoneinfo/Asia/Shanghai
        - name: config # 应用配置文件
          configMap:
            name: $K8S_NAME
            items:
              - key: config
                path: config.index.json

该 Deployment 创建了运行 Docker 镜像 K8SIMAGEPod的一组副本,并为其分配app=K8S_IMAGE 的 Pod 的一组副本,并为其分配 app=K8S_NAME 标签(见selector部分)。 每个 Pod 都有自己的 IP 地址。当 controller 用新 Pod 替代发生故障的 Pod 时,新 Pod 会分配到新的 IP 地址。将 Pod 分组在 Service下,Service 给了客户端一个固定的 Ip,客户端只需要访问 Service 的 IP,Kubernetes 则负责建立和维护 Service 与 Pod 的映射关系,避免由于 Pod 销毁重新创建后的 Ip 变更带来的不稳定和不可依赖。 k8s在创建 Service 时,会根据标签选择器 selector(lable selector) 来查找Pod,在以下 Service 定义中, 会创建一个新的Service对象,名字为 K8SNAME(由外部传入),它收集所有带有selector标签app=K8S_NAME (由外部传入),它收集所有带有 selector标签 app = K8S_NAME 的 Pod ,指向 Pod 的 9000 端口。这个 Service 在接收到请求后,会根据负载策略转发到 Pod。

apiVersion: v1
kind: Service
metadata:
  labels:
    app: $K8S_NAME
  name: $K8S_NAME
  namespace: $K8S_NAMESPACE
spec:
  ports:
    - port: 80 #k8s集群内部服务之间访问service的入口
      protocol: TCP
      targetPort: 9000 #容器端口 
  selector:
    app: $K8S_NAME
  type: $K8S_TYPE

k8s 中 port、nodePort、targetPort概念

从上面Service定义中的spec->ports中可以看到有port和tartgetPort,其实还有未指定的 nodeport ,分别都是什么作用呢

  1. port
    • k8s集群内部服务之间访问service的入口。
  2. targetPort
    • 是pod也就是容器的端口
  3. nodeport
    • 集群外部访问集群内的服务时,所访问的port。

在上述定义的service中,我们优惠券应用容器暴露了9000端口,集群内其他容器可以通过80端口访问我们的优惠券服务,我们没有定义nodeport,因为对外部暴露服务,我们使用其他的方式。

【关键知识】

Service 暴露服务的几种方式

  1. ClusterIP
    • 默认方式,通过集群的内部 IP 暴露服务,选择该值,服务只有 Cluster 内的节点和 Pod 可访问。
  2. NodePort
    • 主要通过每个节点IP加端口的形式暴露端口,Cluster 外部可以通过 : 访问 Service。
    • 可以在 nodePort 字段中指定一个值,如果不指定这个端口,系统将选择一个随机端口,多数时候我们应该让 Kubernetes 来选择端口,自己处理的话可能会出现端口冲突。
  3. LoadBalancer
    • 在使用一个集群内部 IP 和在 NodePort 上开放一个服务之外,向云提供商申请一个负载均衡器,会让流量转发到这个在每个节点上以:的形式开放的服务上,NodePort 端口由 kubernetes 自动分配并管理,所以不存在端口冲突问题
    • 依赖云平台,如果未在受支持的IaaS平台(GCP,AWS,Azure ...)上运行,则LoadBalancers将在创建时无限期地保持“挂起”状态。

ClusterIP 是集群内部访问方式,NodePort 和 LoadBalancer 可以在集群外部访问,这都是在 service 的维度上提供的,service 的作用体现在两个方面,对集群内部,跟踪pod的变化,提供了ip 不断变化的 pod 的服务发现机制,对集群外部,类似负载均衡器,提供了集群外部访问 pod 的方式。

不过,单独使用这几种方式暴露服务,会面临一些问题:

  • ClusterIP:只能在集群内部访问。
  • NodePort:服务变多的情况下会导致节点要开的端口越来越多,不好管理。
  • LoadBalancer:LoadBalancer 的服务在 Kubernetes 内部并没有直接支持,在 GKE 或 EKS 之类的云托管环境中运行并且可以使用该云供应商的负载均衡器技术时,它才有效,使用这种类型的服务会为每个服务启动一个托管的负载均衡器以及一个新的公共IP地址,会产生额外的费用。

所以会需要一个新的抽象层,该层可以在入口点(entrypoint)后面整合许多服务,Kubernetes API引入了Ingress,Ingress 事实上不是一种服务类型,它处于多个服务的前端,扮演着“智能路由”或者集群入口的角色。编写rules,声明希望客户端如何路由到服务,将每个服务映射到特定的URL路径或域名以供公众使用。

Ingress

Ingress可以给service提供集群外部访问的URL、负载均衡、SSL终止、HTTP路由等。为了配置这些Ingress规则,集群管理员需要部署一个Ingress controller,它监听Ingress和service的变化,并根据规则配置负载均衡并提供访问入口(引用自官方文档) 通常情况下,Service 和 Pod 的 IP 仅可在集群内部访问。集群外部的请求需要通过负载均衡转发到 Service 在 Node 上暴露的 NodePort 上,然后再由 kube-proxy 通过边缘路由器 (edge router) 将其转发给相关的 Pod 或者丢弃。而 Ingress 就是为进入集群的请求提供路由规则的集合(引用自Kubernetes系列 之 Ingress 统一访问入口

下面的 Ingress 定义表示请求K8SHOST域名的任意路径时会转发到服务K8S_HOST域名的任意路径时会转发到服务 K8S_NAME 的80端口,也就是在上面 service 定义中 spec->ports 下的port 80,因为Ingress Controller本身也位于集群内部,所以通过集群内部访问的方式去访问我们的优惠劵应用 。

apiVersion: extensions/v1beta1
kind: Ingress
metadata:
  name: $K8S_NAME
  namespace: $K8S_NAMESPACE
spec:
  tls:
    - hosts:
        - $K8S_HOST
      secretName: $TLS_SECRET
  rules:
    - host: $K8S_HOST
      http:
        paths:
          - path: /
            backend:
              serviceName: $K8S_NAME
              servicePort: 80

4.Jenkins配置

项目配置文件

使用了 Jenkins 的 Config File Provider插件,他提供通过Jenkins UI加载的配置文件(包含Maven,XML,Groovy,自定义文件等配置文件)的功能,该配置文件将被复制到 job 的 workspace 中。 微信图片_20200827155820_看图王.png

此为优惠券项目涉及到的配置项,通过 Config File Provider 插件,可以在 jenkins 上编辑我们的配置文件了。 微信图片_20200827155830_看图王.png

微信图片_20200827155834.png

构建Shell

此为最终的 jenkins 构建脚本,在这里定义了应用的IMAGE_NAME(镜像url)、APP_NAMESPACE(k8s namespace)、CONFIG_FILE(项目配置文件的路径)、APP_HOSTNAME(应用的暴露域名)、IAMGE_TAG(镜像tag)等变量,作为 deploy.sh 脚本中所暴露的方法的参数,执行镜像的build,push方法,以及 deploy 方法来完成应用的部署。

微信图片_20200827155840.png

以上较多概念信息来自官方文档,同时整合了其他文章中相对通俗易懂的描述。毕竟是刚入门学习,如果有描述错误的地方请与我联系。本次还是花费了较多时间在 Kubernetes 的各个概念理解上,毕竟学习起来还是难度挺大的,更多的是要结合一些实际操作来深入地理解。

参考和链接

官方文档

十分钟带你理解Kubernetes核心概念

Ingress 统一访问入口