在 Kubernetes 中运行集成测试

通过使用多个容器来提供完整的测试环境,验证您的应用程序在完整解决方案堆栈中的行为。
344 位读者喜欢这篇文章。
cubes coming together to create a larger cube

Opensource.com

Linux 容器改变了我们运行、构建和管理应用程序的方式。随着 更多更多 平台成为云原生平台,容器在每个企业的基础架构中都扮演着越来越重要的角色。Kubernetes (K8s) 是目前最著名的容器管理解决方案,无论它们运行在私有云、公有云还是混合云中。

借助容器应用程序平台,我们可以动态创建整个环境来运行任务,并在之后将其丢弃。在之前的文章中,我们介绍了如何使用 Jenkins 在容器中运行构建和单元测试。在继续阅读之前,我建议您先查看该文章,以便熟悉该解决方案的基本原理。

现在让我们看看如何通过启动多个容器来运行集成测试,从而提供完整的测试环境。

假设我们有一个后端应用程序,它依赖于其他服务,例如数据库、消息代理或 Web 服务。在单元测试期间,我们尝试使用嵌入式解决方案或简单地模拟这些端点,以确保不需要网络连接。这需要在我们的代码中为测试范围进行更改。

集成测试的目的是验证应用程序在解决方案堆栈的其他部分中的行为。提供服务不仅仅取决于我们的代码库。整体解决方案是模块(例如,带有存储过程的数据库、消息代理或带有服务器端脚本的分布式缓存)的混合,这些模块必须以正确的方式连接在一起才能提供预期的功能。这只能通过将所有这些部分彼此相邻运行来测试,而不是在我们的应用程序中启用“测试模式”。

Unit testing vs Integration testing

在这种情况下,“单元测试”和“集成测试”是否是正确的术语是有争议的。为了简单起见,我将把在没有外部依赖项的单个进程中运行的测试称为“单元测试”,将以生产模式运行应用程序并建立网络连接的测试称为“集成测试”。

为这些测试维护静态环境可能很麻烦并且浪费资源;这正是动态容器的临时性派上用场的地方。

本文的代码库可以在我的 kubernetes-integration-test GitHub 仓库中找到。它包含一个示例 Red Hat Fuse 7 应用程序 (/app-users),该应用程序从 AMQ 接收消息,从 MariaDB 查询数据,并调用 REST API。该仓库还包含集成测试项目 (/integration-test) 和本文中解释的不同 Jenkinsfile。

以下是本教程中使用的软件版本

  • Red Hat Container Development Kit (CDK) v3.4
  • OpenShift v3.9
  • Kubernetes v1.9
  • Jenkins 镜像 v3.9
  • Jenkins kubernetes-plugin v1.7

每次都是全新的开始

我们希望通过集成测试实现以下目标

  • 启动被测应用程序的生产就绪包,
  • 启动所有必需的依赖系统的实例,
  • 运行仅通过公共服务端点与应用程序交互的测试,
  • 确保执行之间没有任何持久化,因此我们不必担心恢复初始状态,并且
  • 仅在测试执行期间分配资源。

Jenkins master and Kubernetes pipeline process

该解决方案基于 Jenkinsjenkins-kubernetes-plugin。Jenkins 可以在不同的代理节点上运行任务,而该插件使得在 Kubernetes 上动态创建这些节点成为可能。代理节点仅在任务执行时创建,并在之后删除。

我们首先需要定义代理节点 Pod 模板。jenkins-master OpenShift 镜像带有 Maven 和 NodeJS 构建的预定义 podTemplates,管理员可以将此类“静态”Pod 模板添加到插件配置中。

幸运的是,如果我们使用 Jenkins Pipeline,则可以直接在我们的项目中定义代理节点 Pod 模板。这显然是一种更灵活的方式,因为整个执行环境可以由开发团队在代码中维护。让我们看一个例子

