1

CI/CD试图解决什么?

CI/CD是一个经常与其他术语(例如DevOps,Agile,Scrum和看板,自动化等)一起听到的术语。有时,它只是工作流的一部分而没有真正了解它是什么或为什么采用它。对于年轻的DevOps工程师来说,将CI/CD视为理所当然的事情很常见,他们可能还没有看到软件发布周期的“传统”方式,因此无法欣赏CI/CD。

CI/CD代表持续集成/持续交付或部署。未实现CI/CD的团队在创建新软件产品时必须经过以下阶段:

  • 产品经理(代表客户的利益)提供了产品应具备的必要功能以及产品应遵循的行为。该文档必须尽可能详尽和具体。
  • 具有业务分析师的开发人员通过编写代码,运行单元测试并将结果提交到版本控制系统(例如git)来开始处理应用程序。
  • 开发阶段完成后,该项目将移至质量检查。针对产品运行了一些测试,例如用户接受测试,集成测试,性能测试。在此期间,在QA阶段完成之前,不得对代码库进行任何更改。如果有任何错误,则会将其传给开发人员进行修复,然后将产品交给质量检查人员。
  • 完成质量检查后,操作团队会将代码部署到生产中。

上述工作流程有许多缺点:

  • 首先,从产品经理提出请求到产品准备生产为止,要花费很长时间。
  • 对于开发人员来说,解决一个月或更长时间以来已经编写的代码中的错误很困难。请记住,仅在开发阶段结束且质量检查阶段开始后才发现错误。
  • 当hotfix(例如需要修复程序的严重错误)时,由于需要尽快部署,因此QA阶段通常会缩短。
  • 由于不同团队之间几乎没有协作,因此人们会在出现错误时开始指责并互相指责。每个人开始只关心自己的项目部分,而忽略了共同的目标。

CI/CD通过引入自动化解决了上述问题。每次将代码更改推送到版本控制系统后,都将进行测试,然后进一步部署到生产/UAT环境中,以进行进一步测试,然后再将其部署到生产环境中供用户使用。自动化可确保整个过程快速,可靠,可重复,并且不易出错。

那么,什么是CI/CD?

我们总是更喜欢较少的理论,更多的实践。话虽如此,以下是对一旦执行代码更改即应执行的自动化步骤的简要说明:

  • 持续集成(CI):第一步不包括质量检查。换句话说,它不关注代码是否提供了客户端请求的功能。相反,它可以确保代码的质量。通过单元测试,集成测试,可以将任何代码质量问题迅速通知开发人员。我们可以通过代码覆盖率和静态分析来进一步扩展测试,从而进一步保证质量。
  • 用户验收测试:这是CD流程的第一部分。在此阶段,将对代码执行自动测试,以确保其满足客户的期望。例如,一个Web应用程序可以正常运行而不会引发任何错误,但是客户希望访问者在导航到主页之前,先找到登陆页面。当前代码将访问者直接带到主页,这与客户的需求有所不同。 UAT测试指出了此类问题。在非CD环境中,这是人工QA测试人员的工作。
  • 部署:这是CD流程的第二部分。它涉及对将托管应用程序的服务器/Pod/容器进行更改,以使其反映更新的版本。这应该以自动化方式完成,最好通过诸如Ansible,Chef或Puppet之类的配置管理工具来完成。

那什么是一个Pipeline?

Pipeline是一个非常简单的概念的幻想。当您需要以一定顺序执行多个脚本以实现共同目标时,这些脚本统称为“Pipeline”。例如,在Jenkins中,Pipeline可能包含一个或多个阶段,必须全部完成才能使构建成功。使用阶段有助于可视化整个过程,了解每个阶段需要花费多长时间,并确定构建在何处失败。

为Golang应用程序创建Pipeline

