如何把应用程序迁移到 Kubernetes?
发布于 5 天前 作者 yan 15 次浏览

程序部署环境的容器化已经是大势所趋,微服务为容器化提供了广阔的应用舞台,k8s 已经把 Docker 纳入为它的底层支撑容器引擎,一统江湖,成为了容器技术事实上的标准。一般的应用程序是不能直接拿来部署到容器上的,需要经过一些修改才能移植到 k8s 上。那么这些改动包括哪些内容呢?

它主要有两个部分:

  • 第一部分是服务调用 。不论是微服务之间的调用,还是微服务调用数据库或前端调用后端,调用的方式都是一样的。都需要知道 IP 地址,端口和协议,例如“http://127.0.0.1:80”, 其中“http”是协议,“127.0.0.1”是 IP 地址,“80”是端口。它的关键是让 k8s 的配置文件和应用程序都共享相同的调用地址。* 第二部分是数据的持久存储 。在程序运行时,经常要访问持久存储(硬盘)上的数据,例如日志,配置文件或临时共享数据。程序在容器中运行,一旦出现问题,容器会被摧毁,k8s 会自动重新生成一个与原来一模一样的容器,并在上面重新部署应用程序。在集群环境下,用户感觉不到容器故障,因为系统已经自动修复了。但当容器被摧毁时,容器上的数据也一起被摧毁了,因此要保证程序运行的连续性,就要让持久存储不受容器故障的影响。

一、程序实例

我们通过一个 Go(别的语言也大同小异)微服务程序做例子来展示要做的修改。它本身的功能非常简单,只是用 SQL 语句访问数据库中的数据,并写入日志。你可以简单地把它分成两层,后端数据访问层和数据库层。在 k8s 中它被分成两个服务。一个是后端服务程序,另一个是数据库(用 MySQL)服务。后端程序要调用数据库服务,然后会把一些数据写入日志,而且这个日志不能因为容器故障而丢失。数据库对数据的保存要求更高,即使 k8s 集群或虚拟机出了问题或断电也要保证数据的存在。

上面是程序的目录结构。我们重点讲一下与 k8s 相关的。“config”目录包含与程序配置有关的代码,“logs”目录是用来存储日志文件的,没有代码。“script”目录是重点,里面包含了所有与部署程序相关的文件。其中“database”子目录里面是数据库脚本,“kubernetes”子目录存有 k8s 的所有配置文件,一回儿还会详细讲解。

二、服务调用

服务调用涉及到两个不同的部分。一部分是 k8s 的配置文件,它负责服务的注册和发现。所有部署在 k8s 上的应用都通过 k8s 的服务来进行互相调用。另一部分是应用程序,它需要通过 k8s 的服务来访问其他程序。在没有 k8s 时,后端要想访问数据库,代码是这样的:

db, err := sql.Open("mysql", "dbuser:dbuser[@tcp](/user/tcp)(localhost:3306)/service_config?charset=utf8")

其中,“dbuser:dbuser”是数据库用户名和口令,“localhost:3306”是数据库主机名和端口地址,“service-config”是数据库名,共有五个数据需要读取。迁移到 k8s 之后,我们要把这些参数从程序中提取出来,转化成从 k8s 中读取相关数据。

k8s 配置

先来看一下 k8s 的配置文件。

上面就是 k8s 的配置文件目录结构,最外层(kubernetes 目录下)有两个“yaml”文件“k8sdemo-config.yaml”和"k8sdemo-secret.yaml",它们是被不同服务共享的,因此放在最外层。另外还有一个"k8sdemo.sh"文件是 k8s 命令文件,用来创建 k8s 对象。“kubernetes”目录下有两个子目录“backend”和“database”分别存放后端程序和数据库的配置文件。它们内部的结构是类似的,都有三个“yaml”文件:

  • backend-deployment.yaml:部署配置文件,* backend-service.yaml:服务配置文件* backend-volume.yaml:持久卷配置文件.