podTemplate(
  label: 'app-users-it',
  cloud: 'openshift', //This needs to match the cloud name in jenkins-kubernetes-plugin config
  containers: [
    //Jenkins agent. Also executes the integration test. Having a 'jnlp' container is mandatory.
    containerTemplate(name: 'jnlp',
                      image: 'registry.access.redhat.com/openshift3/jenkins-slave-maven-rhel7:v3.9',
                      resourceLimitMemory: '512Mi',
                      args: '${computer.jnlpmac} ${computer.name}',
                      envVars: [
                        //Heap for mvn and surefire process is 1/4 of resourceLimitMemory by default
                        envVar(key: 'JNLP_MAX_HEAP_UPPER_BOUND_MB', value: '64')
                      ]),
    //App under test
    containerTemplate(name: 'app-users',
                      image: '172.30.1.1:5000/myproject/app-users:latest',
                      resourceLimitMemory: '512Mi',
                      envVars: [
                        envVar(key: 'SPRING_PROFILES_ACTIVE', value: 'k8sit'),
                        envVar(key: 'SPRING_CLOUD_KUBERNETES_ENABLED', value: 'false')
                      ]),
    //DB
    containerTemplate(name: 'mariadb',
                      image: 'registry.access.redhat.com/rhscl/mariadb-102-rhel7:1',
                      resourceLimitMemory: '256Mi',
                      envVars: [
                        envVar(key: 'MYSQL_USER', value: 'myuser'),
                        envVar(key: 'MYSQL_PASSWORD', value: 'mypassword'),
                        envVar(key: 'MYSQL_DATABASE', value: 'testdb'),
                        envVar(key: 'MYSQL_ROOT_PASSWORD', value: 'secret')
                      ]),
    //AMQ
    containerTemplate(name: 'amq',
                      image: 'registry.access.redhat.com/jboss-amq-6/amq63-openshift:1.3',
                      resourceLimitMemory: '256Mi',
                      envVars: [
                        envVar(key: 'AMQ_USER', value: 'test'),
                        envVar(key: 'AMQ_PASSWORD', value: 'secret')
                      ]),
    //External Rest API (provided by mockserver)
    containerTemplate(name: 'mockserver',
                      image: 'jamesdbloom/mockserver:mockserver-5.3.0',
                      resourceLimitMemory: '256Mi',
                      envVars: [
                        envVar(key: 'LOG_LEVEL', value: 'INFO'),
                        envVar(key: 'JVM_OPTIONS', value: '-Xmx128m'),
                      ])
    ]
    )
{
    node('app-users-it') {
        /* Run the steps:
         * - pull source
         * - prepare dependency systems, run sqls
         * - run integration test
         * ...
         */
    }
}

此 Pipeline 将创建所有容器,拉取给定的 Docker 镜像并在同一 Pod 中运行它们。这意味着容器将共享 localhost 接口,因此服务可以访问彼此的端口(但我们必须考虑端口绑定冲突)。这是在 OpenShift Web 控制台中运行的 Pod 的外观

The running pod in OpenShift web console

镜像由其 Docker URL 设置(此处不支持 OpenShift 镜像流),因此集群必须访问这些注册表。在上面的示例中,我们之前在同一个 Kubernetes 集群中构建了应用程序的镜像,现在正从内部注册表拉取它:172.30.1.1 (docker-registry.default.svc)。此镜像是我们的发布包,可以部署到开发、测试或生产环境。它使用k8sit应用程序属性配置文件启动,其中连接 URL 指向 127.0.0.1

考虑运行 Java 进程的容器的内存使用情况非常重要。当前版本的 Java (v1.8, v1.9) 默认情况下忽略容器内存限制,并设置更高的堆大小。版本 3.9 jenkins-slave 镜像通过环境变量更好地支持内存限制,比早期版本更好。设置 JNLP_MAX_HEAP_UPPER_BOUND_MB=64 足以让我们在 512MiB 限制下运行 Maven 任务。

Pod 中的所有容器都有一个共享的 empty dir 卷挂载在 /home/jenkins(默认 workingDir)。Jenkins 代理使用它在容器中运行 Pipeline 步骤脚本,这也是我们检出集成测试仓库的位置。这也是执行步骤的当前目录,除非它们位于 dir('relative_dir') 块中。以下是上面示例的 Pipeline 步骤

podTemplate(...)
{
    node('app-users-it') { //must match the label in the podTemplate
        stage('Pull source') {
          checkout scm // pull the git repo of the Jenkinsfile
                       //or: git url: 'https://github.com/bszeti/kubernetes-integration-test.git'
        }
        dir ("integration-test") { //In this example the integration test project is in a sub directory
            stage('Prepare test') {
                container('mariadb') {
                    //requires mysql tool
                    sh 'sql/setup.sh'
                }
                //requires curl and python
                sh 'mockserver/setup.sh'
                
            }

            //These env vars are used by the tests to send message to users.in queue
            withEnv(['AMQ_USER=test',
                     'AMQ_PASSWORD=secret']) {
                stage('Build and run test') {
                    try {
                        //Execute the integration test
                        sh 'mvn -s ../configuration/settings.xml -B clean test'
                    } finally {
                        //Save test results in Jenkins
                        junit 'target/surefire-reports/*.xml'
                    }
                }
            }
        }
    }
}