在此实验中,我们正在构建连续交付(CD)Pipeline。我们正在使用一个用Go编写的非常简单的应用程序。为了简单起见,我们将仅对代码运行一种类型的测试。该实验的前提条件如下:

  • 正在运行的Jenkins实例。这可能是云实例,虚拟机,裸机或Docker容器。它必须可以从Internet公开访问,以便存储库可以通过Webhook连接到Jenkins。
  • 镜像注册表:您可以使用Docker Registry,基于云的产品(如ECR或GCR),甚至可以使用自定义注册表。
  • GitHub上的帐户。尽管在此示例中我们使用GitHub,但是该过程可以与其他存储库(如Bitbucket)一样进行较小的更改。

Pipeline可以描述如下:

z01.jpg

Step 01: 应用文件

我们的示例应用程序将对任何GET请求做出“ Hello World”响应。创建一个名为main.go的新文件,并添加以下行:

package main

import (
   "log"
   "net/http"
)

type Server struct{}

func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
   w.WriteHeader(http.StatusOK)
   w.Header().Set("Content-Type", "application/json")
   w.Write([]byte(`{"message": "hello world"}`))
}

func main() {
   s := &Server{}
   http.Handle("/", s)
   log.Fatal(http.ListenAndServe(":8080", nil))
}

由于我们正在构建CD pipeline,因此我们应该进行一些测试。我们的代码非常简单,只需要一个测试用例即可。确保在点击根URL时收到正确的字符串。在同一目录中创建一个名为main_test.go的新文件,并添加以下行:

package main

import (
   "log"
   "net/http"
)

type Server struct{}

func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
   w.WriteHeader(http.StatusOK)
   w.Header().Set("Content-Type", "application/json")
   w.Write([]byte(`{"message": "hello world"}`))
}

func main() {
   s := &Server{}
   http.Handle("/", s)
   log.Fatal(http.ListenAndServe(":8080", nil))
}

我们还有其他一些文件可以帮助我们部署应用程序,这些文件名为:

Dockerfile:

FROM golang:alpine AS build-env
RUN mkdir /go/src/app && apk update && apk add git
ADD main.go /go/src/app/
WORKDIR /go/src/app
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -ldflags '-extldflags "-static"' -o app .

FROM scratch
WORKDIR /app
COPY --from=build-env /go/src/app/app .
ENTRYPOINT [ "./app" ]

Dockerfile是一个多阶段的程序,用于保持镜像大小尽可能小。它从基于golang:alpine的构建镜像开始。生成的二进制文件将用于第二个镜像,这只是一个临时镜像。暂存镜像不包含依赖项或库,仅包含启动应用程序的二进制文件。

Service:
由于我们使用Kubernetes作为托管此应用程序的平台,因此我们至少需要一项服务和一个部署。我们的service.yml文件如下所示:

apiVersion: v1
kind: Service
metadata:
  name: hello-svc
spec:
  selector:
    role: app
  ports:
    - protocol: TCP
      port: 80
      targetPort: 8080
      nodePort: 32000
  type: NodePort

这个定义没有什么特别的。只是使用NodePort作为其类型的服务。它将在任何群集节点的IP地址上的端口32000上进行侦听。传入的连接将中继到端口8080上的Pod。对于内部通信,服务将侦听端口80。

Deployment:

应用程序本身一旦进行了docker化,就可以通过Deployment资源部署到Kubernetes。 deploy.yml文件如下所示:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: hello-deployment
  labels:
    role: app
spec:
  replicas: 2
  selector:
    matchLabels:
      role: app
  template:
    metadata:
      labels:
        role: app
    spec:
      containers:
      - name: app
        image: ""
        resources:
          requests:
            cpu: 10m

关于此部署定义,最有趣的是镜像部分。我们不是使用硬编码镜像名称和标签,而是使用一个变量。稍后,我们将看到如何将该定义用作Ansible的模板,并通过命令行参数替换镜像名称(以及部署的任何其他参数)。

Playbook:

在本实验中,我们使用Ansible作为部署工具。还有许多其他方式来部署Kubernetes资源,包括Helm Charts,但我认为Ansible是一个更容易的选择。 Ansible使用playbook来组织其说明。我们的playbook.yml文件如下所示:

