使用Dagger代码简化CI/CD管道


本文讨论使用 Dagger 简化 Java/Gradle 服务的 CI/CD 管道。它将使用 Docker/docker-compose 构建、测试和打包服务的传统方法与使用 Dagger 进行了比较。使用 Dagger,一切都是通过代码而不是 Dockerfiles/compose 文件定义的。该代码是可重用的,并且在任何地方运行都是相同的。

本文在 Docker 中实现了构建/测试阶段,然后使用 docker-compose 来支持 MySQL 进行集成测试。它显示了将测试作为库导入并在 Dagger 模块上下文中运行。Dagger 确保环境与 CI 相同。这篇文章强调了 Dagger 在 CI 中的闪光,只需运行模块代码,一切都可以运行,无需额外配置。

背景上下文
我们突然发现一个团队是 Spring-Boot 服务的所有者,该服务使用 gradle 作为构建工具,我们的任务是为该服务构建 CI 流程。

该服务是电子商务系统的一部分,主要用于过滤存储在 MySQL 数据库内的订单。它公开了一个重要的端点,该端点接受一系列过滤器并返回与这些过滤器匹配的订单。例如:

GET /orders?page=1&status=open&shipping_method=43123
{
    "orders": [
        {
           
"id": 1234,
           
"status": "open",
           
"shipping_method": 43123,
           
"payment_status": "pending",
           
"fulfillment_status": "unfulfilled"
        },
        {
           
"id": 1235,
           
"status": "open",
           
"shipping_method": 43123,
           
"payment_status": "paid",
           
"fulfillment_status": "fulfilled"
        }
    ]
}

这项服务尚未投入生产,但我们知道这个端点非常重要。

它必须保持一致,也就是说,如果我指定 status=open 作为过滤器,它应该只返回符合该状态的订单。如果我们打破了这一限制,就会有大麻烦。为了确保我们永远不会合并或部署破坏这一约束的代码,我们决定最好建立一些黑盒集成测试,以验证整个系统的行为。

我们将启动我们的服务及其依赖项,并执行一个程序,用不同的过滤器发出 HTTP 请求,并验证响应是否符合我们的预期。

我们希望开发人员能够在本地运行这些测试,但同时也希望它们成为 CI 的硬约束,因此我们也需要在 CI 上运行这些测试。

为了让这篇博文的重点放在 CI 部分,我只分享集成测试的一个片段。我们在仓库中创建了一个测试文件夹,并编写了一个 main.go 文件来完成刚才提到的工作。下面是这个程序的一个片段:

var endpoint = flag.String("endpoint", "localhost:8080", "service endpoint")

func main() {
    flag.Parse()

    if err := OrdersList(*endpoint); err != nil {
        log.Fatalf(
"tests failed: %s", err)
    }
}

func OrdersList(endpoint string) error {
    tests := []struct {
        name    string
        filters Filters
    }{
        {
            name:
"open",
            filters: Filters{
                Status: OrderStatusOpen,
            },
        },
        {
            name:
"open paid",
            filters: Filters{
                Status:        OrderStatusOpen,
                PaymentStatus: PaymentStatusPaid,
            },
        },
        
// more test cases
    }

    errs := []error{}
    for _, test := range tests {
        name := test.name
        filters := test.filters

        
// create HTTP request and send it using Go's standard library

        
// validate the response
        if err = validateResponse(filters, response.Orders); err != nil {
            errs = append(errs, testErr(name, fmt.Errorf(
"response for filters is invalid: %v.\n\tResponse: %+v\n\tFilters: %+v", err, response.Orders, filters)))
            continue
        }
    }

    if len(errs) == 0 {
        return nil
    }

    rerr := errors.New(
"tests have failed")
    for _, err := range errs {
        rerr = fmt.Errorf(
"%s\n\t%s", rerr, err.Error())
    }
    return rerr
}

func testErr(name string, err error) error {
    return fmt.Errorf(
"❌ test %s failed. Error: %w", name, err)
}

func validateResponse(filters Filters, orders []*Order) error {
    for _, order := range orders {
        
// validate that the orders respect the filters that were specified
       
// if it is not the case then we return an error
    }

    return nil
}

