如何把应用程序移植到k8s

程序部署环境的容器化已经是大势所趋,微服务为容器化提供了广阔的应用舞台,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

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

服务调用:

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

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

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

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

func buildDbConfig () 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。

func RegisterLogrusLog() 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)
    ...
    return nil
}

挂载持久卷:

下一步要做的就是挂载本地目录到容器的“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”, 能够用来定义服务变量。

下面就是使用了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

程序来源

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),新的模板比现在的看起来要强不少,如果你想使用新的还需要再等一等。

结论:

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

源码:

完整源码的github链接

备注:

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

索引:

  1. 通过实例快速掌握k8s(Kubernetes)核心概念
  2. 通过搭建MySQL掌握k8s(Kubernetes)重要概念(上):网络与持久卷
  3. 通过搭建MySQL掌握k8s(Kubernetes)重要概念(下):参数配置
  4. helm/helm
  5. Alpine: A simple Helm chart
  6. A First Look at the Helm 3 Plan

本文由博客一文多发平台 OpenWrite 发布!

相关推荐