Pipeline 步骤在 jnlp 容器上运行,除非它们位于 container('container_name') 块中

  • 首先,我们检出集成项目的源代码。在本例中,它位于仓库中的 integration-test 子目录中。
  • sql/setup.sh 脚本在数据库中创建表并加载测试数据。它需要 mysql 工具,因此必须在 mariadb 容器中运行。
  • 我们的应用程序 (app-users) 调用 Rest API。我们没有镜像来启动此服务,因此我们使用 MockServer 来启动 HTTP 端点。它由 mockserver/setup.sh 配置。
  • 集成测试是用 Java 和 JUnit 编写的,并由 Maven 执行。它可以是任何其他东西——这只是我们熟悉的技术栈。

podTemplatecontainerTemplate 有很多配置参数,遵循 Kubernetes 资源 API,但有一些差异。例如,环境变量可以在容器级别以及 Pod 级别定义。可以将卷添加到 Pod,但它们在每个容器上以相同的 mountPath 挂载

podTemplate(...
  containers: [...], 
  volumes:[
      configMapVolume(mountPath: '/etc/myconfig', 
        configMapName: 'my-settings'),
      persistentVolumeClaim(mountPath: '/home/jenkins/myvolume', 
        claimName:'myclaim')
      ],
  envVars: [
     envVar(key: 'ENV_NAME', value: 'my-k8sit')
   ]
)

听起来很容易,但是…

在同一个 Pod 中运行多个容器是连接它们的好方法,但是如果我们的容器具有不同用户 ID 的入口点,我们可能会遇到一个问题。Docker 镜像过去常常以 root 身份运行进程,但由于安全问题,不建议在生产环境中使用,因此许多镜像切换到非 root 用户。不幸的是,不同的镜像可能会使用不同的 uid(Dockerfile 中的 USER),如果它们使用相同的卷,则可能会导致文件权限问题。

在这种情况下,冲突的根源是 workingDir 卷(/home/jenkins/workspace/)上的 Jenkins 工作区。这用于 Pipeline 执行以及在每个容器中保存步骤输出。如果我们在 container(…) 块中有步骤,并且此镜像中的 uidjnlp 容器中的 uid 不同(非 root),我们将收到以下错误

touch: cannot touch '/home/jenkins/workspace/k8sit-basic/integration-test@tmp/durable-aa8f5204/jenkins-log.txt': Permission denied

让我们看一下示例中镜像的 USER

jnlp 容器中的默认 umask0022,因此 uid 185uid 27 中的步骤将遇到权限问题。解决方法是更改 jnlp 容器中的默认 umask,以便任何 uid 都可以访问 workspace

containerTemplate(name: 'jnlp',
  image: 'registry.access.redhat.com/openshift3/jenkins-slave-maven-rhel7:v3.9',
  resourceLimitMemory: '512Mi',
  command: '/bin/sh -c',
  //change umask so any uid has permission to the jenkins workspace
  args: '"umask 0000; /usr/local/bin/run-jnlp-client ${computer.jnlpmac} ${computer.name}"',
  envVars: [
    envVar(key: 'JNLP_MAX_HEAP_UPPER_BOUND_MB', value: '64')
  ])

要查看首先构建应用程序和 Docker 镜像,然后在运行集成测试的完整 Jenkinsfile,请转到 kubernetes-integration-test/Jenkinsfile

在这些示例中,集成测试在 jnlp 容器上运行,因为我们为测试项目选择了 Java 和 Maven,并且 jenkins-slave-maven 镜像可以执行该操作。当然,这不是强制性的;我们可以使用 jenkins-slave-base 镜像作为 jnlp,并使用单独的容器来执行测试。请参阅 kubernetes-integration-test/Jenkinsfile-jnlp-base 示例,我们在其中有意分离 jnlp 并使用另一个容器进行 Maven

YAML 模板

podTemplate 和 containerTemplate 定义支持许多配置,但它们缺少一些参数。例如

  • 它们无法从 ConfigMap 分配环境变量,只能从 Secret 分配。
  • 它们无法为容器设置就绪探测。没有它们,Kubernetes 会在启动容器后立即报告 Pod 正在运行。Jenkins 将在进程准备好接受请求之前开始执行步骤。这可能会导致由于竞争条件而导致的失败。这些示例 Pipeline 通常可以工作,因为 checkout scm 给了容器足够的时间启动。当然,sleep 有帮助,但定义就绪探测才是正确的方法。

为了解决这个问题,在 kubernetes-plugin (v1.5+) 的 podTemplate() 中添加了一个 YAML 参数。它支持完整的 Kubernetes Pod 资源定义,因此我们可以为 Pod 定义任何配置