- hosts: localhost
  tasks:
  - name: Deploy the service
    k8s:
      state: present
      definition: ""
      validate_certs: no
      namespace: default
  - name: Deploy the application
    k8s:
      state: present
      validate_certs: no
      namespace: default
      definition: ""

Ansible已经包含用于处理与Kubernetes API服务器通信的k8s模块。因此,我们不需要安装kubectl,但是我们需要一个有效的kubeconfig文件来连接到集群(稍后会详细介绍)。让我们快速讨论一下该手册的重要部分:

  • 该playbook用于将服务和资源部署到群集。
  • 由于我们需要在执行时动态地将数据注入到定义文件中,因此我们需要将定义文件用作模板,从那里可以从外部提供变量。
  • 为此,Ansible具有查找功能,您可以在其中传递有效的YAML文件作为模板。 Ansible支持多种将变量注入模板的方法。在这个特定的实验中,我们使用命令行方法。

Step 02: 安装 Jenkins, Ansible, 和 Docker

让我们安装Ansible并使用它自动部署Jenkins服务器和Docker运行时环境。我们还需要安装openshift Python模块以启用与Kubernetes的Ansible连接。

Ansible的安装非常简单;只需安装Python并使用pip安装Ansible:

  • 登录Jenkins
  • 安装Python 3,Ansible和openshift模块

    sudo apt update && sudo apt install -y python3 && sudo apt install -y python3-pip && sudo pip3 install ansible && sudo pip3 install openshift
  • 默认情况下,pip将二进制文件安装在用户主文件夹中的隐藏目录下。我们需要将此目录添加到$PATH变量中,以便我们可以轻松地调用以下命令:

    echo "export PATH=$PATH:~/.local/bin" >> ~/.bashrc && . ~/.bashrc
  • 安装部署Jenkins实例所需的Ansible:

    ansible-galaxy install geerlingguy.jenkins
  • 安装docker

    ansible-galaxy install geerlingguy.docker
  • 创建一个playbook.yaml文件并添加以下行:
- hosts: localhost
  become: yes
  vars:
    jenkins_hostname: 35.238.224.64
    docker_users:
    - jenkins
  roles:
    - role: geerlingguy.jenkins
    - role: geerlingguy.docker
  • 通过以下命令运行playbook:ansible-playbook playbook.yaml。请注意,我们使用实例的公共IP地址作为Jenkins将使用的主机名。如果使用的是DNS,则可能需要用实例的DNS名称替换它。另外,请注意,在运行playbook之前,必须在防火墙上启用端口8080(如果有)。
  • 几分钟后,应安装Jenkins。您可以通过导航到计算机的IP地址(或DNS名称)并指定端口8080进行检查:

z02.jpg

  • 单击“登录”链接,并提供“ admin”作为用户名和“ admin”作为密码。请注意,这些是我们使用的Ansible角色设置的默认凭据。在生产环境中使用Jenkins时,您可以(并且应该)更改这些默认值。这可以通过设置角色变量来完成。您可以参考角色官方页面。
  • 您需要做的最后一件事是安装以下将在我们的实验中使用的插件:

    • git
    • pipeline
    • CloudBees Docker Build and Publish
    • GitHub

Step 03: 配置Jenkins用户连接到集群

如前所述,本实验假设您已经有一个Kubernetes集群启动并正在运行。为了使Jenkins连接到该集群,我们需要添加必要的kubeconfig文件。在此特定实验中,我们使用的是托管在Google Cloud上的Kubernetes集群,因此我们使用的是gcloud命令。您的具体情况可能有所不同。但是在所有情况下,我们都必须按照以下步骤将kubeconfig文件复制到Jenkins的用户目录中:

$ sudo cp ~/.kube/config ~jenkins/.kube/
$ sudo chown -R jenkins: ~jenkins/.kube/