通过该程序,开发人员可以启动本地环境并运行 go run ./tests/main.go -endpoint=localhost:<PORT>.

总之,我们希望建立一种可重现的方法来:

  • 构建和打包我们的服务
  • 运行单元测试
  • 运行需要服务及其依赖项运行的集成测试
  • 这样,我们就能构建一个集成所有这些功能的 CI 流程。

使用 docker 和 docker-compose
让我们开始使用您可能已经熟悉的工具来构建它:docker 和 docker-compose。为了构建和打包我们的服务,我们首先编写一个 Dockerfile:

  • 从已安装我们需要的 Java 版本的映像启动。
  • 复制我们所需的所有文件和文件夹。
  • 使用 gradle 包装器执行 gradle 构建。
  • 从安装了 Java 的映像开始一个新阶段。
  • 安装经常用于排除故障的关键依赖项。
  • 复制上一阶段生成的 .jar。
  • 定义用于运行服务的命令。

FROM amazoncorretto:21.0.1-alpine3.18 AS base

WORKDIR /app

COPY src src
COPY gradlew gradlew
COPY gradle gradle
COPY build.gradle.kts build.gradle.kts
COPY settings.gradle.kts settings.gradle.kts

FROM base AS build

RUN ["./gradlew", "clean", "build", "--no-daemon"]

FROM amazoncorretto:21.0.1-alpine3.18 AS runtime

RUN apk update && apk --no-cache add ca-certificates curl tcpdump procps bind-tools

RUN mkdir -p /var/log/spring

WORKDIR /app

COPY --from=build /app/build/libs/gradle-service-0.0.1-SNAPSHOT.jar app.jar

ENV APP_PROFILE=
"default"
ENV JAVA_OPTS=
""

CMD java $JAVA_OPTS -jar app.jar --server.port=80 --spring.profiles.active=$APP_PROFILE

在此 Dockerfile 中,我们利用 Docker 的多阶段功能来减小映像的最终大小,并仅传送我们需要的内容。
通过这个定义,开发人员现在可以在本地构建和运行服务,而无需安装 java、gradle 等:

docker build -t gradle-service .
docker run -p 8080:8080 -e APP_PROFILE=local gradle-service


运行单元测试怎么样?好吧,在我们的 Dockerfile 中,您可能会看到我们首先定义了一个名为“base挂载应用程序代码”的阶段。这样做是为了重新使用Dockerfile运行我们的单元测试。我们可以通过利用 BuildKit 的针对特定阶段的能力来做到这一点,如下所示:

docker build -t gradle-service-base --target base .
docker run -e APP_PROFILE=test gradle-service-base ./gradlew clean test

通过这种方法,我们能够将构建和测试容器化,以便这些进程在所有机器上以完全相同的方式运行。我经常看到的情况(特别是在 Java 生态系统中)是,开发人员通常通过重用 CI 运行时提供的任何抽象来在 CI 工作流程中运行构建和测试命令。例如,就 Github 工作流程而言,我看到人们经常使用gradle 操作直接在其 CI 中运行 gradle 命令。通常最终发生的情况是,操作以不同的方式进行设置,然后测试最终在 CI 中失败,但在开发人员的本地环境中却失败。让人们感到困惑并被迫忽略 PR 中的测试,因为谁有时间调试 CI?

集成测试
现在是有趣的部分:集成测试。我们之前提到过,我们的服务使用 MySQL 来存储和检索所请求的订单。为了能够运行此集成测试,我们需要运行 MySQL 并连接到它的服务。因为我们希望它可以重现并在我们的 CI 中运行,所以我们将利用 docker-compose:

version: '3.9'

services:
  mysql:
    image: mysql:8.2.0
    container_name: mysql
    environment:
      - MYSQL_ROOT_PASSWORD=password
      - MYSQL_DATABASE=database
    volumes:
      - ./db/db.sql:/docker-entrypoint-initdb.d/db.sql
    ports:
      - 3306:3306
    healthcheck:
        test: ["CMD", "mysqladmin" ,"ping", "-h", "localhost"]
        timeout: 5s
        retries: 10
    networks: [
"service"]

  service:
    build: .
    container_name: service
    environment:
      -