关于 k8s 的核心概念,请参阅 “通过实例快速掌握 k8s(Kubernetes)核心概念”. “backend”目录还多了一个“docker”子目录用来存储 backend 应用的 Docker 镜像,database 的镜像文件直接从 Docker 的库中取得,因此不需要另外生成镜像文件。

k8s 参数配置

要想集成应用程序和 k8s 需要两个层面的参数共享,一个是应用程序和 k8s 之间的参数共享,另一个是不同 k8s 服务之间的参数共享。

k8s 共享参数定义:

共享参数可以通过两种方式实现,一个是环境变量,另一个是持久卷。这两种方式大同小异,我们这里用环境变量的方式。这其中最关键的是“k8sdemo-config.yaml”和"k8sdemo-secret.yaml"这两个文件,它们分别存储了普通参数和保密参数。这些参数是属于整个应用程序的,被各个服务共享。

下面就是“k8sdemo-config.yaml”,它里面(在“data:”下面)定义了三个数据库参数,分别是数据库主机(MYSQL_HOST),数据库端口(MYSQL_PORT),数据库名(MYSQL_DATABASE)。

apiVersion: v1
kind: ConfigMap
metadata:
 name: k8sdemo-config  # ConfigMap的名字, 在引用数据时需要
 labels:
   app: k8sdemo
data:
 MYSQL_HOST: k8sdemo-database-service   # 数据库主机
 MYSQL_PORT: "3306"# 数据库端口
 MYSQL_DATABASE: service_config # 数据库名

下面就是“k8sdemo-secret.yaml”,它里面(在“data:”下面)也定义了三个数据库参数,根用户口令(MYSQL_ROOT_PASSWORD),普通用户名(MYSQL_USER_NAME),普通用户口令(MYSQL_USER_PQSSWORD)

apiVersion: v1
kind: Secret
metadata:
 name: k8sdemo-secret
 labels:
   app: k8sdemo
data:
 MYSQL_ROOT_PASSWORD: cm9vdA== # 根用户口令("root")
 MYSQL_USER_NAME: ZGJ1c2Vy # 普通用户名("dbuser")
 MYSQL_USER_PASSWORD: ZGJ1c2Vy # 普通用户口令("dbuser")

有关 k8s 的参数配置详细信息,请参阅“通过搭建 MySQL 掌握 k8s(Kubernetes)重要概念(下):参数配置”.

引用 k8s 共享参数:

下面就是“backend-deployment.yaml”,它定义了“backend“服务的部署(Deployment)配置。它的“containers:”部分定义了容器,“env:”部分定义了环境变量,也就是我们所熟悉的操作系统的环境变量,一般是由系统来定义。不同的系统例如 Linux 和 Windows 都有自己的方法来定义环境变量。

apiVersion: apps/v1
kind: Deployment
metadata:
 name: k8sdemo-backend-deployment
 labels:
   app: k8sdemo-backend
spec:
 selector:
   matchLabels:
     app: k8sdemo-backend
 strategy:
   type: Recreate
 template:
   metadata:
     labels:
       app: k8sdemo-backend
   spec:
     containers: # 定义容器
       - image: k8sdemo-backend-full:latest
         name: k8sdemo-backend-container
         imagePullPolicy: Never
         env: # 定义环境变量
           - name: MYSQL_USER_NAME
             valueFrom:
               secretKeyRef:
                 name: k8sdemo-secret
                 key: MYSQL_USER_NAME
           - name: MYSQL_USER_PASSWORD
             valueFrom:
               secretKeyRef:
                 name: k8sdemo-secret
                 key: MYSQL_USER_PASSWORD
           - name: MYSQL_HOST
             valueFrom:
              configMapKeyRef:
                name: k8sdemo-config
                key: MYSQL_HOST
           - name: MYSQL_PORT
             valueFrom:
               configMapKeyRef:
                 name: k8sdemo-config
                 key: MYSQL_PORT
           - name: MYSQL_DATABASE
             valueFrom:
               configMapKeyRef:
                 name: k8sdemo-config
                 key: MYSQL_DATABASE
         ports:
           - containerPort: 80
             name: portname
         volumeMounts:
           - name: k8sdemo-backend-persistentstorage
             mountPath: /app/logs
     volumes:
       - name: k8sdemo-backend-persistentstorage
         persistentVolumeClaim:
           claimName: k8sdemo-backend-pvclaim

