使用 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 startMinikube 安装完成后,运行即可启动单节点集群,非常简单。
下一步是为我们的应用程序设置 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
后端开发教程 - Java、Spring Boot 实战 - msg200.com






