CI/CD 尝试解决什么问题?
CI/CD 同 DevOps、Agile、Scrum、Kanban、自动化以及其他术语一样,是一个一起被经常提及的专用术语。有时候,它被当做工作流的一部分,但是并没有搞清楚这是什么或者为什么它会被采用。对于年轻的 DevOps 工程师来说,使用 CI/CD 理所当然已经成为了常态,可能他们并没有看到“传统”的软件发布流程而因此不欣赏 CI/CD。
CI/CD 表示持续集成/持续交付和/或部署。如果一个团队不接入 CI/CD 流程就必须要在产生一个新的软件产品时经历如下的阶段:
上述工作流存在一些弊端:
CI/CD 通过引入自动化来解决上述的问题。代码中的每次改动一旦推送至版本控制系统,进行测试,然后在部署到用户使用的生产环境之前部署至预生产/UAT 环境进行进一步的测试。自动化确保了整体流程的快速,可信赖,可重复,以及不容易出错。
所以,什么是 CI/CD 呢?
关于这个主题已经有著作撰写完毕。如何,为什么,以及什么时候在你的架构中使用。然而,我们总是倾向于轻理论重实践。话虽如此,下文简单介绍了一下一旦修改的代码被提交后会执行哪些自动化步骤:
流水线是一个有着简单的概念的花哨术语;当你有一些需要按照顺序依次执行的脚本用来实现一个共同的目标时,这些组合起来可以称为“流水线”。比如说,在 Jenkins 里,一个流水线包含了一个或多个一次构建需要成功必须全部执行的 stages 。使用 stages 能够可视化整个流程,能够看到每个阶段使用了多长时间,然后能够准确得出构建过程的哪个地方是失败的。
在这个实验中,我们构建一个持续交付(CD)的流水线。我们使用一个用 Go 语言编写的简单的小程序。为了简单起见,我们只对代码运行一种类型的测试。实验的前期工作如下:
流水线可以用下图做一个说明:
我们的实验程序会对任意的 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 流水线时,我们应该进行一些测试。我们代码是如此的简单以至于它仅仅只需要一个测试用例;能够确保我们在输入根 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" ]
Dcokerfile 是一个多阶段的文件能让镜像保持的越小越好。它从基于 golang:alpine 构建镜像开始。生成的二进制文件在第二个镜像中使用,它仅仅是一个临时的镜像,这个镜像没有依赖或者库文件,只有用来启动应用的二进制文件。
Service
由于我们使用 Kubernetes 作为托管该应用程序的平台,我们需要至少一个 service 和一个 deployment。我们的 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 作为其类型的 Service。它会监听任何 IP 地址的集群节点上的 32000 端口。传入的连接将中继到 8080 端口上。而作为内部通信,这个服务在 80 端口上进行监听。
deployment
应用程序本身,一旦容器化了,就可以通过一个 Deployment 资源部署到 Kubernetes。deployment.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
这个部署文件里的定义最有意思的地方就是 image 部分。不同于硬编码镜像名称和标签的方式,我们使用了一个变量。后面的内容,我们会看到怎样将该变量用作 Ansible 的模板以及通过命令替换镜像名称(以及部署用的其他参数)。
Playbook
这个实验中,我们使用 Ansible 作为部署工具。还有许多其他的方式用来部署 Kubernetes 资源包括 Helm Charts,但是我认为 Ansible 是一个相对简单一些的选择。Ansible 使用 playbooks 来组织它的操作。我们的 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 已经包括了 k8s 模块用来处理和 Kubernetes API 服务器的通信。所以我们不需要安装 kubectl 但是我们需要一个有效的 kubeconfig 文件来连接到集群(后面会详细介绍)。让我们快速讨论一下这个 playbook 重要的部分:
学习怎样持续优化您的 k8s 集群
让我们开始安装 Ansible 然后使用它自动部署一个 Jenkins 服务器以及 Docker 运行环境。我们同样需要安装 openshift Python 模块用来将 Ansible 连接到 Kubernetes。Ansible 的安装非常简单;只需要安装 Python 然后使用 pip 安装 Ansible:
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
安装必要的 Ansible 角色用来部署一个 Jenkins 实例。
ansible-galaxy install geerlingguy.jenkins
安装 Dcoker 角色:
ansible-galaxy install geerlingguy.docker
创建一个 playbook.yml 添加下面的代码:
- 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访问到 Jenkins:
点击登录链接使用 “admin” 作为用户名,“admin” 作为登录密码。这些都是通过 Ansible 角色创建的默认凭据。当 Jenkins 在生产环境中使用时,你可以(应该)修改这些默认值。这个可以通过设置角色变量来进行设置。你可以参考角色官方页面。
你需要做的最后一件事情就是安装下面这些我们这个实验中用到的插件:
之前我们提到了,这个实验假设你已经有一个启动的 Kubernetes 集群。为了让 Jenkins 连接到这个集群上,我们需要添加必要的 kubeconfig 文件。在这个特定的实验中,我们使用主机在 Google Cloud 的 Kubernetes 集群所以我们可以使用 gcloud command。因环境而异。但是不管什么情况,我们都必须拷贝 kubeconfig 文件到 Jenkins 的用户目录下,如下所示:
$ sudo cp ~/.kube/config ~jenkins/.kube/
$ sudo chown -R jenkins: ~jenkins/.kube/
需要记住的是你使用的账号必须要有必要的权限用来创建管理 Deployment 和 Service。
创建一个新的 Jenkins 任务选择流水线类型的任务。任务的设置如下图所示:
我们修改的配置有:
转到 /credentials/store/system/domain/_/newCredentials 链接下然后添加目标凭据。请确认你每个凭据均提供一个有意义的 ID 和描述信息因为你会在后面使用到它们。
Jenkinsfile 是用来指导 Jenkins 如何构建,测试,容器化,发布以及交付我们的应用程序的文件。我们的 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}""
}
}
}
}
}
这个文件比它本身看起来要简单的多。基本上,这个流水线包括了 4 个阶段:
现在,让我们讨论下这个 Jenkinsfile 中重要的部分:
了解更多关于配置模式的知识
测试我们的 CD 流水线
这部分是真正将我们的工作进行测试的内容。我们将会提交代码到 GitHub 上确保我们的代码直到到达集群之前都是经过流水线操作的。
获取节点的 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"}
OK,我们可以看到应用程序工作正常。让我们在代码中故意制造一个错误以确保流水线不会将错误的代码应用到目标环境中:
将应显示的信息修改为“Hello World!”,注意到我们将每个单词的首字母大写并在末尾添加了一个感叹号。然而客户或许不想让信息这样显示,流水线应该在 Test 阶段停止。
首先,让我们做一些改动。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,我们可以看到最后一次构建失败了:
点击失败的任务,我们可以看到这个任务失败的原因:
这样我们错误的代码永远不会进入到目标环境上。
内容提要
查看文中链接,请点击
译者:s1mple_zj