k8s 的环境变量主要是用来向容器传递参数的。环境变量引用了“k8sdemo-config.yaml”和"k8sdemo-secret.yaml"文件里的参数,这样就在 k8s 内部用过共享参数定义和参数引用实现了 k8s 层的参数共享。

下面是部署配置文件里的环境变量的片段。“ - name: MYSQL_USER_PASSWORD”是环境变量名,“secretKeyRef”说明它的值来自于 secret,“name: k8sdemo-secret”是 secret 的名字,“key: MYSQL_USER_PASSWORD”是 secret 里的键名,它的最终含义就是环境变量“MYSQL_USER_PASSWORD”的值是由 secret 里的量“MYSQL_USER_PASSWORD”来定义。

env:
 - name: MYSQL_USER_PASSWORD
           valueFrom:
             secretKeyRef:
               name: k8sdemo-secret
               key: MYSQL_USER_PASSWORD

下面是另一个定义环境变量的片段,与上面的类似,只不过它的键值来自于 configMap,而不是 secret。

env:
  - name: MYSQL_DATABASE
           valueFrom:
             configMapKeyRef:
               name: k8sdemo-config
               key: MYSQL_DATABASE

关于 k8s 的部署配置细节,请参阅“通过搭建 MySQL 掌握 k8s(Kubernetes)重要概念(上):网络与持久卷” 。

程序和 k8s 的参数共享:

k8s 在创建容器时,会创建环境变量。应用程序在容器里运行时可以从环境变量里读取共享参数已达到应用程序和 k8s 共享参数的目的。下面就是 Go 程序访问数据库的代码片段。

type dbConfig struct {
  dbHost     string
  dbPort     string
  dbDatabase string
  dbUser string
  dbPassword string
}

funcbuildMysql()(dataservice.UserDataInterface, error) {
  tool.Log.Debug("connect to database ")
  dc :=  buildDbConfig ()
  dataSourceName := dc.dbUser + ":"+ dc.dbPassword + "[@tcp](/user/tcp)(" +dc.dbHost +":" +dc.dbPort +")/" + dc.dbDatabase + "?charset=utf8";
  tool.Log.Debug("dataSourceName:", dataSourceName)
  //db, err := sql.Open("mysql", "dbuser:dbuser[@tcp](/user/tcp)(localhost:3306)/service_config?charset=utf8")
  db, err := sql.Open("mysql", dataSourceName)
  checkErr(err)
  dataService := userdata.UserDataMysql{DB: db}
  return &dataService, err
}

funcbuildDbConfig()dbConfig{
  dc :=dbConfig{}
  dc.dbHost = os.Getenv("MYSQL_HOST")
  dc.dbPort = os.Getenv("MYSQL_PORT")
  dc.dbDatabase = os.Getenv("MYSQL_DATABASE")
  dc.dbUser = os.Getenv("MYSQL_USER_NAME")
  dc.dbPassword = os.Getenv("MYSQL_USER_PASSWORD")
  return dc
}

上面程序中,“buildDbConfig()”函数从环境变量中读取 k8s 给容器设置好的参数,并上传给“buildMysql()”函数,用来连接数据库。上面是用 Go 程序读取环境变量,但其它语言例如 Java 也有类似的功能。

三、持久存储

“backend”服务日志

持久存储相对比较简单,它不需要做额外的应用程序修改 ,但需要程序和 k8s 相互配合来完成。

Go 代码:

下面是日志设置的 Go 代码片段,它把日志的输出设为 k8sdemo 的 logs 目录和 Stdout。

