使用 Go、Docker、Minikube 和 Bash 脚本构建一个简单的 CI/CD 管道用于本地测试,这听起来很酷,但为什么呢?

2025-05-24

使用 Go、Docker、Minikube 和 Bash 脚本构建用于本地测试的简单 CI/CD 管道

听起来很酷,但为什么呢?

在本文中,我将详细介绍我编写的自动构建和部署管道的思考过程和实现,以减少获取对代码库所做的代码更改的反馈所需的时间。

大部分代码是用 Go 和 Bash 编写的。虽然实现起来有点 hacky,但编写和使用起来非常有趣。

免责声明:这篇文章的代码量很大,但我试图强调代码背后的思考过程。

您可以在这里找到整个帖子的代码cicdexample

这是我们将要构建的快速 GIF:
功能 GIF

听起来很酷,但为什么呢?

反馈很重要

对我来说,编写任何类型的代码时最重要的部分是我能多快得到反馈。

今年早些时候,我开始利用业余时间学习 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)
    }
}

Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

太好了,现在如果我们构建(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"]
Enter fullscreen mode Exit fullscreen mode

这个 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
Enter fullscreen mode Exit fullscreen mode

我们已准备好继续前进。

打破现状

接下来,我们需要一个脚本来自动构建 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
Enter fullscreen mode Exit fullscreen mode

使用不同的参数运行它,我们得到以下结果:

./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

Enter fullscreen mode Exit fullscreen mode

./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...

Enter fullscreen mode Exit fullscreen mode

这看起来很合理,我们可以用一个命令编译我们的代码并构建一个docker镜像。

在进行下一步之前,让我们先看看目前的清单:

a. 创建一个小型 Go 应用来提供 HTML 页面 ✔️
b. 将其 Docker 化!✔️
c. 编写一个简单的 Bash 脚本来构建 Docker 镜像 ✔️
d. 将 Docker 镜像推送到 Minikube 并查看其运行情况
e. 编写一个小型命令行工具来自动运行该脚本

Minikube 化

我们最终对构建管道进行了某种形式的自动化,但它并没有真正给我们提供一种测试应用程序的方法。

输入 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
Enter fullscreen mode Exit fullscreen mode

我们告诉 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
Enter fullscreen mode Exit fullscreen mode

确实如此,因为我们每次创建 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
}

Enter fullscreen mode Exit fullscreen mode

条件逻辑:

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..."
Enter fullscreen mode Exit fullscreen mode

我们现在可以运行:
./build build docker deploy
从编译到部署,轻松驱动代码更改!(周转时间约为 1 秒)

最终章:真正的自动化

最后,我们希望将我们创建的 Bash 脚本包装到专门构建的 Go 应用程序中,以自动执行此过程。

我们必须确定构建管道的触发器。目前有两个选项:

  1. 构建是根据时间流逝和文件更改的组合来触发的。
  2. 提交到 git 存储库时触发构建

为了在没有太多技术开销的情况下说明这个概念,我们将采用第一个选项

这基本上就是我们想要写的内容:

自动构建应用程序的更技术性的图表

我不会在这里粘贴整个源代码,但是我会讨论与它有关的一些关键点(完整的代码可以在这里找到)

我们需要解决5个问题:

  1. 我们将对文件使用什么类型的哈希值?
  2. 哈希值多久重新计算一次?
  3. 我们如何设置间隔轮询?
  4. 我们如何在 Go 应用程序的上下文中运行我们的脚本?
  5. 存在哪些竞争条件?

第一个问题在 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))
}
Enter fullscreen mode Exit fullscreen mode

第二个问题有一个非平凡的答案,它特定于您的用例,但是每 15 秒左右重新计算一次哈希值应该是足够合理的 - 这意味着如果该窗口中的代码发生变化,我们应该每 15 秒运行一次自动部署。

我对在 Go 应用程序中运行构建的实际时间进行了几个基准测试,以便我们可以对轮询文件更改的频率做出有根据的猜测。

我用来运行基准测试的代码片段如下:

func stopwatch(start time.Time, name string) {
    elapsed := time.Since(start)
    log.Printf("%s took %s", name, elapsed)
}

Enter fullscreen mode Exit fullscreen mode

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)
}

Enter fullscreen mode Exit fullscreen mode

我们只需创建一个每 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())
Enter fullscreen mode Exit fullscreen mode

最后一个问题很重要,一个明显的竞争条件是,如果我们监控目录中所有文件的哈希值,那么我们很可能会陷入某种递归构建循环,因为我们正在主动更改我们监控的文件(通过生成二进制文件)。我们可以借用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)
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

结合这些问题的解决方案,我们能够产生这个

使用构建构建器
go build -o pipeline将生成一个名为的二进制文件pipeline

然后,我们可以将这个二进制文件移动到我们的工作目录中。我们还需要在工作目录中创建一个ignore文件,以忽略构建器为我们的应用程序生成的二进制文件。

ignore文件很简单:

pipeline
cicdexample
Enter fullscreen mode Exit fullscreen mode

我们.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
Enter fullscreen mode Exit fullscreen mode

就这样!大功告成。我们成功编写了一个实用的(尽管简单)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
PREV
接下来我应该做什么?快速项目创意
NEXT
控制台 API。