Earthly:超越Dockerfile的下一代容器镜像构建工具实战指南
1. 项目概述:为什么我们需要一个“更强大”的镜像构建工具?
如果你和我一样,在容器化和云原生这条路上摸爬滚打了好几年,那你一定对 Dockerfile 又爱又恨。爱它,是因为它用一套简单的语法,彻底改变了我们打包和分发应用的方式;恨它,是因为随着项目规模扩大、构建逻辑复杂化,Dockerfile 很快就暴露了它的局限性:缓存机制脆弱、构建步骤难以复用、跨平台构建繁琐、与 CI/CD 流水线集成时常常“水土不服”。每次为了优化一个多阶段构建的缓存命中率,或者为了在团队中统一构建流程而编写一堆辅助脚本时,我都在想,有没有一个工具,能把构建这件事做得更“工程化”一些?
直到我遇到了 Earthly。它不是一个简单的 Dockerfile 替代品,而是一个全新的构建自动化框架。你可以把它理解为一个“超级构建器”,它吸收了 Dockerfile 的容器化构建思想,同时引入了 Makefile 的依赖管理和声明式任务定义,最终目标是让构建过程像代码一样可维护、可测试、可复用。官方称它为“新一代更强大的镜像构建工具”,这个“更强大”究竟体现在哪里?简单来说,它试图解决我们在日常开发中遇到的所有构建痛点:不可靠的缓存、难以共享的构建逻辑、复杂的多架构支持,以及构建与 CI 的割裂。
在接下来的内容里,我不会只给你罗列 Earthly 的语法,那和看官方文档没区别。我会以一个资深 DevOps 工程师的视角,带你深入拆解 Earthly 的设计哲学,手把手演示如何将一个真实的中型项目的 Dockerfile 迁移到 Earthly,并分享在实际落地过程中我踩过的坑和总结出的最佳实践。无论你是正在为构建速度发愁的开发者,还是负责维护整个公司构建流水线的平台工程师,相信这篇深度解析都能给你带来实实在在的启发。
2. Earthly 核心设计哲学与架构拆解
2.1 超越 Dockerfile:声明式、可复用的构建即代码
Earthly 最根本的革新在于其理念。Dockerfile 本质上是指令式的,它描述的是“如何做”(RUN apt-get update && apt-get install -y...)。而 Earthly 是声明式的,它描述的是“要什么”(一个包含特定依赖和文件的应用镜像),以及达成这个目标所需要的任务和依赖关系。
这种声明式体现在它的核心文件Earthfile中。一个Earthfile由多个target(目标)组成,每个target类似于 Makefile 中的一个任务或 Dockerfile 中的一个构建阶段。但关键在于,这些target可以相互依赖、参数化,并且能被其他Earthfile引用。这就将构建逻辑从一次性的脚本,提升为了可组合、可版本控制的“构建模块”。
举个例子,你的公司可能有十个微服务,每个都需要用同样的方式安装系统依赖、配置时区、设置非 root 用户。在 Dockerfile 的世界里,你需要在十个地方复制粘贴同一段RUN指令。而在 Earthly 里,你可以创建一个base.Earthfile,定义一个叫做setup-base的 target,然后所有服务的Earthfile都可以通过FROM ./base+setup-base来继承这个基础环境。当基础配置需要更新时,你只需修改一处。
# base.Earthfile VERSION 0.7 setup-base: FROM alpine:3.18 RUN apk add --no-cache tzdata curl bash RUN addgroup -g 1000 -S appgroup && adduser -u 1000 -S appuser -G appgroup WORKDIR /app USER appuser# service-a/Earthfile VERSION 0.7 FROM ./base+setup-base # 继承基础设置 COPY src/ . RUN go build -o /app/service-a ./main.go SAVE IMAGE --push my-registry/service-a:latest这种模块化设计,是 Earthly “更强大”的第一个基石。它让构建基础设施的代码复用成为了可能,极大地减少了重复劳动和配置漂移。
2.2 确定性与高性能:革命性的缓存机制
构建缓存是影响开发者体验和 CI 速度的生命线。Docker 的层缓存机制虽然强大,但极其脆弱。任何指令的顺序变化、上下文文件的微小改动,都可能导致缓存失效,引发令人沮丧的漫长重建。
Earthly 的缓存机制则聪明得多。它采用了一种基于内容寻址的缓存。简单来说,Earthly 会为每一个构建步骤(包括其命令、输入文件、依赖的 target)计算一个唯一的哈希值。只要这个哈希值不变,该步骤的输出就直接从缓存中读取,完全不受步骤顺序或无关文件变化的影响。
这带来了两个巨大优势:
- 真正的增量构建:如果你只修改了
service-a的代码,那么构建系统只会重新执行与service-a相关的步骤,service-b以及它们共同依赖的基础层(如setup-base)将直接从缓存加载。 - 跨项目共享缓存:Earthly 支持本地缓存和远程缓存(如 S3、Google Cloud Storage)。这意味着,当团队中第一个人构建了某个依赖项后,其他成员以及 CI 服务器都可以直接复用缓存结果,实现“一次构建,处处可用”。
在实际操作中,你几乎能立刻感受到这种差异。一个原本需要 10 分钟的完整构建,在代码微调后的增量构建可能只需要 20 秒。对于 CI/CD 流水线,这直接意味着更快的反馈循环和更低的云计算成本。
注意:Earthly 的缓存虽然强大,但并非魔法。它依赖于对输入的精确定义。如果你在
COPY指令中使用了通配符(如COPY . .),那么任何文件的变化都会导致该步骤缓存失效。最佳实践是尽可能精确地声明需要复制的文件,例如COPY go.mod go.sum ./和COPY cmd/ cmd/。
2.3 构建、测试、部署一体化:Earthly 作为 CI 的“执行引擎”
这是 Earthly 最具野心的部分。传统的 CI/CD 流水线(如 Jenkins、GitLab CI、GitHub Actions)通常将“构建”作为一个独立的 Job 或 Step,里面塞满了 Docker build 命令和各种 shell 脚本。构建逻辑散落在 CI 配置文件和 Dockerfile 中,难以本地复现,形成了所谓的“CI 脚本魔法”。
Earthly 提出了一个不同的范式:用 Earthly 定义所有构建、测试、甚至部署任务,而 CI 系统只负责触发 Earthly 命令。你的Earthfile里不仅可以构建镜像,还可以定义单元测试、集成测试、代码质量检查、生成文档等任务。
VERSION 0.7 # 构建目标 build: FROM +deps COPY src/ . RUN go build -o /app/myapp ./cmd/server SAVE ARTIFACT /app/myapp AS LOCAL ./dist/myapp # 单元测试目标 unit-test: FROM +deps COPY src/ . RUN go test ./... -v # 集成测试目标(可能需要数据库) integration-test: FROM +deps COPY src/ . WITH DOCKER --compose docker-compose.test.yml RUN ./scripts/wait-for-db.sh && go test ./integration -v END # 主入口,按顺序执行 all: BUILD +unit-test BUILD +integration-test BUILD +build这样一来,Earthfile成为了项目唯一的构建“真相源”。开发者可以在本地运行earthly +unit-test来运行测试,这与 CI 上运行earthly --ci +all在本质上完全一致,彻底解决了“在我机器上是好的”这个经典问题。CI 配置则变得极其简洁,只剩下调用 Earthly 和上传制品等步骤。
3. 从零开始:将一个真实项目迁移到 Earthly
理论说再多,不如动手干。让我们以一个典型的 Go 语言 Web 服务项目为例,将其从传统的 Dockerfile 迁移到 Earthly。假设项目结构如下:
my-go-service/ ├── Dockerfile ├── go.mod ├── go.sum ├── cmd/ │ └── server/ │ └── main.go ├── internal/ ├── pkg/ └── docker-compose.test.yml3.1 原始 Dockerfile 分析
典型的 Dockerfile 可能是这样的:
# 多阶段构建 FROM golang:1.21-alpine AS builder WORKDIR /app COPY go.mod go.sum ./ RUN go mod download COPY . . RUN CGO_ENABLED=0 GOOS=linux go build -o server ./cmd/server FROM alpine:latest RUN apk --no-cache add ca-certificates tzdata WORKDIR /root/ COPY --from=builder /app/server . EXPOSE 8080 CMD ["./server"]这个 Dockerfile 已经不错了,利用了多阶段构建来减小最终镜像体积。但它的问题也很典型:缓存完全依赖于go.mod和go.sum文件,如果任何源代码文件变化,go mod download之后的缓存全部失效,需要重新下载所有依赖(虽然 Go Module 有本地缓存,但在 CI 纯净环境中仍耗时)。
3.2 分步迁移至 Earthfile
第一步:安装 Earthly根据你的操作系统,安装非常简单。以 macOS 为例:
brew install earthly/earthly/earthly && earthly bootstrap安装完成后,在项目根目录初始化:earthly init,这会创建一个空的Earthfile。
第二步:创建基础依赖 Target我们将依赖安装单独抽离,确保只要go.mod和go.sum不变,依赖步骤的缓存就永远有效。
# Earthfile VERSION 0.7 # 基础依赖阶段,高度可缓存 deps: FROM golang:1.21-alpine WORKDIR /app # 精确复制依赖定义文件 COPY go.mod go.sum ./ RUN go mod download # 保存这个状态,供后续 target 使用 SAVE IMAGE第三步:定义构建 Target构建 target 依赖于deps,并且只复制必要的源代码目录,避免因文档、配置文件等无关文件变动导致缓存失效。
build: # 从 deps target 的结果开始,而不是从头开始 FROM +deps # 复制源代码,注意这里没有复制整个根目录 COPY cmd/ cmd/ COPY internal/ internal/ COPY pkg/ pkg/ # 构建 RUN CGO_ENABLED=0 GOOS=linux go build -o server ./cmd/server # 将构建产物保存为本地文件 SAVE ARTIFACT server AS LOCAL ./dist/server第四步:定义最终镜像 Target这是生成最终 Docker 镜像的地方,它不直接依赖deps,而是使用buildtarget 产出的二进制文件。
docker: # 使用轻量级基础镜像 FROM alpine:latest RUN apk --no-cache add ca-certificates tzdata WORKDIR /root/ # 从 +build target 复制构建好的二进制文件 COPY +build/server ./ EXPOSE 8080 ENTRYPOINT ["./server"] # 将最终镜像推送到仓库(可选,通常在 CI 中执行) SAVE IMAGE --push my-registry/my-go-service:latest第五步:添加测试 Target现在,我们可以轻松地添加一个在一致环境中运行的测试任务。
test: FROM +deps COPY . . # 测试需要所有源代码 RUN go test ./... -v -count=1第六步:创建默认或聚合 Target通常,我们会定义一个all或defaulttarget 来串联常用任务。
# 默认任务,运行测试和构建 default: BUILD +test BUILD +build现在,一个完整的、模块化的Earthfile就完成了。在本地,你可以运行:
earthly +build:仅构建二进制文件。earthly +test:运行所有测试。earthly +docker:构建并输出 Docker 镜像。earthly --push +docker:构建并推送镜像(需要提前配置认证)。- 直接运行
earthly:执行defaulttarget,即运行测试后再构建。
3.3 迁移过程中的关键决策与技巧
Target 粒度划分:不要把所有东西塞进一个 target。像
deps、build、test、docker这样按职责分离,能最大化缓存收益。一个经验法则是:将变化频率不同的步骤分离到不同的 target 中。依赖文件(go.mod)变化少,单独成 target;源代码变化频繁,放在后续 target。COPY 指令的艺术:这是优化缓存的关键。永远优先使用精确的目录或文件列表,而不是
COPY . .。例如,COPY cmd/ cmd/比COPY . .要好得多,因为改一个 README.md 不会导致构建缓存失效。理解
SAVE指令:SAVE IMAGE用于输出 Docker 镜像,SAVE ARTIFACT用于将容器内的文件保存到本地宿主机。这是 Earthly 与 Dockerfile 的重要区别,它让你可以在构建流水线的中间阶段提取产物。本地开发与 CI 的统一:在
Earthfile中,你可以使用ARG指令来参数化构建。例如,为测试设置不同的数据库连接字符串,或者为不同环境设置不同的镜像标签。这确保了本地和 CI 的行为完全一致。
VERSION 0.7 docker: ARG tag=latest FROM alpine:latest COPY +build/server ./ SAVE IMAGE --push my-registry/my-go-service:$tag在 CI 中你可以这样调用:earthly --push --tag=prod-v1.0 +docker。
4. 高级特性与复杂场景实战
4.1 跨平台构建与多架构镜像
在 Dockerfile 中,构建多架构镜像(如 amd64, arm64)通常需要配置复杂的buildx或手动编写多个 Dockerfile。Earthly 将此过程大大简化。
Earthly 原生支持通过--platform标志进行跨平台构建。更重要的是,它可以与docker manifest命令结合,轻松创建多架构镜像清单。
VERSION 0.7 docker-multiarch: # 这个 target 本身不构建,它编排多个平台的构建 BUILD --platform=linux/amd64 +docker --tag=myapp-amd64 BUILD --platform=linux/arm64/v8 +docker --tag=myapp-arm64 # 本地构建时,可以保存不同架构的镜像 SAVE IMAGE --push my-registry/myapp:latest-amd64 AS my-registry/myapp:latest-amd64 SAVE IMAGE --push my-registry/myapp:latest-arm64 AS my-registry/myapp:latest-arm64 # 在 CI 中,可以继续执行创建 manifest 的命令(通常通过 earthly 的 RUN 指令调用 docker cli)在实际的 CI 流水线中,你可能会专门有一个 Earthly target 或一个后续的 shell 脚本,来使用docker manifest create将myapp:latest-amd64和myapp:latest-arm64合并为myapp:latest。
实操心得:对于复杂的多架构推送和 manifest 创建,我更喜欢在 Earthly 中完成所有架构的构建和打标签,然后在一个专门的“发布” Earthfile 或 CI 步骤中,使用
docker manifest工具完成最终清单的创建和推送。这样关注点分离更清晰。
4.2 集成外部依赖:WITH DOCKER 的威力
很多项目的测试或构建需要依赖其他服务,比如数据库、消息队列。Earthly 提供了WITH DOCKER指令,允许你在一个构建步骤中启动一个临时的 Docker Compose 环境。
VERSION 0.7 integration-test: FROM +deps COPY . . # 启动测试依赖 WITH DOCKER --compose docker-compose.test.yml # 等待数据库就绪 RUN ./scripts/wait-for-it.sh db:5432 --timeout=30 # 运行集成测试 RUN go test ./integration -v -count=1 ENDWITH DOCKER块内的RUN指令会在一个包含了你所启动的 Docker Compose 服务网络的环境中执行。这为集成测试提供了完美的、可重复的隔离环境。测试结束后,所有临时容器会被自动清理。
4.3 构建流水线编排与条件执行
Earthly 的 target 之间可以形成复杂的依赖图。你可以利用BUILD指令和IF、FOR等控制语句来编排复杂的构建流水线。
VERSION 0.7 # 构建所有微服务 build-all: FOR service IN ./services/* BUILD $service+docker END # 一个根据 git 分支决定行为的部署目标 deploy: IF [ "$EARTHLY_GIT_BRANCH" == "main" ] BUILD +deploy-prod ELSE BUILD +deploy-staging END deploy-prod: # ... 生产环境部署逻辑 RUN ./deploy.sh --env=prod deploy-staging: # ... 预发环境部署逻辑 RUN ./deploy.sh --env=stagingEarthly 提供了一些内置的 ARG,如EARTHLY_GIT_BRANCH、EARTHLY_GIT_TAG,方便你在构建逻辑中根据代码仓库的状态做决策。
5. 落地实践:集成到 CI/CD 与团队协作
5.1 在 GitHub Actions 中集成 Earthly
将 Earthly 集成到现代 CI 系统中非常直观。以下是一个 GitHub Actions 工作流的示例,它会在每次推送到 main 分支时运行测试、构建并推送多架构镜像。
# .github/workflows/ci.yml name: CI with Earthly on: push: branches: [ main ] pull_request: branches: [ main ] jobs: build-and-test: runs-on: ubuntu-latest steps: - name: Checkout code uses: actions/checkout@v3 - name: Set up Earthly uses: earthly/actions/setup-earthly@v1 with: version: "v0.7.22" # 建议固定版本 enable-earthly-ci: true # 启用 CI 模式 - name: Login to Container Registry run: echo "${{ secrets.DOCKER_PASSWORD }}" | docker login -u "${{ secrets.DOCKER_USERNAME }}" --password-stdin - name: Run tests and build run: earthly --ci +all # 运行 Earthfile 中的 default target - name: Build and push multi-arch image (on main) if: github.ref == 'refs/heads/main' run: | earthly --ci --push +docker-multiarch # 假设 docker-multiarch target 只构建,这里再创建 manifest docker manifest create my-registry/myapp:latest \ my-registry/myapp:latest-amd64 \ my-registry/myapp:latest-arm64 docker manifest push my-registry/myapp:latest关键点:
--ci标志:Earthly 的 CI 模式会进行一些优化,例如更激进的缓存清理策略,并确保构建日志格式适合 CI 环境。- 远程缓存:为了获得极致的 CI 速度,强烈建议配置远程缓存。你可以在 Earthly 的
~/.earthly/config.yml或通过环境变量配置一个 S3 兼容的存储后端。这样,第一个合并请求触发的构建产生的缓存,可以被后续的构建复用。
5.2 团队协作与 Earthly Satellites
对于大型团队,本地构建可能因网络或机器性能差异导致体验不一致。Earthly 提供了一个企业级功能叫Satellites。你可以将其理解为一个由 Earthly 管理的、云上的共享构建“执行器”。
团队开发者不再需要在本地运行沉重的构建,而是通过earthly --satellite <name> +build命令,将构建任务提交到云端的 Satellite 执行。Satellite 拥有强大的计算资源、稳定的网络,并且共享同一个缓存。这带来了几个好处:
- 一致的构建环境:所有人都使用完全相同的环境构建,彻底消灭“在我机器上可以”的问题。
- 极快的构建速度:云实例性能强大,且缓存命中率极高。
- 降低本地负载:开发者的笔记本电脑不再需要运行 Docker 消耗资源。
虽然 Satellites 是 Earthly 的商业功能,但对于中大型工程团队,它在提升开发效率和减少环境问题上的投资回报率是非常高的。
5.3 迁移策略与团队培训
将团队从一个成熟的 Dockerfile 工作流迁移到 Earthly,需要谨慎的计划:
- 试点项目:选择一个中等复杂度、构建速度慢或构建逻辑复杂的服务作为试点。用本文的步骤进行迁移,并记录耗时和遇到的问题。
- 并行运行:在试点项目的 CI 中,同时运行旧的 Docker 构建和新的 Earthly 构建,对比产物(镜像的层、二进制文件的 MD5)是否一致,确保正确性。
- 知识分享:为团队举办一次内部 workshop,讲解 Earthly 的核心概念(Target, 缓存机制,
SAVE指令)和基本语法。重点演示它如何解决当前工作流中的痛点。 - 编写团队规范:制定团队的
Earthfile编写规范。例如:如何命名 target、如何划分粒度、如何使用 ARG、如何编写可复用的基础 Earthfile 等。 - 渐进式迁移:不要试图一次性迁移所有项目。按优先级逐个迁移,并鼓励开发者在迁移过程中重构和优化原有的构建逻辑。
6. 常见问题、性能调优与避坑指南
即使有了强大的工具,错误的用法也会导致问题。以下是我在多个项目中实践 Earthly 后总结的“血泪教训”。
6.1 缓存不生效?检查你的输入!
这是新手最常见的问题。“我明明只改了一行注释,为什么整个depstarget 都重跑了?”
- 原因:很可能是因为你的
COPY指令包含了不该包含的文件。例如,如果你在depstarget 里写了COPY . .,那么任何文件的改动,包括README.md,都会改变该步骤的哈希值。 - 解决:严格限定
COPY的范围。对于依赖安装,只复制go.mod和go.sum。对于构建,只复制src/、cmd/等源代码目录。
6.2 构建速度没有想象中快?
- 未使用远程缓存:本地缓存只对你自己有用。团队协作和 CI 环境中,必须配置远程缓存(如 S3)才能实现缓存共享。这是提升 CI 构建速度最有效的一步。
- Target 粒度过粗:如果你把下载依赖、编译代码、运行测试都放在一个 target 里,那么任何代码改动都会导致“下载依赖”这个本应高度稳定的步骤缓存失效。务必拆分开。
- 网络问题:Earthly 构建时,每个
FROM和RUN(如果涉及下载)都会在容器内进行。确保你的基础镜像源和下载地址(如 npm registry, pip index)是高速可用的。可以在基础 Earthfile 中预先配置镜像源。
6.3 如何调试 Earthly 构建?
earthly --verbose +target:输出极其详细的日志,包括每个步骤的计算哈希、缓存查询结果等。这是排查缓存问题的利器。earthly --no-cache +target:强制忽略所有缓存,全新构建。用于验证构建的确定性和正确性。earthly --save-inline-cache --push +target:在推送镜像的同时,将缓存也推送到远程。这对于在 CI 中为后续构建准备缓存非常有用。- 交互式调试:Earthly 目前不直接支持
docker run -it那样的交互式进入容器。如果某个RUN步骤失败,你需要仔细查看错误日志,或者尝试将该步骤的命令拆分,并在本地模拟容器环境进行测试。
6.4 与现有 Docker 工具链的兼容性
docker build参数:Earthly 不完全支持所有docker build的参数。例如,--build-arg在 Earthly 中是通过ARG指令在Earthfile内部声明的,或者在命令行通过earthly --build-arg key=value传递。- Docker Compose:Earthly 的
WITH DOCKER可以启动 Compose 服务,但如果你现有的开发流程严重依赖docker-compose up进行本地开发,Earthly 并不会取代它。Earthly 主要负责“构建”和“测试”阶段的自动化,本地开发时的服务编排可以继续使用 Docker Compose。两者是互补关系。
6.5 成本考量
- Earthly Satellites:作为商业功能,需要订阅付费。你需要评估它带来的开发效率提升和本地资源节省,是否值得这笔开销。对于小型团队或开源项目,本地构建+远程缓存可能已足够。
- 远程缓存存储:使用 S3 等云存储作为远程缓存后端会产生存储和流量费用。但通常这部分成本极低,因为缓存是增量更新的,且构建日志等中间产物不会存入缓存。
经过几个项目的深度使用,Earthly 已经彻底改变了我对构建系统的看法。它带来的最大价值不是某个单点速度的提升,而是一种工程秩序的建立。构建逻辑变得清晰、可复用、可测试,缓存行为变得可预测,本地与 CI 环境达到高度一致。这些特性共同作用,显著降低了与构建相关的维护成本和认知负担。如果你正在为混乱的 Dockerfile、缓慢的 CI 构建,或是脆弱的构建流程而头疼,那么投入时间学习和引入 Earthly,很可能是一笔非常划算的技术投资。