"DB_HOST=mysql"
      -
"DB_PORT=3306"
      -
"APP_PROFILE=default"
    ports:
      - 8080:80
    depends_on:
      mysql:
        condition: service_healthy
    links:
      -
"mysql:mysql"
    networks: [
"service"]

networks:
  service:

让我们快速了解一下这个 docker-compose 文件的内容:

  • service:我们的服务的定义,当我们运行它时会触发构建(这是因为我们在 docker-compose 中指定了 build: .)。我们为 MySQL 添加了一个依赖项(depend_on)和一个健康条件(service_healthy),这样只有当 MySQL 启动并接受连接时,服务才会启动。
  • mysql:一个运行 MySQL 8 服务器的服务,安装了一个 SQL 文件,为我们的测试提供一些数据。我们指定了一个使用 mysqladmin 的健康检查,这样只有在 MySQL 准备好接受连接时,服务才会连接到它。

现在,我们可以通过运行 docker-compose up 在本地或任何其他环境中启动我们的服务。

是时候添加集成测试了。我们还希望这些测试能够在任何主机上运行,而不需要该主机安装依赖项(当然,运行容器的能力除外)。为此,我们开始在测试文件夹中编写一个 Dockerfile:

FROM golang:1.21-alpine

WORKDIR /app
COPY main.go main.go

CMD go run main.go -endpoint $ENDPOINT

很简单:我们要 Go,我们要执行测试。我们并不关心发布此映像,因此无需通过多阶段构建进行优化。

有了这个新的 dockerfile 和 docker-compose,我们就可以执行测试了。不过,由于所有东西都是 docker 化的,我们需要在服务运行的同一网络中运行测试容器,这样它才能访问服务端点。在触发测试之前,我们还需要等待服务运行一段时间:

$ docker-compose up -d
# You should wait for the service to be up and running
$ cd tests && docker build -t tests .
$ docker run --network dagger-developer-perspective_service -e "ENDPOINT=service:80" --rm --name tests tests
2023/11/19 14:22:37 running validation for: open
2023/11/19 14:22:37 running validation for: open paid
2023/11/19 14:22:37 running validation for: open pending
2023/11/19 14:22:37 running validation for: open paid unpacked
2023/11/19 14:22:37 running validation for: open paid unpacked shippingMethod:table
2023/11/19 14:22:37 running validation for: open paid unpacked completedAtFrom completedAtTo
2023/11/19 14:22:37 running validation for: open paid and pending

我们现在准备构建我们的 Github 工作流程,以重用我们在这里构建的内容。

构建我们的 CI 工作流程
我不会在这里详细介绍 Github 工作流程的语法。我们希望我们的工作流程仅在针对我们的主分支发出的拉取请求上运行,并且我们希望它:

  • 运行单元测试
  • 构建服务
  • 运行集成测试

在此 CI 工作流程中,我们:

  1. 构建base我们可以用来运行测试的图像
  2. 使用base图像来运行我们的测试
  3. 使用 docker-compose 启动服务和数据库
  4. 运行一个脚本来检查服务的运行状况,以便我们在服务启动后运行集成测试
  5. 为我们的测试构建容器映像并在与服务相同的网络中运行它们
  6. 拆除我们的服务和数据库

name: 'pull request'

on:
  pull_request:
    branches:
      - main

jobs:
  build-and-test:
    runs-on: ubuntu-latest
    steps:
    - name: Checkout
      uses: actions/checkout@v3
    - name: Build the base image
      run: docker build -t gradle-service-base --target base . 
    - name: Run unit tests
      run: docker run gradle-service-base ./gradlew clean test
    - name: Start services
      run: docker-compose up -d
    - name: Check service health
      run: ./tests/check_health.sh
    - name: Run integration tests
      working-directory: tests
      run: docker build -t tests . && docker run --network dagger-developer-perspective_service -e "ENDPOINT=service:80" --rm --name tests tests
    - name: Tear down
      run: docker-compose down

您可以看到我们添加了一个以前手动完成的新步骤:服务运行状况检查。这样集成测试仅在服务准备就绪后运行:

