1. 职责边界:provision 与 configure
一句话总结: Terraform 负责「把机器造出来」(provision),Ansible 负责「把机器配置好」(configure),二者的边界应画在「资源生命周期」与「软件配置生命周期」之间。
1.1 两个工具的本质差异
| 维度 | Terraform | Ansible |
|---|---|---|
| 关注对象 | 云资源的存在与属性 | 主机/系统的期望配置 |
| 状态记录 | 远程 state 文件 | 无状态(每次重新探测) |
| 执行模型 | 声明式差异计算 + apply | 任务序列(幂等任务) |
| 失败处理 | 部分应用,state 记录已建 | 逐主机失败,可 --limit 重跑 |
| 删除语义 | destroy 删除资源 | 通常不负责删除 |
| 变更粒度 | 资源级 | 任务级 |
Terraform 的强项是编排资源依赖图:VPC → 子网 → 安全组 → 实例,顺序由引用关系自动推导。Ansible 的强项是主机内的复杂配置:装包、写配置、启服务、装 agent、配证书,这些用 Terraform 的 remote-exec 硬写会非常痛苦。
1.2 边界的推荐画法
Terraform 管:VPC / 子网 / 路由 / 安全组、EC2 / ASG / LB / 数据库、
IAM / 密钥 / DNS / 证书签发、实例的「出生」(cloud-init 最小引导)
Ansible 管: 操作系统层配置、中间件安装与配置(nginx / java / docker)、
应用部署与滚动发布、监控 agent 与日志采集、运行期配置漂移纠正
一句话: 凡是「改了它就要重建资源」的属于 Terraform;凡是「改了它只需重跑任务」的属于 Ansible。
1.3 为什么不该让 Terraform 干 Ansible 的活
很多人第一反应是用 remote-exec provisioner 在实例上跑脚本。这条路短期能通,长期有三个硬伤:provisioner 只在创建时运行,实例被改坏了 Terraform 不会修;无法幂等重放,脚本写得不严谨就重复执行出错;破坏 terraform plan 的可预测性,plan 里看不到脚本要做什么。
# 反模式:把大段配置塞进 remote-exec
resource "aws_instance" "app" {
provisioner "remote-exec" {
inline = [
"apt-get update",
"apt-get install -y nginx", # 重建才会重跑,日常改配置无能为力
]
}
}
正解是 Terraform 只把实例建出来,把「装 nginx」交给 Ansible。
2. 动态清单生成
一句话总结: 动态清单是两者协同的核心接口——Terraform 用 output 暴露实例信息,Ansible 用 inventory 插件或模板消费它,避免手工维护 IP 列表。
2.1 用 terraform output 暴露清单
# outputs.tf
output "app_hosts" {
value = {
for idx, inst in aws_instance.app :
"app-${idx}" => {
ansible_host = inst.public_ip
private_ip = inst.private_ip
instance_id = inst.id
az = inst.availability_zone
}
}
}
output "db_host" {
value = aws_db_instance.main.address
}
terraform output -json > tf_outputs.json # 导出为 JSON 供 Ansible 消费
2.2 方式一:inventory 脚本
写一个可执行脚本作为 inventory,Ansible 会调用它:
#!/usr/bin/env python3
# inventory/terraform_inventory.py
import json, subprocess
raw = subprocess.check_output(["terraform", "output", "-json"],
cwd="/opt/infra/envs/prod")
hosts = json.loads(raw).get("app_hosts", {}).get("value", {})
inv = {"app": {"hosts": {
name: {"ansible_host": i["ansible_host"], "instance_id": i["instance_id"]}
for name, i in hosts.items()}}}
print(json.dumps(inv, indent=2))
chmod +x inventory/terraform_inventory.py
ansible-inventory -i inventory/terraform_inventory.py --list
ansible-playbook -i inventory/terraform_inventory.py site.yml
2.3 方式二:inventory 模板 + 静态文件
# inventory/hosts.tpl
[app]
%{ for name, info in app_hosts ~}
${name} ansible_host=${info.ansible_host} instance_id=${info.instance_id}
%{ endfor ~}
[db]
db1 ansible_host=${db_host}
resource "local_file" "inventory" {
content = templatefile("${path.module}/inventory/hosts.tpl", {
app_hosts = aws_instance.app
db_host = aws_db_instance.main.address
})
filename = "${path.module}/../ansible/inventory/hosts"
}
local_file 会让 state 里多出一个本地文件资源,且每次 apply 都可能重写。它适合小规模,大规模建议用方式一或方式三。
2.4 方式三:直接查云(不依赖 Terraform)
用 Ansible 的云清单插件按标签发现实例:
# inventory/aws_ec2.yml
plugin: amazon.aws.aws_ec2
regions: [ap-northeast-1]
filters:
tag:ManagedBy: terraform
instance-state-name: running
keyed_groups:
- key: tags.Role
prefix: role
这种方式解耦了 Ansible 与 Terraform,Ansible 不依赖 state,只要实例打了正确标签就能发现。这与 数据源与远程查询 里「用标签作为稳定契约」的思路一致,代价是需要约定好标签规范。
2.5 三种方式对比
| 方式 | 依赖 Terraform state | 实时性 | 适合规模 |
|---|---|---|---|
| inventory 脚本 | 是 | apply 后刷新 | 中 |
| 模板生成文件 | 是 | apply 时写死 | 小 |
| 云清单插件 | 否 | 实时 | 大 |
一句话: 规模越大越应该走「标签契约 + 云清单」,让 Ansible 不依赖 Terraform 的内部状态。
3. 状态传递与依赖
一句话总结: Terraform 的输出要传到 Ansible,Ansible 的结论有时也要回传 Terraform;两条方向都要有明确机制,不要靠人工拷贝。
3.1 Terraform → Ansible
除清单外,还有非主机类变量需要传递,例如数据库地址、密钥名、S3 桶名:
output "app_env" {
value = {
db_host = aws_db_instance.main.address
bucket_name = aws_s3_bucket.assets.bucket
region = var.region
}
}
terraform output -json app_env > ansible/group_vars/all/tf_env.json
# group_vars/all/main.yml
tf_env: "{{ lookup('file', 'tf_env.json') | from_json }}"
这样 Ansible 里就能用 {{ tf_env.db_host }}。
3.2 Ansible → Terraform
反方向较少但存在,例如「实例初始化完成后产出某个 ID 供 Terraform 后续引用」。让 Ansible 写 JSON、Terraform 用 local_file 数据源读回来是可以的,但会引入循环依赖风险(Terraform 依赖 Ansible 产物,Ansible 依赖 Terraform 输出)。
更干净的做法是彻底避免回传:让 Ansible 的产出写进外部系统(SSM Parameter Store、Consul、Vault),Terraform 通过 data "aws_ssm_parameter" 之类数据源读取。这样两个工具只依赖一个中立的存储,不互相依赖。
3.3 依赖编排
正确的执行顺序是「terraform apply(建资源 + 出清单)→ ansible-playbook(配置机器)→ 可选的回传补建」。用 Makefile 串起来:
.PHONY: up configure
up:
cd envs/prod && terraform apply -auto-approve && \
terraform output -json > ../../ansible/tf_outputs.json
configure: up
cd ansible && ansible-playbook -i inventory/terraform_inventory.py site.yml
一句话: 优先让两个工具通过「中立存储」交换数据,而不是互相读对方的产物,避免环形依赖。
4. 幂等与重建语义
一句话总结: Terraform 的「重建」意味着资源销毁再创建,Ansible 的「重跑」只保证配置收敛;两者语义不同,混用会造成「机器被换了但配置没跟上」。
4.1 两种幂等的差异
Terraform 幂等:plan 显示 "No changes" ⇔ 云资源与配置一致
实例被删 → 下次 apply 重新创建(新 IP、新磁盘)
Ansible 幂等: 任务显示 "ok"(非 changed)⇔ 系统状态与任务期望一致
实例被删 → 清单里没了,Ansible 不会重建
关键结论:Ansible 不负责重建资源。如果实例被替换,必须重跑 Terraform 让新实例进入清单,再重跑 Ansible。
4.2 重建时的配置追赶
实例重建后需要自动重新配置。两种方案:cloud-init 兜底,把「最小可运行配置」写进 user_data,保证新实例至少能被 Ansible 连上;CI 编排保证顺序,apply 后总是跟一次 ansible-playbook,不让中间状态停留。
resource "aws_instance" "app" {
user_data = <<-EOF
#!/bin/bash
# 只做最小引导:装 python(Ansible 需要)与 ssh key
apt-get update -y && apt-get install -y python3
EOF
}
注意 user_data 的变更默认会重建实例,所以里面不要放频繁变动的配置。
4.3 让 Terraform 感知 Ansible 的变更
Terraform 不知道 Ansible 改了机器内部什么,因此它可能做出「重建实例」的决定而不自知会丢失运行期状态。对策:关键运行期状态(如数据目录)放在独立的卷上;用 lifecycle { prevent_destroy = true } 保护关键资源;在 CI 里对「将销毁实例」的 plan 做人工确认。
resource "aws_instance" "app" {
lifecycle {
prevent_destroy = true
ignore_changes = [user_data] # 避免 user_data 漂移触发重建
}
}
4.4 配置漂移的归属
用 Ansible 改了机器配置,Terraform 的 plan 通常看不到(Terraform 只跟踪云 API 层的属性)。这意味着:机器内部漂移由 Ansible 负责收敛(定期 ansible-playbook --check 巡检);云资源层漂移由 Terraform 负责收敛(terraform plan 巡检)。两层巡检都应进 CI,参见 可观测与运维
里的巡检体系设计。
5. 集成方式对比
一句话总结: 从「Terraform 内嵌 Ansible」到「完全解耦的两条流水线」有四种集成方式,耦合越紧越省事但越脆弱,生产推荐松耦合。
| 方式 | 实现 | 耦合度 | 推荐度 |
|---|---|---|---|
| remote-exec 内嵌 | provisioner 里跑 ansible-playbook | 极紧 | 不推荐 |
| local-exec 调用 | local-exec 触发 Ansible | 紧 | 小规模可用 |
| 独立流水线 + 动态清单 | CI 串行两个阶段 | 松 | 推荐 |
| 完全解耦 | 标签 + 云清单 + 各自巡检 | 极松 | 大规模推荐 |
5.1 local-exec 方式示例
resource "null_resource" "configure" {
triggers = {
instance_ids = join(",", aws_instance.app[*].id)
playbook_sha = filesha256("${path.module}/../ansible/site.yml")
}
provisioner "local-exec" {
working_dir = "${path.module}/../ansible"
command = "ansible-playbook -i inventory/terraform_inventory.py site.yml"
}
depends_on = [aws_instance.app]
}
triggers 里的 instance_ids 保证实例变化时重跑,playbook_sha 保证 playbook 变化时重跑。这是 local-exec 方式的关键——没有 triggers 它只会在创建时跑一次。
5.2 为什么生产不推荐 local-exec
Ansible 在 Terraform 的进程里跑,日志混在一起,失败难以归因;需要跑 Terraform 的机器同时具备 Ansible 与凭据,权限放大;terraform destroy 时 null_resource 不会自动「反配置」;长时间运行会撑爆 Terraform 的超时与 CI Job 时限。
5.3 Ansible 的 Terraform 模块
Ansible 官方有 community.general.terraform 模块,方向相反——在 Ansible 里调 Terraform:
- name: 确保基础设施就绪
community.general.terraform:
project_path: /opt/infra/envs/prod
state: present
force_init: true
register: tf_result
- name: 用输出的地址配置应用
ansible.builtin.template:
src: app.conf.j2
dest: /etc/app/app.conf
vars:
db_host: "{{ tf_result.outputs.db_host.value }}"
这适合「Ansible 是主控、Terraform 是其中一个步骤」的场景。但要注意:这会让 Ansible 持有云凭据,权限边界需要重新评估。多数团队更愿意让 Terraform 作为独立阶段运行。
6. 实战:一条完整流水线
一句话总结: 把「Terraform 建资源 → 出清单 → Ansible 配置 → 冒烟验证」串成 CI 阶段,每阶段失败即停,并保留可重跑性。
# .gitlab-ci.yml 片段
stages: [provision, configure, verify]
provision:
stage: provision
script:
- cd envs/prod
- terraform init -backend-config=prod.backend.tfvars
- terraform apply -auto-approve -var-file=prod.tfvars
- terraform output -json > ../../ansible/tf_outputs.json
artifacts: {paths: [ansible/tf_outputs.json]}
configure:
stage: configure
needs: [provision]
script: [cd ansible, ansible-playbook -i inventory/terraform_inventory.py site.yml]
verify:
stage: verify
needs: [configure]
script: [cd ansible, ansible-playbook -i inventory/terraform_inventory.py smoke.yml]
冒烟 playbook 只做只读断言:
- hosts: app
tasks:
- name: 服务在监听
ansible.builtin.wait_for: {port: 8080, timeout: 30}
- name: 健康检查返回 200
ansible.builtin.uri:
url: "http://{{ ansible_host }}:8080/healthz"
status_code: 200
6.1 用模块封装可复用的实例定义
Terraform 侧建议把「可被 Ansible 管理的实例」封装成模块,模块设计
里讨论的接口规范在这里同样适用:模块输出标准化的 ansible_host、instance_id、role,Ansible 侧只依赖这三个字段,不再关心底层是 EC2 还是 VM。
module "app_fleet" {
source = "../../modules/managed-instance"
count = 3
role = "app"
instance_type = "t3.medium"
subnet_ids = module.network.private_subnet_ids
}
output "app_hosts" { value = module.app_fleet.ansible_inventory }
6.2 滚动发布
Ansible 做应用滚动发布时要控制并发:
- hosts: app
serial: 1 # 一次一台,其余保持服务
max_fail_percentage: 0 # 任何一台失败即停
tasks:
- name: 从 LB 摘除 -> 部署新版本 -> 健康检查 -> 挂回 LB
# 每个步骤都应有失败即回滚的处理
7. 反模式与排错
一句话总结: 最常见的三类问题是「清单过期、权限不足、顺序错乱」,排查时先确认 Ansible 看到的主机列表与 Terraform 的实际情况是否一致。
7.1 反模式清单
- 在 remote-exec 里跑长脚本:不可重放、不可见、易超时。
- Ansible 里创建云资源:绕过 Terraform 的 state,产生「影子资源」。
- 手工维护静态清单:实例换了 IP 忘了改,playbook 打到老机器上。
- Terraform 与 Ansible 都管同一个配置文件:互相覆盖。
- Ansible 里硬编码 IP:应全部走清单变量。
7.2 常见故障
| 现象 | 原因 | 处理 |
|---|---|---|
UNREACHABLE | 安全组没放行 SSH / 私钥不对 | 查 SG 规则与 ansible_ssh_private_key_file |
| 清单里主机数为 0 | output 名不对 / state 未刷新 | terraform output -json 核对字段名 |
python 找不到 | 镜像无 python,Ansible 无法执行模块 | user_data 预装 python3 |
| 配置跑了两遍 | 未用 serial,多台并发改共享资源 | 加 serial 与锁 |
| 实例重建后配置丢失 | 没有在 apply 后重跑 configure | CI 阶段串联,别让中间态停留 |
7.3 排错命令
ansible-inventory -i inventory/terraform_inventory.py --graph # 确认清单内容
ansible -i inventory/terraform_inventory.py app-0 -m ping # 单机连通性
ansible-playbook -i inventory/terraform_inventory.py site.yml --check --diff # dry-run
ansible-playbook -i inventory/terraform_inventory.py site.yml --limit app-0 # 限制范围
一句话收尾: Terraform 与 Ansible 协同的关键不是「怎么把两者接起来」,而是「把边界画清楚、把数据接口固定下来」。边界清晰后,两者各自都是简单可靠的工具;边界模糊时,任何集成方式都会变成事故来源。规模小的时候用动态清单脚本足够,规模大了就退到「标签契约 + 云清单」,让两个工具真正解耦。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。