请注意,您将在此处使用的帐户必须具有创建和管理“部署和服务”的必要权限。

Step 04: 创建Jenkins Pipeline 作业

z03.jpg

创建一个新的Jenkins作业,然后选择Pipeline类型。作业设置应如下所示:

z04.jpg

z05.jpg

我们更改的设置是:

  • 我们使用Poll SCM作为构建触发器;设置此选项将指示Jenkins定期(按 *指示的每一分钟)检查Git存储库。如果自上次轮询以来仓库已更改,则将触发作业。
  • 在Pipeline本身中,我们指定了存储库URL和凭据。分支是master。
  • 在本实验中,我们将作业的所有代码添加到Jenkins文件中,该文件与代码存储在同一存储库中。本文稍后将讨论Jenkinsfile。

Step 05: 为GitHub和Docker Hub配置Jenkins凭据

转到 /credentials/store/system/domain/_/newCredentials 并将凭据添加到两个目标。确保为每一个都提供有意义的ID和说明,因为稍后会引用它们:

z06.jpg

z07.jpg

Step 06: 创建JenkinsFile

Jenkinsfile指导Jenkins如何构建,测试,docker化,发布和交付我们的应用程序。我们的Jenkinsfile看起来像这样:

pipeline {
   agent any
   environment {
       registry = "magalixcorp/k8scicd"
       GOCACHE = "/tmp"
   }
   stages {
       stage('Build') {
           agent {
               docker {
                   image 'golang'
               }
           }
           steps {
               // Create our project directory.
               sh 'cd ${GOPATH}/src'
               sh 'mkdir -p ${GOPATH}/src/hello-world'
               // Copy all files in our Jenkins workspace to our project directory.               
               sh 'cp -r ${WORKSPACE}/* ${GOPATH}/src/hello-world'
               // Build the app.
               sh 'go build'              
           }    
       }
       stage('Test') {
           agent {
               docker {
                   image 'golang'
               }
           }
           steps {                
               // Create our project directory.
               sh 'cd ${GOPATH}/src'
               sh 'mkdir -p ${GOPATH}/src/hello-world'
               // Copy all files in our Jenkins workspace to our project directory.               
               sh 'cp -r ${WORKSPACE}/* ${GOPATH}/src/hello-world'
               // Remove cached test results.
               sh 'go clean -cache'
               // Run Unit Tests.
               sh 'go test ./... -v -short'           
           }
       }
       stage('Publish') {
           environment {
               registryCredential = 'dockerhub'
           }
           steps{
               script {
                   def appimage = docker.build registry + ":$BUILD_NUMBER"
                   docker.withRegistry( '', registryCredential ) {
                       appimage.push()
                       appimage.push('latest')
                   }
               }
           }
       }
       stage ('Deploy') {
           steps {
               script{
                   def image_id = registry + ":$BUILD_NUMBER"
                   sh "ansible-playbook  playbook.yml --extra-vars \"image_id=${image_id}\""
               }
           }
       }
   }
}

该文件比看起来容易。pipeline基本上包含四个阶段:

  • 构建是我们构建Go二进制文件的地方,并确保在构建过程中没有错误。
  • 测试是我们应用简单的UAT测试以确保应用程序按预期工作的地方。
  • 发布,构建Docker镜像并将其推送到注册表。之后,任何环境都可以使用它。
  • 部署,这是调用Ansible与Kubernetes联系并应用定义文件的最后一步。

