Terraform 与 Ansible 协同

划分 Terraform 与 Ansible 的职责边界:动态清单生成的三种方式、terraform output 到 group_vars 的传递与依赖编排、幂等与重建语义差异、四种集成方式对比,以及流水线落地、滚动发布与清单过期、权限不足等常见反模式排错。

1. 职责边界:provision 与 configure

一句话总结: Terraform 负责「把机器造出来」(provision),Ansible 负责「把机器配置好」(configure),二者的边界应画在「资源生命周期」与「软件配置生命周期」之间。

1.1 两个工具的本质差异

维度TerraformAnsible
关注对象云资源的存在与属性主机/系统的期望配置
状态记录远程 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
清单里主机数为 0output 名不对 / state 未刷新terraform output -json 核对字段名
python 找不到镜像无 python,Ansible 无法执行模块user_data 预装 python3
配置跑了两遍未用 serial,多台并发改共享资源加 serial 与锁
实例重建后配置丢失没有在 apply 后重跑 configureCI 阶段串联,别让中间态停留

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 协同的关键不是「怎么把两者接起来」,而是「把边界画清楚、把数据接口固定下来」。边界清晰后,两者各自都是简单可靠的工具;边界模糊时,任何集成方式都会变成事故来源。规模小的时候用动态清单脚本足够,规模大了就退到「标签契约 + 云清单」,让两个工具真正解耦。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「terraform」更多文章

  1. 引导与状态后端自举
  2. 数据平台基础设施即代码
  3. 从 CloudFormation 迁移到 Terraform