使用 Go、Docker、Minikube 和 Bash 脚本构建用于本地测试的简单 CI/CD 管道
瓦
听起来很酷,但为什么呢?
瓦
在本文中,我将详细介绍我编写的自动构建和部署管道的思考过程和实现,以减少获取对代码库所做的代码更改的反馈所需的时间。
大部分代码是用 Go 和 Bash 编写的。虽然实现起来有点 hacky,但编写和使用起来非常有趣。
免责声明:这篇文章的代码量很大,但我试图强调代码背后的思考过程。
您可以在这里找到整个帖子的代码cicdexample
听起来很酷,但为什么呢?
反馈很重要
对我来说,编写任何类型的代码时最重要的部分是我能多快得到反馈。
今年早些时候,我开始利用业余时间学习 Go,在学习期间,我想将我的应用程序代码 Docker 化(我正在用 Go 编写我的第一个 Web 应用程序),并最终将其托管在 Heroku 上。
这就是我这几天所做的事情:
总的来说,我在
“生产”环境中验证新功能大约花了10分钟。
对于日常工作来说,这不算太长的周转时间,但对于一个我很喜欢的项目来说,这真的没那么有趣。
上述流程的另一个缺点是,当我没有稳定的互联网连接时,很难测试功能,这种情况经常发生。所以我开始思考替代方案。
我希望在ctrl-s
测试更改之间有最小的延迟 - 就像这张图一样:
环境一致性权衡
我意识到在 Heroku 上测试并不是我开发流程的硬性要求。但我确实希望我的测试环境与我的“生产”环境有一些相似之处。
我开始勾勒出一个可以在我的笔记本电脑上运行的简单 CI/CD 管道,这将允许我在类似的云基础架构上测试在 Docker 中运行的应用程序。
顺便说一句,我知道我可能会写一些已经存在的东西,但我也知道这将是一个有趣的练习和学习经历。
简单的开始
你需要什么?
- Go(本文适用任何版本)
- 一些 Bash 知识和 Unix 终端
- 迷你库贝
- Kubectl
-
Docker
-
IDE 或文本编辑器(在本文中我交替使用 Vim 和 VSCode)
-
Linux(我正在使用的发行版是 Ubuntu 18.04 LTS)
我们从哪里开始?
我有使用 Jenkins 等构建工具的一些经验,也有运行与自动部署工具绑定的自动构建管道的一些经验。
因此,我运用从这些工具中获得的想法和背景。我先从简单的开始,暂时忽略了 Heroku,编写了一个简单的 Shell 脚本来构建我的 Go 代码库,然后基于命令行参数构建并标记 Docker 镜像。
我扩展了 Shell 脚本,使其包含kubectl
在构建 docker 镜像后部署到 Minikube 的命令。
我意识到,既然我有了这个 Shell 脚本,我实际上就为我的应用程序创建了一个部署和构建作业。于是我开始编写一个 Go 应用,以便根据触发器自动运行这个脚本。
你到底是怎么做到的?
为了说明我如何做到这一点,而又不让这篇文章因过多的技术细节而变得复杂,我创建了一个可供遵循的小清单,本文将对此进行扩展:
a. 创建一个小型 Go 应用程序来服务于 HTML 页面
b. 将其 Docker 化!
c. 编写一个简单的 Bash 脚本来构建 Docker 镜像
d. 将 Docker 镜像推送到 Minikube 并查看其运行
e. 编写一个小型命令行工具来自动运行该脚本
创建测试应用程序
让我们创建一个示例 Go 应用程序,它启动一个简单的文件服务器并在 '/' 上提供一个简单的 HTTP 页面(使用Bulma CSS使其看起来更好一些)
Web 服务器如下所示:
package main
import (
"log"
"net/http"
"os"
)
func main() {
// for heroku since we have to use the assigned port for the app
port := os.Getenv("PORT")
if port == "" {
// if we are running on minikube or just running the bin
defaultPort := "3000"
log.Println("no env var set for port, defaulting to " + defaultPort)
// serve the contents of the static folder on /
http.Handle("/", http.FileServer(http.Dir("./static")))
http.ListenAndServe(":" + defaultPort, nil)
} else {
http.Handle("/", http.FileServer(http.Dir("./static")))
log.Println("starting server on port " + port)
http.ListenAndServe(":" + port, nil)
}
}
html 文件/static
非常简单:
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>CI/CD Example</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@0.8.0/css/bulma.min.css">
</head>
<body>
<div style="text-align: center" class="container">
<h1 class="title is-1">Example CI/CD Stuff</h1>
<p>Changes should make this automatically redeploy to our local test environment</p>
</div>
</body>
</html>
太好了,现在如果我们构建(go build
)这个 Go App 并运行它,我们应该能够看到以下内容localhost:3000
集装箱化
我们现在有了一个应用,它很酷,能做很多事(其实没什么用),但这不关我们的事。我们专注于如何让这个小家伙自己构建。首先,我们需要将它 Docker 化。
如果您尚未安装 Docker,请在此处查看其文档(https://docs.docker.com/install/)
让我们创建以下简单的两阶段 Dockerfile:
# Stage 1
FROM golang:alpine as builder
RUN apk update && apk add --no-cache git
RUN mkdir /build
ADD . /build/
WORKDIR /build
RUN go get -d -v
RUN go build -o cicdexample .
# Stage 2
FROM alpine
RUN adduser -S -D -H -h /app appuser
USER appuser
COPY --from=builder /build/ /app/
WORKDIR /app
CMD ["./cicdexample"]
这个 Dockerfile 首先创建一个构建器镜像,并将本地目录的所有内容复制到/build
镜像上的一个名为 的目录中。然后,它会获取我们这个小应用程序的依赖项,并进行构建,生成一个名为cicdexample
第二阶段实际上创建了我们最终要运行的镜像。我们使用基础 Alpine 镜像,并创建一个名为 的用户,appuser
该用户使用名为 的目录/app
作为其主目录。然后,我们将构建器镜像中目录
的内容复制到Alpine 镜像的目录中,将工作目录设置为 ,并运行我们刚刚复制的 Go 二进制文件。需要注意的是,我们复制了整个目录的内容,因为我们需要静态资源目录,而它不包含在 Go 二进制文件中。/build
/app
/app
/build
一旦我们创建了 Dockerfile 并成功运行,就会docker build -t example:test .
产生以下结果(输出略有修剪):
Sending build context to Docker daemon 7.638MB
...
Successfully built 561ee4597a93
Successfully tagged example:test
我们已准备好继续前进。
打破现状
接下来,我们需要一个脚本来自动构建 Docker 镜像。但我们不仅仅想用脚本构建镜像,还想创建一个反馈循环,go build
这样当我们的代码甚至无法编译时,我们就不会浪费时间去设置 Docker 镜像。
从技术上讲,我们不必从结果中标记成功go build
,而是将错误输出重定向到文件,然后我们可以检查其中的任何内容,如果stderr
由于go build
该脚本的逻辑步骤最好以图形方式表示:
脚本如下(包含良好的时间戳和命令行输出):
#!/bin/bash
# Timestamp Function
timestamp() {
date +"%T"
}
# Temporary file for stderr redirects
tmpfile=$(mktemp)
# Go build
build () {
echo "⏲️ $(timestamp): started build script..."
echo "🏗️ $(timestamp): building cicdexample"
go build 2>tmpfile
if [ -s tmpfile ]; then
cat tmpfile
echo "❌ $(timestamp): compilation error, exiting"
rm tmpfile
exit 0
fi
}
# Deploy to Minikube using kubectl
deploy() {
echo "🌧️ $(timestamp): deploying to Minikube"
kubectl apply -f deploy.yml
}
# Orchestrate
echo "🤖 Welcome to the Builder v0.2, written by github.com/cishiv"
if [[ $1 = "build" ]]; then
if [[ $2 = "docker" ]]; then
build
buildDocker
echo "✔️ $(timestamp): complete."
echo "👋 $(timestamp): exiting..."
elif [[ $2 = "bin" ]]; then
build
echo "✔️ $(timestamp): complete."
echo "👋 $(timestamp): exiting..."
else
echo "🤔 $(timestamp): missing build argument"
fi
else
if [[ $1 = "--help" ]]; then
echo "build - start a build to produce artifacts"
echo " docker - produces docker images"
echo " bin - produces executable binaries"
else
echo "🤔 $(timestamp): no arguments passed, type --help for a list of arguments"
fi
fi
使用不同的参数运行它,我们得到以下结果:
./build build bin
(编译失败)
🤖 Welcome to the Builder builder v0.2, written by github.com/cishiv
⏲️ 16:40:47: started build script...
🏗️ 16:40:47: building cicdexample
# _/home/shiv/Work/dev/go/cicdexample
./main.go:25:1: syntax error: non-declaration statement outside function body
❌ 16:40:47: compilation error, exiting
./build build docker
(稍微修剪一下输出)
🤖 Welcome to the Builder builder v0.2, written by github.com/cishiv
⏲️ 16:40:05: started build script...
🏗️ 16:40:05: building cicdexample
🐋 16:40:06: building image example:test
Sending build context to Docker daemon 7.639MB
...
Successfully tagged example:test
✔️ 16:40:06: complete.
👋 16:40:06: exiting...
这看起来很合理,我们可以用一个命令编译我们的代码并构建一个docker镜像。
在进行下一步之前,让我们先看看目前的清单:
a. 创建一个小型 Go 应用来提供 HTML 页面 ✔️
b. 将其 Docker 化!✔️
c. 编写一个简单的 Bash 脚本来构建 Docker 镜像 ✔️
d. 将 Docker 镜像推送到 Minikube 并查看其运行情况
e. 编写一个小型命令行工具来自动运行该脚本
Minikube 化
我们最终对构建管道进行了某种形式的自动化,但它并没有真正给我们提供一种测试应用程序的方法。
输入 Minikube 来拯救这一天(某种程度上)。
我们希望我们的测试流程如下:
如果您尚未安装 Minikube,您可以查看此处的文档(https://kubernetes.io/docs/tasks/tools/install-minikube/)了解如何启动和运行它。
您还需要获取kubectl。
minikube start
Minikube 安装完成后,运行即可启动单节点集群,非常简单。
下一步是为我们的应用程序设置 Kubernetes 部署,以便我们可以将其推送到 Minikube 集群。
由于这不是一篇关于 Kubernetes 的文章,所以我将这一步简化。我们需要一个deploy.yml
文件,告诉 Kubernetes 为我们的应用程序创建一个部署和一个服务。如果分别使用单独的文件来创建部署和服务会更好,但在本例中,我们每次需要重新部署时都只需要重新创建这两个文件即可。
所以我们需要以下文件。
apiVersion: apps/v1
kind: Deployment
metadata:
name: example
spec:
selector:
matchLabels:
app: example
tier: example
track: stable
template:
metadata:
labels:
app: example
tier: example
track: stable
spec:
containers:
- name: example
image: "example:test"
ports:
- name: http
containerPort: 3000
---
apiVersion: v1
kind: Service
metadata:
name: example
spec:
type: NodePort
selector:
app: example
tier: example
ports:
- protocol: TCP
port: 3000
targetPort: http
nodePort: 30000
我们告诉 kubernetes 创建一个名为 的服务,example
该服务在 NodePort 30000 上公开(以便我们可以通过每次重新创建服务时不会改变的 URL 访问它),并在容器中的端口 3000 上运行一个应用程序。
此时,由于我们已经在部署描述符中指定了 NodePort,我们应该能够简单地刷新我们的网页并查看我们的更改。
要将应用程序部署到集群中,请在终端中运行以下命令:
./build build docker && kubectl apply -f deploy.yml
该应用程序现在应该在 Minikube 集群上运行并在 NodePort 上公开。
要获取应用程序的 URL,请运行以下命令:
minikube service example --url
您应该获得类似这样的 URL:
http://10.0.0.101:30000
example
是我们在文件中指定的服务名称deploy.yml
。
如果我们导航到我们的 URL,我们应该会看到我们的应用程序。
我们现在可以第一次测试我们的管道。
更改代码index.html
并运行:
./build build docker && kubectl apply -f deploy.yml
你可能会发现这实际上还不起作用。kubectl 命令输出:
deployment.apps/example unchanged
service/example unchanged
确实如此,因为我们每次创建 Docker 镜像时都不会使用不同的标签——Kubernetes 实际上无法识别我们的代码已更改。由于我们是在本地测试,因此有一个快速的解决方法,将命令修改为以下内容:
./build build docker && kubectl delete deployment example && kubectl delete service example && kubectl apply -f deploy.yml
这样,每次重新运行脚本时,我们都能干净地重新创建服务和部署。需要注意的是,这很大程度上是一种 hack 行为,更好的方法是每次生成 Docker 镜像时都使用不同的标签,然后deploy.yml
用正确的 Docker 镜像标签更新文件。
因此运行:
./build build docker && kubectl delete deployment example && kubectl delete service example && kubectl apply -f deploy.yml
将允许我们看到对 HTML 所做的更改。
这看起来似乎合理,但是为了稍微清理一下,让我们将额外的kubectl
命令添加到我们的 Bash 脚本中。
通过添加以下函数并稍微改变条件逻辑,允许将deploy
参数传递给./build build ...
命令,这很简单:
功能:
# Deploy to Minikube using kubectl
deploy() {
echo "🌧️ $(timestamp): deploying to Minikube"
kubectl delete deployment example
kubectl delete service example
kubectl apply -f deploy.yml
}
条件逻辑:
if [[ $1 = "build" ]]; then
if [[ $2 = "docker" ]]; then
if [[ $3 = "deploy" ]]; then
build
buildDocker
deploy
else
build
buildDocker
fi
echo "✔️ $(timestamp): complete."
echo "👋 $(timestamp): exiting..."
我们现在可以运行:./build build docker deploy
从编译到部署,轻松驱动代码更改!(周转时间约为 1 秒)
最终章:真正的自动化
最后,我们希望将我们创建的 Bash 脚本包装到专门构建的 Go 应用程序中,以自动执行此过程。
我们必须确定构建管道的触发器。目前有两个选项:
- 构建是根据时间流逝和文件更改的组合来触发的。
- 提交到 git 存储库时触发构建
为了在没有太多技术开销的情况下说明这个概念,我们将采用第一个选项
这基本上就是我们想要写的内容:
我不会在这里粘贴整个源代码,但是我会讨论与它有关的一些关键点(完整的代码可以在这里找到)
我们需要解决5个问题:
- 我们将对文件使用什么类型的哈希值?
- 哈希值多久重新计算一次?
- 我们如何设置间隔轮询?
- 我们如何在 Go 应用程序的上下文中运行我们的脚本?
- 存在哪些竞争条件?
第一个问题在 Go 中得到了相当简洁的解决,我们只需使用以下代码片段即可计算文件的 sha256 哈希值:
func CalculateHash(absoluteFilePath string) string {
f, err := os.Open(absoluteFilePath)
HandleError(err)
defer f.Close()
h := sha256.New()
if _, err := io.Copy(h, f); err != nil {
log.Fatal(err)
}
return hex.EncodeToString(h.Sum(nil))
}
第二个问题有一个非平凡的答案,它特定于您的用例,但是每 15 秒左右重新计算一次哈希值应该是足够合理的 - 这意味着如果该窗口中的代码发生变化,我们应该每 15 秒运行一次自动部署。
我对在 Go 应用程序中运行构建的实际时间进行了几个基准测试,以便我们可以对轮询文件更改的频率做出有根据的猜测。
我用来运行基准测试的代码片段如下:
func stopwatch(start time.Time, name string) {
elapsed := time.Since(start)
log.Printf("%s took %s", name, elapsed)
}
defer stopwatch(time.Now(), "benchmark")
只需在您想要基准测试的函数顶部添加即可!
基准测试之后,我估算了一下,我们需要预留 7 秒来完成之前的构建,以及 8 秒的额外开销以应对意外情况。这样一来,总共需要 15 秒。
问题 3 可以通过定义以下函数并按图示使用它来解决:
package main
import (
"fmt"
"time"
)
func main() {
go DoEvery(10*time.Second, f, "test")
for {}
}
func DoEvery(d time.Duration, f func(time.Time, string), action string) {
for x := range time.Tick(d) {
f(x, action)
}
}
func f(t time.Time, action string) {
fmt.Println(action)
}
我们只需创建一个每 X 秒调用一次的函数。
第四个问题在Go中也有一个比较简单有效的解决方案,我们可以定义一个动作,然后使用Go中的包string
来运行它。os/exec
package main
import (
"bytes"
"log"
"os/exec"
)
func main() {
runAction("./build build docker deploy")
}
func runAction(action string) {
log.Println("Taking action, running: " + action)
cmd := exec.Command("/bin/sh", "-c", action)
var outb, errb bytes.Buffer
cmd.Stdout = &outb
cmd.Stderr = &errb
err := cmd.Run()
if err != nil {
log.Printf("error")
}
log.Println(outb.String())
log.Println(errb.String())
最后一个问题很重要,一个明显的竞争条件是,如果我们监控目录中所有文件的哈希值,那么我们很可能会陷入某种递归构建循环,因为我们正在主动更改我们监控的文件(通过生成二进制文件)。我们可以借用git
这里的一个概念,实现一个whitelist
,即一个在哈希计算中要忽略的文件列表。与此相关的一些事情,
var whiteList []string
func CreateWhiteList() {
file, err := os.Open("./.ignore")
if err != nil {
log.Println("no .ignore file found, race condition will ensue if jobs edit files -- will not create whitelist")
} else {
defer file.Close()
scanner := bufio.NewScanner(file)
for scanner.Scan() {
log.Println(scanner.Text())
whiteList = append(whiteList, scanner.Text())
}
if err := scanner.Err(); err != nil {
log.Fatal(err)
}
}
}
结合这些问题的解决方案,我们能够产生这个)
使用构建构建器go build -o pipeline
将生成一个名为的二进制文件pipeline
。
然后,我们可以将这个二进制文件移动到我们的工作目录中。我们还需要在工作目录中创建一个ignore
文件,以忽略构建器为我们的应用程序生成的二进制文件。
该ignore
文件很简单:
pipeline
cicdexample
我们.git
通过编程将其忽略,因为这不是我们想要包含的东西。
我们终于可以运行我们的构建器了
./pipeline
对我们的应用程序进行更改index.html
,应该启动自动构建和重新部署,正如我们打算做的那样。
从这个输出中你可以看到:
shiv@shiv-Lenovo-ideapad-310-15IKB:~/Work/dev/go/cicdexample$ sudo ./pipeline
2019/12/24 18:23:42 map[]
2019/12/24 18:23:42 creating whitelist
2019/12/24 18:23:42 pipeline
2019/12/24 18:23:42 cicdexample
2019/12/24 18:23:42 builder
2019/12/24 18:23:42 building registry
2019/12/24 18:23:42 starting directory scan
2019/12/24 18:23:42 pipeline is whitelisted, not adding to registry
2019/12/24 18:23:42 computing hashes & creating map entries
2019/12/24 18:23:57 verifying hashes
2019/12/24 18:24:12 verifying hashes
2019/12/24 18:24:12 ./static/index.html old hashbebc0fe5b73e2217e1e61def2978c4d65b0ffc15ce2d4f36cf6ab6ca1b519c17new hash16af318df74a774939db922bcb4458a695b9a38ecf28f9ea573b91680771eb3achanged detected - updating hash, action required
2019/12/24 18:24:12 Taking action, running: ./build build docker deploy
2019/12/24 18:24:20 🤖 Welcome to the Builder builder v0.2, written by github.com/cishiv
⏲️ 18:24:12: started build script...
🏗️ 18:24:12: building cicdexample
🐋 18:24:13: building image example:test
Sending build context to Docker daemon 10.11MB
...
Successfully tagged example:test
🌧️ 18:24:19: deploying to Minikube
deployment.apps "example" deleted
service "example" deleted
deployment.apps/example created
service/example created
✔️ 18:24:20: complete.
👋 18:24:20: exiting...
2019/12/24 18:24:20
2019/12/24 18:24:20 --------------------------------------------------------------------------------
2019/12/24 18:24:27 verifying hashes
就这样!大功告成。我们成功编写了一个实用的(尽管简单)CI/CD 流水线,以改进我们的开发流程。
这次经历给我们带来了什么启示?
尖端
对于这种流水线,使用现有的解决方案可能很诱人,但我的建议是,如果你有时间和精力为遇到的问题编写一个小的解决方案,那么绝对应该这样做。这会迫使你思考你的应用程序在生产环境中是如何工作的,以及如何改进你的开发流程。而且,这本身也很有趣。
扩展
这个项目有很多可能的扩展,其中一些我想尽快解决,我在这里列出了一些更有趣的扩展:
- 允许通过构建器应用程序创建构建脚本
- 从 VCS 构建(即 git clone 一个 repo 并根据工作描述进行构建)
- 此构建管道的 UI
- 在 Minikube 本身上的 Docker 中运行构建器应用程序
- 在云平台上托管构建器应用程序,并进行可配置的部署和构建
- 让用户创建构建
.json
作业文件 - 多语言支持
注意事项
这种快速而粗糙的管道并不比预先存在的解决方案更好,但使用起来更有趣,因为我可以根据自己的需要快速对其进行更改。
我对 Go、Kubernetes 和 Docker 还比较陌生。所以我使用的风格可能不是最佳实践,但对我来说确实有效。
使用示例
我目前正在使用类似的流水线,在编写代码的同时,将我正在开发的名为 Crtx ( http://crtx.xyz/ )的项目自动部署到测试环境中。它主要是一个 Go 代码库,包含多个应用程序,这些应用程序会持续部署到 Minikube 集群中。此流水线使测试应用程序之间的数据流变得更加容易。
结束语
这是我第一次撰写技术文章,希望听到对我所采取的方法以及实际内容的反馈!
我计划写更多关于我使用和构建的工具的内容,所以如果您喜欢阅读这篇文章,请告诉我!
您可以在Twitter上或在此处的评论中给我反馈✍️
文章来源:https://dev.to/cishiv/building-a-simple-ci-cd-pipeline-for-local-testing-using-go-docker-minikube-and-a-bash-script-1647