现在,让我们讨论这个Jenkinsfile的重要部分:

  • 前两个阶段大致相似。他们俩都使用golang Docker镜像来构建/测试应用程序。让阶段在已包含所有必要构建和测试工具的Docker容器中运行始终是一个好习惯。另一种选择是在主服务器或从服务器之一上安装这些工具。当您需要针对不同的工具版本进行测试时,就会出现问题。例如,也许我们想使用Go 1.9来构建和测试代码,因为我们的应用程序尚未准备好使用最新的Golang版本。镜像中包含所有内容,因此更改版本甚至镜像类型就像更改字符串一样简单。
  • Publish阶段(从第42行开始)首先指定一个环境变量,该变量将在以后的步骤中使用。该变量指向我们在先前步骤中添加到Jenkins的Docker Hub凭据的ID。
  • 第48行:我们使用docker插件构建镜像。默认情况下,它在我们的注册表中使用Dockerfile,并将内部版本号添加为镜像标签。稍后,当您需要确定哪个Jenkins构建是当前运行的容器的来源时,这将非常重要。
  • 第49-51行:成功构建镜像后,我们使用内部版本号将其推送到Docker Hub。此外,我们在镜像上添加了“最新”标签(第二个标签),以便我们允许用户在需要的情况下无需指定内部版本号即可拉取镜像。
  • 第56-60行:在部署阶段,我们将部署和服务定义文件应用到集群。我们使用前面讨论的剧本调用Ansible。请注意,我们将image_id作为命令行变量传递。该值将自动替换部署文件中的镜像名称。

测试我们的CD Pipeline

本文的最后一部分是我们实际对我们的工作进行测试的地方。我们将代码提交到GitHub,并确保我们的代码在pipeline中移动到达集群:

  • 添加我们的文件:git add *
  • 提交更改:git commit -m“初始提交”
  • 推送到GitHub:git push
  • 在Jenkins上,我们可以等待作业自动触发,也可以单击“立即构建”
  • 如果作业成功,我们可以使用以下命令检查已部署的应用程序:

获取节点的IP地址:

kubectl get nodes -o wide
NAME                                          STATUS   ROLES    AGE   VERSION          INTERNAL-IP   EXTERNAL-IP     OS-IMAGE                             KERNEL-VERSION   CONTAINER-RUNTIME
gke-security-lab-default-pool-46f98c95-qsdj   Ready       7d    v1.13.11-gke.9   10.128.0.59   35.193.211.74   Container-Optimized OS from Google   4.14.145+        docker://18.9.7

现在,让我们向应用程序发起HTTP请求:

$ curl 35.193.211.74:32000
{"message": "hello world"}

好的,我们可以看到我们的应用程序运行正常。让我们故意在代码中犯一个错误,并确保管道不会将错误的代码发送到目标环境:

将应显示的消息更改为“ Hello World!”,请注意,我们将每个单词的首字母大写,并在末尾添加了感叹号。由于我们的客户可能不希望该消息以这种方式显示,因此管道应在测试阶段停止。

首先,让我们进行更改。现在,main.go文件应如下所示:

package main

import (
   "log"
   "net/http"
)

type Server struct{}

func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
   w.WriteHeader(http.StatusOK)
   w.Header().Set("Content-Type", "application/json")
   w.Write([]byte(`{"message": "Hello World!"}`))
}

func main() {
   s := &Server{}
   http.Handle("/", s)
   log.Fatal(http.ListenAndServe(":8080", nil))
}

接下来,提交并推送我们的代码:

$ git add main.go
$ git commit -m "Changes the greeting message"                                                                                                       
[master 24a310e] Changes the greeting message
 1 file changed, 1 insertion(+), 1 deletion(-)
$ git push
Counting objects: 3, done.
Delta compression using up to 4 threads.
Compressing objects: 100% (3/3), done.
Writing objects: 100% (3/3), 319 bytes | 319.00 KiB/s, done.
Total 3 (delta 2), reused 0 (delta 0)
remote: Resolving deltas: 100% (2/2), completed with 2 local objects.
To https://github.com/MagalixCorp/k8scicd.git
   7954e03..24a310e  master -> master

回到Jenkins,我们可以看到上一次构建失败了:

z08.jpg

通过单击失败的作业,我们可以看到其失败的原因:

z09.jpg

我们的错误代码将永远不会进入目标环境。


iyacontrol
1.4k 声望2.7k 粉丝

专注kubernetes,devops,aiops,service mesh。