#!/bin/bash

max_attempts=5
for (( i=1; i<=max_attempts; i++ ))
do
    response=$(curl -o /dev/null -s -w "%{http_code}\n" http://localhost:8080/health)
    if [
"$response" -eq 200 ]; then
        echo
"Server is up and running."
        exit 0
    else
        echo
"Attempt $i: Server not responding, retrying in 5 seconds..."
        sleep 5
    fi
done
echo
"All attempts failed, exiting with code 1."
exit 1


现在我们可以推送我们的分支,创建拉取请求并查看我们的 CI 工作流程是否成功运行

为了实现这一目标,我们必须做很多事情,而且这些事情都有点分散:

总结
为了实现这一目标,我们必须做很多事情,而且这些事情都有点分散:

  • 服务的 Docker 文件
  • 集成测试的 Dockerfile
  • 以可重现的方式运行服务的 docker-compose.yaml
  • check_health.sh 用于在运行集成测试前检查服务的健康状况
  • 将所有内容粘合在一起的 .github/workflows/pr.yaml。

开发过程并不像我在这里写的那么 "顺利"。中间我犯了很多错误,这迫使我在本地运行和在 CI 中运行的方式上反复斟酌,比如 check_health.sh 只有在 CI 中才有必要。为了能以 docker 化的方式运行测试,我不得不定义 3 个阶段,这也是我没有见过的人经常做的事情,他们只是依赖 CI 运行时来安装依赖项,然后祈祷不会出错。我还得研究如何将容器连接在一起,以便我们的集成测试能够真正访问服务。在我看来,我在这里的开发体验并不好。团队中可能没有开发人员会在本地使用 docker build && docker run 来运行他们的测试,因为这比简单地使用他们选择的工具(在本例中为 gradle)需要更多的精力和步骤。如果 CI 出现问题,很可能会被忽视,直到有人决定不高兴一下并修复它。

使用 Dagger 模块
Dagger 能否提供更好的构建体验?让我们试一试!

快速介绍:Dagger 允许你使用支持的语言之一来定义应用程序的整个生命周期(构建、测试、打包等)。你不必编写 Dockerfile 和 docker-compose 文件,而是在 Dagger 模块中使用你选择的编程语言编写所有内容。在内部,Dagger 利用 BuildKit 和容器确保你编写的代码能以完全相同的方式在任何地方运行。

那么,我们首先要做什么呢?我们在版本库中初始化一个 Dagger 模块,该模块将包含构建之前构建的版本所需的所有代码。让我们先创建一个 ci 文件夹,然后创建一个使用 Go 的新 Dagger 模块:

mkdir ci && cd ci
dagger module init --name gradle-service --sdk go

快速查看 dagger 生成的程序内容,我们可以看到它正在使用一个名为 dag 的对象来创建容器并对其执行操作:

package main

import (
    "context"
)

type GradleService struct {}

// example usage: "dagger call container-echo --string-arg yo"
func (m *GradleService) ContainerEcho(stringArg string) *Container {
    return dag.Container().From(
"alpine:latest").WithExec([]string{"echo", stringArg})
}

// example usage: "dagger call grep-dir --directory-arg . --pattern GrepDir"
func (m *GradleService) GrepDir(ctx context.Context, directoryArg *Directory, pattern string) (string, error) {
    return dag.Container().
        From(
"alpine:latest").
        WithMountedDirectory(
"/mnt", directoryArg).
        WithWorkdir(
"/mnt").
        WithExec([]string{
"grep", "-R", pattern, "."}).
        Stdout(ctx)
}


我们可以利用 dagger 的 CLI 来探索模块的实际功能,从版本库的根目录运行 dagger -m ./ci 函数,然后尝试一些已经提供的示例

现在,Dagger 的有趣之处在于,无论使用哪种编程语言编写模块,模块都可以被其他人发布和重用,这意味着您可以使用Python 模块中的 Go 模块。在撰写本文时,模块已在daggerverse上发布。

我们可以利用这个模块来执行构建和测试命令。要使用它,我们只需通过运行来导入它dagger mod use github.com/matipan/daggerverse/gradle。如果您检查该模块,dagger -m github.com/matipan/daggerverse/gradle functions您会发现我们可以定义从哪个版本的 gradle 图像开始。在我们的例子中,我们使用的是 JDK 21。让我们在编写Build和Test函数时牢记这一点:

package main

import (
    "context"
    
"fmt"
    
"log"
)

var GradleVersion =
"jdk21-alpine"

type GradleService struct {
    Source *Directory

    gradle *Gradle
}

func (m *GradleService) WithSource(src *Directory) *GradleService {
    m.Source = src
    return m
}

func (m *GradleService) Build(ctx context.Context) *Container {
    return m.getGradle(m.Source).Build()
}

func (m *GradleService) Test(ctx context.Context) *Container {
    return m.getGradle(m.Source).Test()
}

func (m *GradleService) getGradle(src *Directory) *Gradle {
    if m.gradle != nil {
        return m.gradle
    }

    m.gradle = dag.Gradle().
        FromVersion(GradleVersion).
        WithDirectory(src).
        WithWrapper()
    return m.gradle
}

让我们回顾一下我们在这里做了什么:

  • WithSource:所有 Dagger 调用都在沙盒环境中运行,因此我们需要显式向命令提供文件夹和环境变量等内容。
  • getGradle:此函数创建对模块的引用Gradle,指定版本并将其配置为使用 gradle 包装器。
  • Build and Test:在此函数中,我们只需获取对已安装 src 目录的 Gradle 模块的引用,并在那里运行特定操作。


我们现在可以通过运行dagger -m ./ci call with-source --src "." build来尝试我们的构建功能

总结
通过使用代码和 Dagger 运行容器化服务和命令的功能,我们能够在单个 Go 文件中以编程方式构建整个 CI 工作流程。

在这篇博文中,我们比较了使用 docker-compose 等传统工具与 Dagger 构建 CI 工作流程来构建和测试 Java-Gradle 服务的体验。我们看到,使用 docker 和 docker-compose,我们必须编写大量配置文件和 YAML,以一种不易重现的方式将事物粘合在一起。这意味着开发人员将以不同于 CI 工作方式的方式在本地运行事物。然而,使用 Dagger,我们发现不再需要编写此配置和 YAMl 文件。相反,我们使用 Go 代码构建了所有内容,利用了 Dagger 对运行服务的本机支持,并以编程但仍然是声明性的方式将所有内容粘合在一起。然后,我们能够以与在本地运行完全相同的方式在 CI 中运行此代码。

我个人认为 Dagger 仍然有一些学习曲线。它需要转变思维方式,以便停止认为应该使用 YAML 和 dockerfile 等配置“语言”来编写和定义该流程。然而,我认为 Dagger 以其代码优先的方法及其生态系统提供了强大的功能,并且只会变得越来越大、越来越好。有趣的是,它可以轻松地与现有 CI 系统集成,并为开发人员提供体验,使我们能够将管道视为软件并使用我们的编辑器和 IDE 来编写它们。

网友观点:

  • 如果你曾经使用过一个或一组足够大的管道,你就会面对这样一个事实:东西坏了,你不得不拼命提交,试图让奇怪的 yaml dsl 做你想做的事情"。对于复杂的流水线来说,YAML 可能很难处理,调试问题需要多次提交。
  • 当我们将管道推广到数千个软件源时,我们希望对管道有实际的保证。有了 Dagger,我们就可以对管道的一部分进行传统的单元测试,并对整个示例管道进行全面的集成测试(或者你想怎么称呼都行)。"Dagger 允许在大规模部署前对管道进行本地测试,从而增强信心。
  • 也许这是一个愚蠢的比喻,但它几乎就像一个没有浏览器的网络开发人员,需要将他们的代码发送给一个能告诉他们字体大小是否好看的朋友。用 YAML 定义的管道很难像其他代码一样在本地进行测试,需要推送更改才能运行。
  • 我认为,我们早就应该把系统的这些 "蓝图 "从 CI 的束缚中解放出来,并使它们具有可移植性和灵活性。管道可以像普通代码一样进行定义和测试,而不是将配置文件绑定到特定的 CI 系统上。