funcRegisterLogrusLog()error {
   //standard configuration
   log := logrus.New()
   log.SetFormatter(&logrus.TextFormatter{})
   log.SetReportCaller(true)
   file, err := os.OpenFile("../logs/demo.log", os.O_RDWR|os.O_CREATE|os.O_APPEND, 0666)
   if err != nil {
       fmt.Println("Could Not Open Log File : ", err)
       return errors.Wrap(err, "")
   }
   mw := io.MultiWriter(os.Stdout,file)
   log.SetOutput(mw)
   ...
   returnnil
}

挂载持久卷:

下一步要做的就是挂载本地目录到容器的“logs”目录,这样日志在写入“logs”目录的时候就写入了本地目录。下面是生成 k8s 持久卷的配置文件“backend-volume.yaml”,它内部分成两部分(用“—”隔开)。上半部分是持久卷,下半部分是持久卷申请。它由本地硬盘的“/home/vagrant/app/k8sdemo/logs”目录生成 k8s 的持久卷。

apiVersion: v1
kind: PersistentVolume
metadata:
 name: k8sdemo-backend-pv
 labels:
   app: k8sdemo-backend
spec:
 capacity:
   storage: 1Gi
 volumeMode: Filesystem
 accessModes:
   - ReadWriteOnce
 storageClassName: standard
 local:
   path: /home/vagrant/app/k8sdemo/logs
 nodeAffinity:
   required:
     nodeSelectorTerms:
       - matchExpressions:
           - key: kubernetes.io/hostname
             operator: In
             values:
               - minikube
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
 name: k8sdemo-backend-pvclaim
 labels:
   app: k8sdemo-backend
spec:
 accessModes:
   - ReadWriteOnce
 # storageClassName: local-storage
 resources:
   requests:
     storage: 1Gi #1 GB

下面是“backend-deployment.yaml”部署文件片段,它把 k8s 的持久卷挂载到容器的“app/logs”上。

   volumeMounts:
     - name: k8sdemo-backend-persistentstorage
       mountPath: /app/logs
volumes:
 - name: k8sdemo-backend-persistentstorage
   persistentVolumeClaim:
     claimName: k8sdemo-backend-pvclaim

完成之后,就可以在本地目录上查看日志文件,这样即使容器或 k8s 集群出现问题,日志也不会丢失。

为什么目录是“app/logs”呢?因为在生成“beckend”的镜像时,设定的容器的运行程序根目录是“app”。关于如何创建 Go 镜像文件,请参阅“创建优化的 Go 镜像文件以及踩过的坑”.

数据库持久卷

Mysql 数据库的持久卷设置与日志类似,详情请参阅 通过搭建 MySQL 掌握 k8s(Kubernetes)重要概念(上):网络与持久卷

四、存在的问题

细心的读者可能已经发现了,在定义的环境变量中,有两个与其他的有些不同,这两个就是“MYSQL_HOST”和"MYSQL_PORT"。所有的环境变量都是在参数文件(k8sdemo-config.yaml)中定义,别的环境变量是在 k8s 配置文件(例如 backend-deployment.yaml)中引用,但这两个虽然在 k8s 的部署配置文件提到了,但只是用来定义环境变量,最终只是被应用程序引用了,但服务的配置文件并没有真正引用它。

apiVersion: v1
kind: Service
metadata:
 name: k8sdemo-database-service # 这里并没有引用环境变量
 labels:
   app: k8sdemo-database
spec:
 type: NodePort
 selector:
   app: k8sdemo-database
 ports:
   - protocol : TCP
     nodePort: 30306
     port: 3306 # 这里并没有引用环境变量
     targetPort: 3306

上面是数据库服务的配置文件“database-service.yaml”, 这里并没有引用“MYSQL_HOST”和"MYSQL_PORT",而是直接写上“k8sdemo-database-service”和“3306”。为什么会是这样呢?因为 k8s 的环境变量是有局限性的,它只能定义在“containers:”里面,也就是说只有容器才能定义环境变量,这从理论上也说得过去。因为如果没有容器,那么环境变量定义给谁呢?但这就导致了服务名不能引用配置参数,结果就是服务名要在两处被定义,一个是参数文件,另一个是服务配置文件。如果你要修改它,就要在两处同时修改,加大了出错的几率。有什么办法可以解决呢?