podTemplate(
  label: 'app-users-it',
  cloud: 'openshift',
  //yaml configuration inline. It's a yaml so indentation is important.
  yaml: '''
apiVersion: v1
kind: Pod
metadata:
  labels:
    test: app-users
spec:
  containers:
  #Java agent, test executor
  - name: jnlp
    image: registry.access.redhat.com/openshift3/jenkins-slave-maven-rhel7:v3.9
    command:
    - /bin/sh
    args:
    - -c
      #Note the args and syntax for run-jnlp-client
    - umask 0000; /usr/local/bin/run-jnlp-client $(JENKINS_SECRET) $(JENKINS_NAME)
    resources:
      limits:
        memory: 512Mi
  #App under test
  - name: app-users
    image: 172.30.1.1:5000/myproject/app-users:latest        
  ...
''',
  //volumes for example can be defined in the yaml our as parameter
  volumes:[...]
) {...}

确保将 Jenkins 中的 Kubernetes 插件更新到 v1.5+,否则 YAML 参数将被静默忽略。

YAML 定义和其他 podTemplate 参数应该以某种方式合并,但仅使用其中一个或另一个更不容易出错。如果在 Pipeline 中内联定义 YAML 难以阅读,请参阅 kubernetes-integration-test/Jenkinsfile-yaml,这是一个从文件加载 YAML 的示例。

声明式 Pipeline 语法

以上所有示例 Pipeline 都使用了脚本式 Pipeline 语法,这实际上是一个带有Pipeline 步骤的 Groovy 脚本。声明式 Pipeline 语法是一种新方法,它通过提供较小的灵活性和不允许任何“Groovy hack”来强制执行脚本的更多结构。它产生了更简洁的代码,但在复杂的情况下,您可能不得不切换回脚本式语法。

在声明式 Pipeline 中,kubernetes-plugin (v1.7+) 仅支持 YAML 定义来定义 Pod

pipeline {
  agent{
    kubernetes {
      label 'app-users-it'
      cloud 'openshift'
      defaultContainer 'jnlp'
      yaml '''
apiVersion: v1
kind: Pod
metadata:
  labels:
    app: app-users
spec:
  containers:
  #Java agent, test executor
  - name: jnlp
    image: registry.access.redhat.com/openshift3/jenkins-slave-maven-rhel7:v3.9
    command:
    - /bin/sh
    args:
    - -c
    - umask 0000; /usr/local/bin/run-jnlp-client $(JENKINS_SECRET) $(JENKINS_NAME)
    ...
'''
      }
    }
    stages {
        stage('Run integration test') {
            environment {
                AMQ_USER = 'test'
                AMQ_PASSWORD = 'secret'
            }
            steps {
                dir ("integration-test") {
                    container('mariadb') {
                        sh 'sql/setup.sh'
                    }
                    sh 'mockserver/setup.sh'

                    //Run the tests.
                    //Somehow simply "mvn ..." doesn't work here
                    sh '/bin/bash -c "mvn -s ../configuration/settings.xml -B clean test"'
                }
            }
            post {
                always {
                    junit testResults: 'integration-test/target/surefire-reports/*.xml', allowEmptyResults: true
                }
            }
        }
    }
}

为每个阶段设置不同的代理也是可能的,如 kubernetes-integration-test/Jenkinsfile-declarative 中所示。

在 Minishift 上尝试

如果您想尝试上面描述的解决方案,您将需要访问 Kubernetes 集群。在红帽,我们使用 OpenShift,它是企业就绪版本的 Kubernetes。有几种方法可以访问全规模集群

在本地机器上运行小型单节点集群也是可能的,这可能是尝试各种方法的最简单方法。让我们看看如何设置 Red Hat CDK(或 Minikube)来运行我们的测试。

