编程 修复基础设施仓库里 Makefile 的静默失败:三个根因与对策

2026-09-07 15:19:08

修复基础设施仓库里 Makefile 的静默失败:三个根因与对策

一个基础设施仓库里,make <target> 退出码是 0,却什么都没发生——没有 Terraform plan、没有 Docker build、没有任何输出,唯一的线索是 CI 任务快得可疑。这种失败模式在包装 Terraform、Docker 或 kubectl 的 Makefile 里反复出现,而且常常被误诊。Make 没坏,它完全按文档工作;问题在于今天写 Makefile 的人大多用 shell 脚本的心智模型,去套一个 1970 年代为"C 源文件是否要重编译"设计的、按文件时间戳决策的工具。

症状清单

动手改之前先确认模式匹配:make <target> 退出 0 但无可见效果(没有 plan、没有 build、没有输出);目标"有时"运行——git clean 后正常、新克隆后失败,或反过来;CI 日志出现从未显式 echo 的环境转储或密钥值;make help 打印不出东西或列出无描述的 target;recipe 明显跑了五条命令里的两条就安静地继续——没有错误、没有红字、下一个 target 照常开始。还有个 CI 专属变体:目标在开发者笔记本上正常,只在流水线里挂掉或失败——通常是精简容器镜像缺 bash、默认 SHELL 不同,或 recipe 期待交互式 TTY 而 CI 给不了。

成本信号容易漏:如果 terraform init 或 docker build 每次调用都重跑,而不是只在输入变化时跑,那不是性能怪癖,是 CI 分钟数烧在根本不该发生的活上。

三个根因

Make 为编译 C 程序而生,target 映射到输出文件,用时间戳比较决定是否执行 recipe。这个模型在基础设施仓库里以三种方式泄漏:

第一:同名目录骗过 Make。Make 检查磁盘上是否已有与 target 同名的文件或目录。如果仓库里恰好存在 build、apply 或 deploy 目录——比如一次多余的 terraform apply 日志捕获或遗留的 build 产物——Make 会认为 target 已最新而整个跳过,除非该 target 声明为 .PHONY。没有错误,没有警告,只有沉默。

第二:每行 recipe 默认跑在自己的子 shell 里。第一行的 cd some-dir 不会带到第二行;一行里 export VAR=x 对下一行不可见。这是 GNU Make 手册里记录的行为,却恰恰是多数人从 shell 脚本带来的假设。

第三:默认 SHELL 是 dash 不是 bash。很多 Linux 发行版和 CI 基础镜像默认 SHELL 是 dash,set -o pipefail[[ ]] 条件、数组这些特性会报错或悄悄行为不同。特别要警惕:开发者 macOS 上正常的 Makefile(多数环境默认 bash),跑到 Alpine 系 CI runner 里(/bin/sh 是 dash)就出问题。

三个修复

Fix 1:硬化 Makefile 的 shell 契约。让 Make 不管在什么机器或 CI 镜像上都大声、一致地失败:显式钉住 shell 并强制 fail-fast 标志。

SHELL := /usr/bin/env bash
.SHELLFLAGS := -eu -o pipefail -c

-e 让 recipe 在第一条失败命令处停止而不是继续硬跑;-u 对未设置变量报错而不是静默展开成空;-o pipefail 让管道里失败的命令真正失败 recipe,而不是只认最后一条命令的退出码。再声明每个逻辑 target 为 .PHONY(这一条单独就能解决"target 啥也不干"),设 .DEFAULT_GOAL := help 避免裸 make 意外触发文件里第一个(可能是 init 或 apply 这类破坏性的)target,加 MAKEFLAGS += --warn-undefined-variables 在解析期暴露拼错的变量。recipe 真要跨行共享 shell 状态(循环、条件),GNU Make 3.82+ 有 ONESHELL:,但错误传播方式会变,要刻意测试而不是当 drop-in 替换。

Fix 2:守卫必需环境变量和密钥。一个不先检查 AWS_PROFILE 或 TF_VAR_env 就跑 terraform apply 的 Makefile,离"用默认值打到错误账号"只差一个漏掉的 export。解析期检查在任何 target 执行前运行:

ifndef AWS_PROFILE
$(error AWS_PROFILE is not set — export it before running make)
endif

单独提一个坑:Makefile 顶部的 include .env 不会把变量导出进 recipe 子 shell,只是让 Make 自己的变量展开可用。recipe 要直接调 terraform/aws 并期望 .env 值成为真实环境变量,得在 recipe 里加载:

deploy:
	set -a; source .env; set +a; terraform apply -auto-approve

另外绝不让 recipe echo 密钥:敏感行前缀 @ 抑制命令回显,并把 CI 密钥掩码当真正的安全控制——单靠 Makefile 约定挡不住一次 stray 的 env 或 printenv 把凭据倒进同事能读的 CI 日志。

Fix 3:用 stamp 文件终结重复重跑。terraform init 或镜像构建每次流水线都重跑,就是浪费 CI 分钟。Stamp 文件给 Make 一个真实文件去比时间戳:

.terraform-init.stamp: .terraform.lock.hcl
	terraform init -input=false
	@touch .terraform-init.stamp

init: .terraform-init.stamp

Make 只在 lockfile 变化时重跑 terraform init。多模块仓库用 order-only 前置(target: | dependency)防止无关模块因共享依赖链触发级联重建。

不过当 Makefile 里的 workaround 注释开始多于实际逻辑——嵌套 $(shell ...) 做条件分支、为三行脚本的循环做多行转义——就该评估专用 task runner(Taskfile、just)了。它们直接扔掉文件时间戳模型、支持 Windows、原生处理 dotenv。决策标准:仓库小、仅 Unix、团队读 Make 无障碍就留着;需要 Windows 支持、给非 DevOps 贡献者可读的 YAML、或不想用 shell 技巧做模板时再迁。

预防

加固过的 Makefile 只在"今天的检查被自动强制执行"时不重新腐烂。加 checkmake 作为合并前 CI 门禁——它标记缺失的 .PHONY 声明、无文档 target、重复 target 名;对 recipe 内部 shell 逻辑跑 shellcheck,能抓同一类导致静默部分失败的引号和子 shell 错误。README 里写清必需环境变量、最低 Make 版本、ONESHLL/.RECIPEPREFIX 的使用。新工程师不了解 Makefile 和 shell 脚本行为不同,正是会把子 shell bug 重新引入的人。

来源:Fix Silent Makefile Target Failures in Infra Repos - DEV Community

复制全文 生成海报 Makefile CI 基础设施 工程实践

推荐文章

程序员茄子在线接单