最近,我们团队做了一个重要的决定:把基础设施从手动管理,迁移到Terraform,用代码来定义和管理云资源。
以前,我们的基础设施都是手动在云控制台里点来点去创建的,服务器、数据库、负载均衡、网络配置,全靠手动操作。这种方式,问题很多:配置不一致,同样的环境,测试和生产配置不一样;没有版本控制,谁改了什么、什么时候改的,完全不知道;难以复现,环境出了问题,想重建一个一模一样的,非常困难;新人上手慢,要记一堆手动操作步骤。
所以,我们决定引入基础设施即代码(Infrastructure as Code,IaC),用Terraform来管理所有的云资源。Terraform是HashiCorp出品的开源工具,用声明式的配置语言(HCL)来定义基础设施,支持多云(AWS、Azure、GCP等),有强大的社区和生态。
本以为,引入Terraform之后,我们的运维会变得更高效、更可靠、更可追溯。结果没想到,Terraform的坑这么多,一个接一个,让我熬了好几个通宵,差点放弃。
今天,我想记录一下我在使用Terraform过程中遇到的那些让我熬夜的问题,包括状态文件管理、资源依赖、循环和条件、模块复用、provider版本、多云部署、以及我是怎么解决这些问题的。希望能给正在用或者准备用Terraform的朋友一些参考,少走一些弯路。
一、状态文件管理:最容易出大事的坑
第一个让我熬夜的问题,是Terraform的状态文件(terraform.tfstate)管理。
Terraform的工作原理,是通过状态文件来记录当前基础设施的真实状态。每次执行terraform plan或者terraform apply的时候,Terraform都会对比配置文件(你想要的状态)和状态文件(当前的真实状态),然后计算出需要做哪些变更。
所以,状态文件是Terraform的核心,非常重要。如果状态文件丢了,或者和真实状态不一致了,Terraform就不知道当前基础设施是什么样子了,就会出大问题。
最开始,我们对状态文件的重要性认识不足,踩了好几个大坑。
坑1:状态文件放在本地,被误删或者丢失
最开始,我们把状态文件放在本地,和代码一起。结果,有一次,一个同事清理本地文件的时候,不小心把terraform.tfstate文件删了。然后,他重新执行terraform apply,Terraform发现状态文件是空的,以为所有资源都不存在,就试图重新创建所有资源。结果,因为资源已经存在,创建失败,报了一堆错误,整个基础设施的管理都乱了。
那天晚上,我熬到凌晨三点,手动对比云控制台里的真实资源和配置文件,用terraform import命令一个一个地把资源导入到状态文件里,才把状态恢复过来。
解决方案:状态文件绝对不能放在本地,必须放在远程存储里,并且开启版本控制和备份。我们后来用了AWS S3来存储状态文件,开启了版本控制(S3 Versioning)和服务器端加密,并且用DynamoDB来做状态锁(State Locking),防止多人同时操作导致状态冲突。
配置大概是这样的:
terraform {
backend "s3" {
bucket = "my-terraform-state"
key = "prod/terraform.tfstate"
region = "us-east-1"
encrypt = true
dynamodb_table = "terraform-locks"
}
}这样,状态文件存在远程S3里,不会因为本地文件误删而丢失;开启了版本控制,即使状态文件被误改,也能回滚到之前的版本;用DynamoDB做锁,防止多人同时apply导致状态冲突。
坑2:多人协作,状态冲突
第二个坑,是多人协作的时候,状态冲突。最开始,我们没有用状态锁,两个同事同时执行terraform apply,结果两个人都修改了状态文件,最后状态文件被覆盖,和真实状态不一致了。
解决方案:就是上面说的,用DynamoDB(或者其他支持锁的后端)做状态锁。当一个人执行terraform apply的时候,Terraform会先获取锁,其他人这时候执行apply就会失败,提示状态被锁定。这样就避免了多人同时操作导致的状态冲突。
坑3:状态文件里有敏感信息
第三个坑,是状态文件里可能包含敏感信息。比如,数据库的密码、API密钥等,Terraform会把这些信息明文存在状态文件里。如果状态文件放在代码仓库里(用Git管理),那这些敏感信息就泄露了。
最开始,我们把状态文件也提交到了Git仓库,结果数据库密码就泄露了。虽然是内部仓库,但是这也是一个安全隐患。
解决方案:状态文件绝对不能提交到Git仓库,要在.gitignore里排除.tfstate和.tfstate.backup。状态文件放在远程存储里(比如S3),并且开启加密。敏感信息不要硬编码在配置文件里,要用环境变量或者密钥管理服务(比如AWS Secrets Manager、HashiCorp Vault)来管理。
二、资源依赖:顺序不对就报错
第二个让我熬夜的问题,是资源依赖(Resource Dependencies)。
Terraform会自动分析资源之间的依赖关系,按照正确的顺序创建和销毁资源。比如,一个EC2实例依赖于一个VPC和一个子网,Terraform会先创建VPC和子网,再创建EC2实例。
但是,有些依赖关系,Terraform无法自动分析出来,需要我们显式地声明。如果依赖关系没处理好,就会出现创建顺序不对,导致报错。
坑1:隐式依赖没识别到
最常见的坑,是Terraform没有识别到隐式依赖。比如,我们创建了一个EC2实例,然后在这个EC2实例上用userdata脚本安装了一个软件,这个软件需要连接一个RDS数据库。配置文件里,EC2和RDS是两个独立的资源,Terraform可能会同时创建它们,结果EC2启动的时候,RDS还没创建好,userdata脚本连接数据库失败,EC2初始化失败。
解决方案:用dependson显式声明依赖关系。在EC2资源里加上dependson = ["awsdbinstance.my_db"],告诉Terraform,EC2依赖于RDS,必须等RDS创建好之后再创建EC2。
resource "aws_instance" "my_server" {
# ... 其他配置 ...
user_data = "${file("init.sh")}"
depends_on = ["aws_db_instance.my_db"]
}坑2:销毁顺序不对
另一个坑,是销毁资源的时候顺序不对。比如,一个VPC里有子网、安全组、EC2实例等资源。销毁的时候,Terraform需要先销毁EC2实例,再销毁子网和安全组,最后销毁VPC。如果顺序不对,就会报错,比如VPC里还有资源,无法删除VPC。
大部分时候,Terraform能自动处理好销毁顺序。但是有些复杂的场景,还是会出问题。
解决方案:同样用depends_on显式声明依赖关系,Terraform在销毁的时候,会按照依赖关系的反顺序来销毁。另外,销毁的时候可以先用terraform plan -destroy看看销毁计划,确认顺序没问题再执行terraform destroy。
坑3:循环依赖
还有一个比较少见但是很头疼的坑,是循环依赖。比如,资源A依赖于资源B,资源B又依赖于资源A,这就形成了循环依赖,Terraform会报错,无法创建。
这种情况,通常是设计有问题,需要重新梳理资源之间的关系,把循环依赖打破。比如,把其中一个资源的配置拆分成两部分,一部分不依赖于另一个资源,先创建,另一部分依赖于另一个资源,后创建。
三、循环和条件:HCL的表达能力有限
第三个让我熬夜的问题,是Terraform的HCL语言在循环和条件表达上的限制。
我们用的是Terraform 0.11版本(2018年4月的时候,0.12还没发布),这个版本的HCL,表达能力比较有限,不支持for循环,不支持if条件语句,很多复杂的逻辑写起来很费劲。
坑1:创建多个相似资源,只能复制粘贴
最常见的需求,是创建多个相似的资源。比如,创建3台EC2实例,配置都一样,只是名字和IP不同。在普通的编程语言里,用一个for循环就能搞定。但是在Terraform 0.11里,没有for循环,只能用count参数。
count参数可以创建多个相同的资源,但是如果每个资源的配置有细微差别(比如名字不同),就需要用element函数配合count.index来实现:
resource "aws_instance" "server" {
count = 3
ami = "ami-12345678"
instance_type = "t2.micro"
tags {
Name = "server-${count.index}"
}
}这样可以创建3台EC2,名字分别是server-0、server-1、server-2。
但是,count有一个很大的坑:如果你修改了count的值,比如从3改成2,Terraform会销毁最后一个资源(index=2的那个)。但是如果你在列表中间插入或者删除了一个元素,Terraform会认为后面所有的资源都变了,会销毁重建后面所有的资源,这可能会导致严重的问题。
比如,你有3台服务器,名字是["web", "app", "db"],用count创建。后来你想在中间加一台,变成["web", "cache", "app", "db"]。这时候,Terraform会认为index=1的资源从"app"变成了"cache",index=2的从"db"变成了"app",然后会销毁重建这两台服务器,这可能会导致服务中断。
解决方案:对于这种需要灵活增删的资源列表,不要用count,而是把每个资源单独定义,或者用模块(module)来封装。另外,Terraform 0.12引入了for_each,可以解决这个问题(但是我们用的时候0.12还没发布)。
坑2:条件创建资源,很绕
另一个常见需求,是根据条件来决定是否创建某个资源。比如,生产环境创建一个高可用的数据库,测试环境创建一个单节点数据库。在普通编程语言里,用if语句就能搞定。但是在Terraform 0.11里,没有if语句,只能用count = "${var.env == "prod" ? 1 : 0}"这种技巧来实现条件创建。
resource "aws_db_instance" "prod_db" {
count = "${var.environment == "production" ? 1 : 0}"
# 生产环境的高可用配置
multi_az = true
# ...
}
resource "aws_db_instance" "test_db" {
count = "${var.environment == "test" ? 1 : 0}"
# 测试环境的单节点配置
multi_az = false
# ...
}这种写法,虽然能实现条件创建,但是很绕,可读性不好,而且引用的时候也很麻烦,需要用element函数:"${element(awsdbinstance.prod_db.*.address, 0)}"。
解决方案:尽量把不同环境的配置拆分成不同的目录或者workspace,不要在一个配置文件里用太多条件逻辑。条件逻辑太多,配置文件会变得很难维护。
四、模块复用:看起来美好,用起来坑多
第四个让我熬夜的问题,是Terraform的模块(Module)复用。
Terraform的模块,是把一组相关的资源封装在一起,作为一个可复用的单元。比如,一个"VPC模块",包含了VPC、子网、路由表、网关等资源,可以在不同的项目里复用。
模块看起来很美好,能提高代码复用率,减少重复代码。但是实际用起来,坑很多。
坑1:模块的版本管理
第一个坑,是模块的版本管理。最开始,我们把模块放在本地目录里,用相对路径引用:
module "vpc" {
source = "./modules/vpc"
# ...
}这种方式,模块和主配置在同一个代码仓库里,修改模块的时候,所有引用这个模块的地方都会受影响。如果模块的修改不兼容,就会导致其他项目出问题。
后来,我们把模块拆到了独立的Git仓库里,用Git标签来做版本管理:
module "vpc" {
source = "git::https://github.com/myorg/terraform-aws-vpc.git?ref=v1.2.0"
# ...
}这样,每个项目可以指定使用哪个版本的模块,模块的升级不会影响其他项目。
但是,这种方式也有坑:模块的版本号要管理好,要遵循语义化版本(Semantic Versioning),不兼容的修改要升大版本号。而且,模块的文档要写清楚,包括输入参数、输出参数、使用示例等,否则别人不知道怎么用。
坑2:模块的灵活性和通用性的矛盾
第二个坑,是模块的灵活性和通用性的矛盾。模块要通用,就要支持很多参数,通过变量来配置不同的场景。但是参数太多,模块就会变得很复杂,很难维护,也很难用。
比如,一个EC2模块,要支持不同的实例类型、不同的AMI、不同的安全组、不同的标签、不同的用户数据、是否挂载EBS卷、是否分配公网IP等等。参数可能有几十个,用起来要传一大堆参数,还不如直接写资源定义方便。
解决方案:模块不要设计得太通用,要针对具体的场景。比如,不要做一个"万能EC2模块",而是做几个针对不同场景的模块,比如"Web服务器模块""数据库服务器模块""缓存服务器模块",每个模块的参数少一些,针对性强一些,用起来更方便。
另外,模块的粒度要适中,不要太大(一个模块包含几十种资源),也不要太小(一个模块只有一个资源)。一般来说,一个模块应该包含一组紧密相关的资源,共同完成一个功能。
坑3:模块里的资源无法直接引用
第三个坑,是模块里的资源无法在主配置里直接引用,必须通过模块的输出(output)来暴露。如果模块没有输出某个属性,主配置里就用不了。
最开始,我们写模块的时候,输出写得不全,结果主配置里需要用到模块里某个资源的属性的时候,发现没有输出,只能回去改模块,加输出,然后重新初始化,很麻烦。
解决方案:写模块的时候,把可能用到的资源属性都通过output暴露出来。虽然可能有些输出暂时用不到,但是总比用到的时候发现没有要好。另外,模块的输出要写清楚文档,说明每个输出是什么意思。
五、Provider版本:不锁定版本就出事
第五个让我熬夜的问题,是Provider的版本管理。
Terraform通过Provider(提供者)来和不同的云平台交互,比如AWS Provider、Azure Provider、GCP Provider等。Provider是独立于Terraform核心发布的,有自己的版本号。
最开始,我们没有锁定Provider的版本,配置文件里只写了provider "aws" {},没有指定版本。结果,有一次,AWS Provider发布了一个新版本,有一些不兼容的变更。我们的一个同事执行了terraform init,自动下载了最新版本的Provider,然后执行terraform apply,结果报了一堆错误,因为配置文件里的某些参数在新版本里被废弃或者改名了。
那天晚上,我又熬到很晚,一个一个地修改配置文件,适配新版本的Provider,才把基础设施恢复正常。
解决方案:一定要锁定Provider的版本,在配置文件里指定版本号:
provider "aws" {
version = "~> 1.30.0"
region = "us-east-1"
}~> 1.30.0的意思是,使用1.30.x系列的最新版本,不会自动升级到1.31.0,这样可以避免不兼容的大版本变更。
另外,还可以用terraform version命令查看当前使用的Provider版本,团队里所有人都要用相同的版本,避免因为版本不一致导致的问题。
六、其他一些让我熬夜的小坑
除了上面几个大坑,还有一些小坑,也让我熬了不少夜。
坑1:terraform init之后,模块和Provider的更新
修改了模块的source或者Provider的版本之后,需要重新执行terraform init,Terraform才会下载新的模块和Provider。如果忘了执行init,就会用旧的版本,导致配置和实际使用的不一致。
解决方案:修改了模块source或者Provider版本之后,一定要执行terraform init。可以在CI/CD流水线里,每次都执行terraform init,确保用的是正确的版本。
坑2:资源的强制更新(Force New)
有些资源的某些参数,修改之后会导致资源被销毁重建(Force New)。比如,AWS EC2的ami参数,修改之后,Terraform会销毁旧的EC2实例,创建一个新的。如果这个EC2是生产环境的服务器,销毁重建就会导致服务中断。
最开始,我们没有注意到哪些参数是Force New的,修改了一个参数,结果terraform apply的时候,Terraform说要替换一个生产环境的EC2实例,吓出一身冷汗,赶紧取消了。
解决方案:执行terraform apply之前,一定要仔细看terraform plan的输出,确认哪些资源是要修改的,哪些是要销毁重建的。对于生产环境,尤其要小心。可以用terraform plan -out=plan.txt把计划保存到文件里,仔细检查之后再执行terraform apply plan.txt。
另外,对于不能轻易销毁重建的资源(比如数据库、生产服务器),要在配置里加保护,或者用lifecycle的prevent_destroy参数防止误删:
resource "aws_db_instance" "prod_db" {
# ... 其他配置 ...
lifecycle {
prevent_destroy = true
}
}这样,如果有人试图销毁这个数据库,Terraform会报错,阻止销毁操作。
坑3:导入现有资源(terraform import)
如果有一些资源是手动创建的,不是Terraform管理的,想把它们纳入Terraform管理,需要用terraform import命令把资源导入到状态文件里。
但是,terraform import有很多限制:不是所有资源都支持import;import的时候,需要手动写好配置文件,然后再执行import;import只能导入资源到状态文件,不会自动生成配置文件。
最开始,我们导入一个现有资源的时候,以为import会自动生成配置,结果import之后,状态文件里有了资源,但是配置文件里没有,下次执行terraform plan的时候,Terraform说要销毁这个资源(因为配置里没有),吓了一跳。
解决方案:import之前,先在配置文件里写好资源的定义(尽量和真实配置一致),然后执行terraform import把资源导入到状态文件,然后执行terraform plan确认没有差异(或者只有很小的差异),再执行terraform apply同步配置。
七、我的一些经验总结
踩了这么多坑之后,我总结了一些使用Terraform的经验,分享给大家:
- 状态文件是生命线:一定要放在远程存储,开启版本控制、加密和状态锁,绝对不能放在本地,绝对不能提交到Git。
- 执行plan再apply:每次修改之后,先执行terraform plan,仔细检查变更计划,确认没问题再执行terraform apply。生产环境尤其要小心。
- 锁定版本:Terraform核心版本、Provider版本、模块版本,都要锁定,避免因为版本升级导致的不兼容问题。
- 模块化但不过度模块化:模块能提高复用率,但是不要过度模块化,模块的粒度要适中,针对性要强,不要做"万能模块"。
- 配置文件要写注释:复杂的配置、特殊的处理、为什么这么写,都要写注释,方便自己和别人理解。
- 用workspace或者目录区分环境:开发、测试、生产环境,要用不同的workspace或者不同的目录,不要在一个配置里用太多条件逻辑。
- 定期备份状态文件:虽然远程存储有版本控制,但是也要定期备份状态文件,以防万一。
- 团队培训和规范:团队里所有人都要接受Terraform的培训,遵守统一的规范,比如命名规范、目录结构、模块使用规范等。
八、写在最后
Terraform是一个非常强大的工具,基础设施即代码(IaC)也是未来的趋势。用好了,能大大提高运维效率,让基础设施更可靠、更可追溯、更易复现。
但是,Terraform的学习曲线也比较陡,坑也比较多。特别是状态文件管理、资源依赖、模块复用这些核心概念,如果理解不透彻,很容易出问题,而且一出问题就是大问题(比如整个基础设施乱掉)。
我踩了这么多坑,熬了这么多夜,最大的感受是:Terraform的核心不是语法,而是思维方式的转变。从"手动操作基础设施"转变为"用代码定义基础设施",需要理解基础设施的状态管理、依赖关系、版本控制等概念,这些和写应用代码是不一样的。
但是,一旦你理解了这些概念,掌握了Terraform的正确用法,你会发现,基础设施管理变得前所未有的简单和可靠。你可以用代码来定义整个基础设施,可以用Git来版本控制,可以用CI/CD来自动化部署,可以轻松地复制和重建环境。这些,都是手动管理无法比拟的。
所以,如果你还在手动管理基础设施,我推荐你试一试Terraform。虽然学习过程会有一些坑,但是坚持下来,你会发现一切都是值得的。
最后,用一句话总结我的Terraform踩坑经历:"Terraform,用之前觉得很简单,用的时候发现坑很多,踩完坑之后觉得很强大。它的坑,都是因为我们对它的核心概念理解不深;它的强大,在于它用代码的方式,重新定义了基础设施管理。"
愿每一个用Terraform的朋友,都能少踩一些坑,多享受一些基础设施即代码带来的便利和可靠。
评论(0)
暂无评论,快来抢沙发~
评论功能仅对会员开放,请先登录
登录