Helm

这在 k8s 内部是没法解决的,但在 k8s 外是可以解决的。有一个很流行的 k8s 的包管理工具,叫“helm”[1], 能够用来定义服务变量。

下面就是使用了 Helm 之后的 Pod 的配置文件。

alpine-pod.yaml

apiVersion: v1
kind: Pod
metadata:
 name: {{ template "alpine.fullname" . }}
 labels:
   # The "app.kubernetes.io/managed-by" label is used to track which tool deployed a given chart.# It is useful for admins who want to see what releases a particular tool# is responsible for.
   app.kubernetes.io/managed-by: {{ .Release.Service }}
   # The "app.kubernetes.io/instance" convention makes it easy to tie a release to all of the# Kubernetes resources that were created as part of that release.
   app.kubernetes.io/instance: {{ .Release.Name | quote }}
   app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
   # This makes it easy to audit chart usage.
   helm.sh/chart: {{ .Chart.Name }}-{{ .Chart.Version }}
   app.kubernetes.io/name: {{ template "alpine.name" . }}
spec:
 # This shows how to use a simple value. This will look for a passed-in value called restartPolicy.
 restartPolicy: {{ .Values.restartPolicy }}
 containers:
 - name: waiter
   image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
   imagePullPolicy: {{ .Values.image.pullPolicy }}
   command: ["/bin/sleep", "9000"]

下面是变量的定义文件 values.yaml

image:
 repository: alpine
 tag: latest
 pullPolicy: IfNotPresent

restartPolicy: Never

程序来源[2]

Helm 使用了 Go 的模板(template)。模板是用数据驱动的文本生成器。它在文本模板里用特殊符号(这里是“{{ }}”)定义变量或数据,然后在执行模板时再将变量转换成变量值,生成最终文本,一般在前端用的比较多。在 Helm 模板里,“{{ }}”里面的就是变量引用,变量是定义在“values.yaml”文件里的。

上面的例子有两个文件,一个是“alpine-pod.yaml”,另一个是“values.yaml”。变量定义在“values.yaml”里,再在“alpine-pod.yaml”文件里引用,这样就解决了 k8s 的环境变量的局限性。

Helm 是功能非常强大的 k8s 包管理工具,而且可以简化容器部署,是一款非常流行的工具。但它的问题是 Helm 增加了配置文件的复杂度,降低了可读性。现在的版本是 Helm2,但 Helm3 不久就要出炉了。Helm3 有一个功能是支持 Lua 模板,能直接用对象编程(详情请见A First Look at the Helm 3 Plan[3]),新的模板比现在的看起来要强不少,如果你想使用新的还需要再等一等。

五、结论

一般的应用程序是不能直接部署到 k8s 上的,需要经过一些改动才行。它主要有两个部分。第一个是服务调用。第二个是数据的持久存储。服务调用的关键是让 k8s 和应用程序共享参数。k8s 里已经有这种机制,但它还有一点缺陷,只能用来定义容器的环境变量,需要引入其他工具,例如 Helm 才能解决这个问题。持久存储不需要修改程序,但需要 k8s 的配置和应用程序配合才能成功。

六、源码

完整源码的 github 链接[4]

七、备注

本文中的 Go 程序只是示例程序,只有 k8s 配置文件部分是认真写的,可以直接拷贝或引用。其他部分都是临时拼凑来的,主要是为了作为例子,因此没有花时间完善它们,总的来说它们写得比较粗糙,千万不要直接拷贝。

文中链接

[1] “helm”: https://github.com/helm/helm [2] 程序来源: https://github.com/helm/helm/tree/master/docs/examples/alpine [3] A First Look at the Helm 3 Plan: https://sweetcode.io/a-first-look-at-the-helm-3-plan/ [4] 完整源码的github链接: https://github.com/jfeng45/k8sdemo

END ----- Kubernetes  CKA线下班

回到顶部