下载 Red Hat CDK 后,准备 Minishift 环境

  • 运行设置:minishift setup-cdk
  • 将内部 Docker 注册表设置为不安全

    minishift config set insecure-registry 172.30.0.0/16 这是必需的,因为 kubernetes-plugin 直接从内部注册表拉取镜像,而内部注册表不是 HTTPS。
  • 启动 Minishift 虚拟机(使用您的免费 红帽帐户): minishift --username me@mymail.com --password ... --memory 4GB start
  • 记下控制台 URL(或者您可以通过输入以下内容获取它:minishift console --url
  • oc 工具添加到路径:eval $(minishift oc-env)
  • 登录到 OpenShift API (admin/admin)

    oc login https://192.168.42.84:8443

使用可用模板在集群中启动 Jenkins master

oc new-app --template=jenkins-persistent -p MEMORY_LIMIT=1024Mi

Jenkins 启动后,应该可以通过模板创建的路由访问它(例如,https://jenkins-myproject.192.168.42.84.nip.io)。登录与 OpenShift 集成 (admin/admin)。

创建一个新的 Pipeline 项目,该项目从 SCM 获取 Pipeline script,指向具有要执行的 JenkinsfileGit 仓库(例如,kubernetes-integration-test.git)。然后只需 Build Now

首次运行需要更长的时间,因为镜像从 Docker 注册表下载。如果一切顺利,我们可以在 Jenkins 构建的控制台输出中看到测试执行。动态创建的 Pod 可以在 OpenShift 控制台的我的项目 / Pod 下看到。

如果出现问题,请尝试通过查看以下内容进行调查

  • Jenkins 构建输出
  • Jenkins master Pod 日志
  • Jenkins kubernetes-plugin 配置
  • 创建的 Pod(Maven 或 integration-test)的事件
  • 创建的 Pod 的日志

如果您想加快后续执行速度,可以使用卷作为本地 Maven 仓库,这样 Maven 就不必每次都下载依赖项。创建一个 PersistentVolumeClaim

# oc create -f - <<EOF
kind: PersistentVolumeClaim
apiVersion: v1
metadata:
  name: mavenlocalrepo
spec:
  accessModes:
    - ReadWriteOnce
  resources:
    requests:
      storage: 10Gi
EOF

将卷添加到 podTemplate(以及 kubernetes-plugin 中的可选 Maven 模板)。请参阅 kubernetes-integration-test/Jenkinsfile-mavenlocalrepo

volumes: [ 
  persistentVolumeClaim( mountPath: '/home/jenkins/.m2/repository', 
    claimName: 'mavenlocalrepo') 
]

请注意,Maven 本地仓库声称是“非线程安全的”,不应同时被多个构建使用。我们在此处使用 ReadWriteOnce 声明,它一次将仅挂载到一个 Pod。

jenkins-2-rhel7:v3.9 镜像安装了 kubernetes-plugin v1.2。要运行 Jenkinsfile-declarativeJenkinsfile-yaml 示例,您需要在 Jenkins 中将插件更新到 v1.7+。

要完全清理停止 Minishift 后的内容,请删除 ~/.minishift 目录。

局限性

每个项目都不同,因此了解以下局限性和因素对您的案例的影响非常重要

  • 使用 jenkins-kubernetes-plugin 创建测试环境与集成测试本身无关。测试可以使用任何语言编写,并使用任何测试框架执行——这是一种强大的力量,但也是一种巨大的责任。
  • 整个测试 Pod 在测试执行之前创建,并在之后关闭。此处未提供在测试执行期间管理容器的解决方案。可以将测试拆分为具有不同 Pod 模板的不同阶段,但这会增加很多复杂性。
  • 容器在执行第一个 Pipeline 步骤之前启动。此时无法访问集成测试项目中的文件,因此我们无法为这些进程运行准备脚本或提供配置文件。
  • 所有容器都属于同一个 Pod,因此它们必须在同一个节点上运行。如果我们需要许多容器并且 Pod 需要太多资源,则可能没有可用于运行 Pod 的节点。
  • 集成测试环境的大小和规模应保持较低水平。虽然可以在一个 Pod 中启动多个微服务并运行端到端测试,但所需容器的数量可能会迅速增加。此环境也不适合测试高可用性和可伸缩性要求。
  • 测试 Pod 为每次执行重新创建,但容器的状态在其运行期间仍然保留。这意味着各个测试用例彼此不独立。如果需要,测试项目有责任在它们之间进行一些清理。

总结

使用 Jenkins Pipeline 和 kubernetes-plugin 在从代码动态创建的环境中运行集成测试相对容易。我们只需要一个 Kubernetes 集群和一些容器经验。幸运的是,越来越多的平台在公共注册表之一上提供官方 Docker 镜像。在最坏的情况下,我们必须自己构建一些。准备 Pipeline 和集成测试的努力很快就会得到回报,特别是如果您想在应用程序的生命周期中尝试不同的配置或依赖项版本升级。


本文最初发表在 Medium 上,经许可转载。

Balazs
开源爱好者,在 Java 环境中担任企业系统集成技术顾问。经常无法区分 API、服务架构、消息流和所有云原生世界中的“真正酷的东西”和“短期炒作”,但会继续尝试...

1 条评论

感谢分享。

© . All rights reserved.