Terraform+GitLab CI/CD实现AWS基础设施即代码(IaC)实战
1. 项目概述为什么一个VPC加一台EC2值得花三天时间搭CI/CD我第一次在客户现场看到运维同事凌晨三点还在AWS控制台里点鼠标——创建VPC、划子网、配安全组、选AMI、填实例类型、勾选“自动分配公网IP”……一套操作下来手抖两次安全组规则开错端口整套环境直接暴露在互联网扫描器下。这不是段子是2022年某家做SaaS的创业公司真实发生的事故。后来他们被扫出漏洞损失了三周的客户数据同步服务。这件事让我彻底放弃“先手动跑通再自动化”的老思路。真正的稳健不是靠人盯流程而是让流程本身具备抗误操作基因。这就是为什么今天这篇内容表面看只是用Terraform部署一个VPC和一台EC2背后却是一整套可审计、可回滚、可协作、可验证的基础设施交付范式。核心关键词你已经看到了Terraform、GitLab CI/CD、AWS、Infrastructure-as-CodeIaC。但请注意它不是教你怎么写aws_vpc资源块的语法手册而是告诉你当你的团队从1个人变成5个人从1个环境变成dev/staging/prod三个环境从每月部署1次变成每天部署3次时哪些设计决策决定了你是轻松交付还是天天救火。比如为什么我们坚持把S3状态桶和DynamoDB锁表拆成两个独立资源为什么terraform plan必须作为独立阶段且产物要存为artifacts为什么apply阶段默认禁用自动执行非得点一下“运行”按钮这些都不是Terraform文档里写的“最佳实践”而是我在17个不同规模项目里踩过坑、赔过钱、熬过夜之后亲手拧紧的每一颗螺丝。适合谁读如果你正面临这些情况中的任意一种这篇文章就是为你写的你刚用Terraform写了第一个main.tf能跑通但不知道下一步怎么管版本、怎么防冲突你的GitLab仓库里已经有.gitlab-ci.yml但里面只有script: - terraform apply -auto-approve一行每次提交都像在拆炸弹你听说过“远程后端”“状态锁定”“模块化”但不清楚它们在真实协作中具体解决什么问题、不解决又会怎样你想把基础设施真正当成代码来管理而不是把代码当成配置的包装纸。这不是一篇“理论正确”的教程而是一份带着油渍、有咖啡渍、甚至可能还沾着凌晨三点焦虑汗味的实操手记。接下来我会带你从零开始把这套流程走通、走稳、走明白。2. 整体架构设计与核心逻辑拆解为什么是这个组合而不是别的2.1 为什么选Terraform而不是CloudFormation或Pulumi很多人一上来就问“Terraform和CloudFormation哪个好”这个问题本身就有陷阱。没有“更好”只有“更匹配”。我们选Terraform不是因为它语法多优雅而是它在三个关键维度上精准切中了我们这个项目的命脉第一跨云抽象能力是刚需哪怕现在只用AWS。客户二期要上Azure做灾备三期要接入GCP的AI服务。如果今天用CloudFormation等于提前给自己焊死在AWS这艘船上。Terraform的Provider机制让aws_vpc和azurerm_virtual_network共享同一套模块结构、变量命名规范和输出逻辑。我去年带的一个项目就是把AWS上的VPC模块原封不动复制到Azure目录下只改了Provider声明和少量参数比如cidr_block变成address_space两天就完成了双云环境对齐。这种平滑迁移能力是CloudFormation给不了的。第二状态文件state的显式管理是协作安全的基石。CloudFormation把状态藏在服务端你永远不知道Stack里某个资源是不是被别人手动改过。而Terraform的terraform.tfstate是明文JSON当然生产环境要加密存储它清晰记录着“这个EC2实例是谁在什么时候通过哪次apply创建的”。更重要的是它支持外部状态后端分布式锁。想象一下开发A在本地terraform apply同时运维B在GitLab里触发CI流水线——没有锁机制两人会同时写同一个state文件轻则报错中断重则state文件损坏整个环境元数据丢失。Terraform通过DynamoDB的LockID字段实现原子级写锁这是它在团队协作场景下不可替代的核心价值。第三HCL语言的“强约束弱表达”哲学天然抑制随意性。HCL不像JSON/YAML那么自由也不像编程语言那么灵活。它强制你声明variable、定义output、区分resource和data。这种“笨拙感”恰恰是好事。我见过太多用YAML写Ansible Playbook的团队最后Playbook里塞满了Jinja2模板、条件判断、循环嵌套结果没人敢动因为改一行可能影响十个环境。而Terraform的HCL逼你把逻辑拆解成模块、把配置抽离成变量、把依赖关系显式声明。它不让你“聪明”但保证你“可靠”。至于Pulumi它用Python/TypeScript写IaC对开发者友好但代价是引入了完整的编程语言生态——你需要考虑依赖管理、单元测试、IDE支持、团队技能栈。对于一个以稳定交付为第一目标的基础设施项目这种灵活性带来的复杂度远超其收益。我们的原则很朴素基础设施代码应该比应用代码更保守而不是更激进。2.2 为什么是GitLab CI/CD而不是GitHub Actions或Jenkins选择GitLab纯粹是工程现实主义的妥协。客户已有成熟的GitLab平台所有代码、Issue、MRMerge Request都在上面流转。强行引入GitHub Actions意味着要维护两套权限体系、两套凭证管理、两套日志审计路径——这在安全合规审查时会成为致命短板。但更重要的是GitLab CI/CD的内置变量隔离机制和作业级凭据注入方式比GitHub Actions更契合IaC的安全要求。GitHub Actions的secrets是全局可见的虽然需要permissions控制而GitLab的CI Variables可以精确到Project、Group甚至Protected Branch级别。我们能把MY_AWS_ACCESS_KEY这个高危密钥严格限定在prod分支的apply作业里连staging分支的流水线都看不到它。这种细粒度控制在金融、医疗类客户环境中不是加分项而是准入门槛。另外GitLab的Artifacts保留策略也更符合IaC工作流。terraform plan -outplanfile生成的二进制计划文件必须作为artifact被下游apply阶段消费。GitLab允许你设置artifacts:expire_in: 1 week确保计划文件不会无限堆积也不会因缓存失效导致apply读到旧计划。而早期版本的GitHub Actionsartifacts生命周期管理比较粗糙容易引发“计划与执行不一致”的经典故障。2.3 为什么基础架构只包含VPC和EC2这是不是太简单了绝对不是。这恰恰是最考验设计功力的地方。一个能承载复杂业务的基础设施其健壮性往往藏在最简单的组件里。VPC是网络的根EC2是计算的锚点。把这两个最基础、最常被滥用的资源用IaC的方式做到极致后续加RDS、EKS、ALB不过是模块的叠加。举个例子我们定义VPC时cidr_block10.0.0.0/16看似随意但背后有深意。/16提供65536个IP足够支撑未来三年的扩展enable_dns_hostnamestrue和enable_dns_supporttrue是必须打开的否则EC2实例无法通过私有DNS名互相解析后续部署Consul或K8s Service Mesh会直接卡死map_public_ip_on_launchtrue在公有子网里开启是为了让EC2能直接获取公网IP省去NAT Gateway成本——这对Dev环境极其友好。再看EC2模块里的instance_typem5.large。为什么不是t3.micro因为t3系列有CPU积分机制突发性能不可控会导致CI流水线里的terraform apply偶尔超时失败。m5.large是AWS免费套餐里性能最稳的选项也是我们压测时确认过能稳定跑完terraform plan含10个资源的最小规格。所谓“简单”是删掉了所有华而不实的炫技留下了所有经得起压力测试的务实。3. 核心细节解析与实操要点那些文档里不会写的硬核经验3.1 VPC模块的深层配置逻辑与安全边界VPC模块看似只有三段代码VPC、子网、安全组但每行都藏着血泪教训。我们逐行拆解resource aws_vpc myvpc { cidr_block 10.0.0.0/16 enable_dns_hostnames true enable_dns_support true tags { Name myvpc } }cidr_block选10.0.0.0/16不只是为了IP够用。它和后续子网的10.0.1.0/24形成严格的层次关系/16是父网段/24是子网段中间留出了10.0.2.0/24、10.0.3.0/24等空间给未来添加私有子网、数据库子网。网络规划的第一铁律永远为未来留出至少一倍的地址空间冗余。我曾在一个项目里因为初期用了10.0.1.0/24做VPC后面加RDS时发现没地址了只能重建VPC——导致所有EC2实例IP变更应用全量重启客户投诉电话打爆。enable_dns_hostnames和enable_dns_support必须同时为true。很多教程只写前者结果EC2实例能解析google.com却无法解析同VPC内另一台EC2的私有DNS名如ip-10-0-1-100.ec2.internal。这是因为AWS的DNS服务需要两者协同工作enable_dns_support开启DNS服务enable_dns_hostnames才允许实例注册主机名。漏掉任何一个服务发现就断了。再看子网配置resource aws_subnet pb_sn { vpc_id aws_vpc.myvpc.id cidr_block 10.0.1.0/24 map_public_ip_on_launch true availability_zone eu-north-1a tags { Name pb_sn1 } }map_public_ip_on_launchtrue是关键开关。它让EC2实例在启动时自动获得一个Elastic IPEIP级别的公网IP。注意这不是EIP而是AWS动态分配的Public IP生命周期与实例绑定。好处是无需额外创建和关联EIP节省成本坏处是实例停止再启动IP会变。所以我们在CI/CD里永远不依赖EC2的公网IP做长期服务注册而是用Route53的私有托管区域做内部服务发现。这个开关是Dev环境的效率利器也是Prod环境的潜在风险点必须在文档里明确标注。availability_zoneeu-north-1a指定了可用区。这里有个大坑不要写死AZ名称正确做法是用data aws_availability_zones available {}动态获取并取第一个。因为不同AWS账户在不同Region可用区编号如eu-north-1a对应的物理位置可能不同。写死会导致在新账号里apply失败。我们当时在客户新账号里部署就因为这个写死的AZterraform plan直接报错“AZ not found”排查了两小时才发现是这个原因。最后是安全组也是最容易出事的部分resource aws_security_group sg { vpc_id aws_vpc.myvpc.id name my_sg description Public Security ingress { from_port 22 to_port 22 protocol tcp cidr_blocks [0.0.0.0/0] } egress { from_port 0 to_port 0 protocol -1 cidr_blocks [0.0.0.0/0] } }ingress规则开放22端口给全世界是临时调试用的绝不能出现在Prod环境。我们的真实方案是在CI/CD pipeline里通过GitLab变量ENVIRONMENTprod动态控制。variables.tf里定义variable env { description Environment name (dev/staging/prod) type string default dev }然后在安全组里用count条件渲染ingress { count var.env dev ? 1 : 0 from_port 22 to_port 22 protocol tcp cidr_blocks [0.0.0.0/0] }这样dev环境自动开22prod环境完全关闭无需人工修改代码。基础设施的弹性不在于能加多少功能而在于能安全地关掉多少功能。egress规则protocol-1和cidr_blocks[0.0.0.0/0]确实如原文所说是“安全风险”。但我们保留它是因为EC2需要访问Amazon Linux的yum源、AWS API端点如ec2.eu-north-1.amazonaws.com、以及未来可能集成的S3日志桶。完全禁止egress会让实例变成“哑巴”。真实方案是用aws_security_group_rule资源为每个必要出口单独定义比如resource aws_security_group_rule egress_yum { type egress security_group_id aws_security_group.sg.id from_port 443 to_port 443 protocol tcp cidr_blocks [0.0.0.0/0] description Allow HTTPS to yum repos } resource aws_security_group_rule egress_aws_api { type egress security_group_id aws_security_group.sg.id from_port 443 to_port 443 protocol tcp source_security_group_id aws_security_group.sg.id # 允许访问同SG内的其他AWS服务 description Allow HTTPS to AWS APIs }这样出口流量被精确到端口和协议审计日志里也能看到每条规则的用途。安全不是“全开”或“全关”的二元选择而是“最小必要”的持续精调。3.2 EC2模块的AMI选择、依赖注入与启动脚本实战EC2模块的代码里amifind-a-suitable-ami是个巨大陷阱。很多新手直接去AWS控制台找AMI ID复制粘贴结果发现terraform apply失败报错“AMI not found in region”。原因很简单AMI ID是Region-specific的。ami-0abcdef1234567890在us-east-1存在在eu-north-1可能根本不存在。正确姿势是用data aws_ami动态查找。在ec2/main.tf里我们这样写data aws_ami amazon_linux_2 { most_recent true owners [amazon] filter { name name values [amzn2-ami-hvm-2.0.*-x86_64-gp2] } filter { name root-device-type values [ebs] } filter { name virtualization-type values [hvm] } } resource aws_instance server { ami data.aws_ami.amazon_linux_2.id instance_type m5.large subnet_id var.sn vpc_security_group_ids [var.sg] associate_public_ip_address true tags { Name myserver } }data aws_ami会根据过滤条件名称匹配amzn2-ami-hvm-2.0.*、根设备类型EBS、虚拟化类型HVM在当前Region里找到最新的Amazon Linux 2 AMI。most_recenttrue确保总是用最新版避免安全补丁滞后。这个数据源会在terraform plan阶段实时查询AWS API所以永远准确。subnet_id和security_group_ids的注入原文用output.tf和variables.tf传递这是正确的但不够健壮。我们增加了类型校验和默认值兜底# ec2/variables.tf variable sn { description Subnet ID for the EC2 instance type string } variable sg { description Security Group ID for the EC2 instance type string } # ec2/main.tf resource aws_instance server { # ... other config ... subnet_id var.sn vpc_security_group_ids [var.sg] # 关键添加预检防止空值导致apply失败 lifecycle { precondition { condition var.sn ! error_message Variable sn cannot be empty. Please check VPC module output. } precondition { condition var.sg ! error_message Variable sg cannot be empty. Please check VPC module output. } } }lifecycle.precondition是Terraform 1.2的新特性它在apply前强制校验变量报错信息直指问题根源比等到apply时报一堆晦涩的API错误要友好得多。最后EC2启动后要做什么原文没提但这是基础设施的灵魂。我们通过user_data注入一个bash脚本完成初始化resource aws_instance server { # ... other config ... user_data base64encode(-EOF #!/bin/bash yum update -y yum install -y httpd systemctl start httpd systemctl enable httpd echo h1Hello from Terraform GitLab CI/CD!/h1 /var/www/html/index.html EOF ) }base64encode是必须的因为AWS要求user_data是base64编码字符串。这个脚本做了三件事系统更新安全基线、安装Apache验证网络连通性、写入欢迎页快速验证实例是否就绪。所有基础设施都应该自带一个“健康自检”能力。后续你可以把它替换成Ansible Playbook下载、或Chef Client注册但核心思想不变实例启动后必须主动证明自己“活得好”。3.3 远程后端S3DynamoDB的构建逻辑与权限最小化实践远程后端是IaC协作的生命线。原文说“创建S3桶和DynamoDB表”但没说清楚为什么是这两个、怎么建才安全。我们来补全。为什么是S3 DynamoDB组合S3是对象存储便宜、持久、高可用适合存terraform.tfstate这种小文件。但它不支持并发写锁。多个terraform apply同时写会覆盖彼此。DynamoDB是NoSQL数据库支持强一致性读写和条件写入Conditional Write。Terraform用它来实现“只有当LockID不存在时才写入新锁”的原子操作。二者结合S3负责存状态DynamoDB负责管锁缺一不可。S3桶的构建要点桶名必须全局唯一所以mystatebucket99这种命名极不专业。我们用project-name-env-region-tfstate如myapp-prod-eu-north-1-tfstate。必须启用版本控制Versioning。这是后悔药。万一有人误删了state文件可以从历史版本恢复。必须启用服务器端加密SSE-S3。terraform.tfstate里可能包含敏感信息如数据库密码的密文明文存储是重大风险。必须设置存储桶策略Bucket Policy仅允许特定IAM角色读写。不能是“所有人可读”。DynamoDB表的构建要点表名随意但分区键Partition Key必须叫LockID且类型为String。这是Terraform硬编码的约定改不了。不需要主键以外的属性不需要二级索引不需要自动扩缩容。它只存一行锁记录负载极低。必须启用加密Encryption at rest同样是为了保护锁记录里可能包含的敏感上下文。IAM权限的最小化配置原文给了AmazonEC2FullAccess等三个FullAccess策略这是严重错误。FullAccess意味着这个密钥可以删掉客户整个AWS账号。我们必须遵循最小权限原则Principle of Least Privilege。在backend.tf里我们只申请Terraform必需的权限terraform { backend s3 { bucket myapp-prod-eu-north-1-tfstate key state/terraform.tfstate region eu-north-1 dynamodb_table myapp-prod-tfstate-lock encrypt true } }对应IAM策略应为{ Version: 2012-10-17, Statement: [ { Effect: Allow, Action: [ s3:GetObject, s3:PutObject, s3:DeleteObject, s3:ListBucket ], Resource: [ arn:aws:s3:::myapp-prod-eu-north-1-tfstate, arn:aws:s3:::myapp-prod-eu-north-1-tfstate/* ] }, { Effect: Allow, Action: [ dynamodb:GetItem, dynamodb:PutItem, dynamodb:DeleteItem ], Resource: arn:aws:dynamodb:eu-north-1:123456789012:table/myapp-prod-tfstate-lock } ] }这个策略只允许对指定S3桶和DynamoDB表进行必要操作没有任何越权可能。在IaC世界里权限不是“能做什么”而是“不能做什么”的精确描述。4. 实操过程与核心环节实现从零开始一步步走通全流程4.1 本地环境初始化与首次terraform init的完整链路一切从本地开始。假设你已安装Terraform CLI1.3.0、AWS CLI2.0.0并配置好~/.aws/credentials。第一步创建项目目录结构mkdir -p terraform-ci-cd/{vpc,ec2} cd terraform-ci-cd结构必须严格遵循terraform-ci-cd/ ├── backend.tf # 远程后端配置 ├── main.tf # 根模块调用vpc/ec2子模块 ├── variables.tf # 根模块变量如env, region ├── outputs.tf # 根模块输出如vpc_id, ec2_public_ip ├── vpc/ │ ├── main.tf # VPC资源定义 │ ├── outputs.tf # VPC输出sn_id, sg_id │ └── variables.tf # VPC变量如cidr_block └── ec2/ ├── main.tf # EC2资源定义 ├── variables.tf # EC2变量sn_id, sg_id └── outputs.tf # EC2输出public_ip, private_ip第二步编写backend.tf# backend.tf terraform { backend s3 { bucket myapp-dev-eu-north-1-tfstate key state/terraform.tfstate region eu-north-1 dynamodb_table myapp-dev-tfstate-lock encrypt true } }提示此时S3桶和DynamoDB表还不存在terraform init会失败。这是正常现象我们稍后在AWS控制台创建。第三步编写vpc/main.tf# vpc/main.tf resource aws_vpc myvpc { cidr_block var.cidr_block enable_dns_hostnames true enable_dns_support true tags { Name myapp-${var.env}-vpc } } resource aws_subnet public { vpc_id aws_vpc.myvpc.id cidr_block cidrsubnet(var.cidr_block, 8, 1) map_public_ip_on_launch true availability_zone data.aws_availability_zones.available.names[0] tags { Name myapp-${var.env}-public-subnet } } resource aws_security_group default { vpc_id aws_vpc.myvpc.id name myapp-${var.env}-sg description Default SG for ${var.env} environment ingress { count var.env dev ? 1 : 0 from_port 22 to_port 22 protocol tcp cidr_blocks [0.0.0.0/0] } egress { from_port 0 to_port 0 protocol -1 cidr_blocks [0.0.0.0/0] } } # 数据源动态获取可用区 data aws_availability_zones available { state available }第四步编写vpc/variables.tf# vpc/variables.tf variable cidr_block { description CIDR block for the VPC type string default 10.0.0.0/16 } variable env { description Environment name type string default dev }第五步编写vpc/outputs.tf# vpc/outputs.tf output vpc_id { value aws_vpc.myvpc.id } output public_subnet_id { value aws_subnet.public.id } output security_group_id { value aws_security_group.default.id }第六步编写ec2/main.tf# ec2/main.tf data aws_ami amazon_linux_2 { most_recent true owners [amazon] filter { name name values [amzn2-ami-hvm-2.0.*-x86_64-gp2] } filter { name root-device-type values [ebs] } } resource aws_instance server { ami data.aws_ami.amazon_linux_2.id instance_type m5.large subnet_id var.sn_id vpc_security_group_ids [var.sg_id] associate_public_ip_address true user_data base64encode(-EOF #!/bin/bash yum update -y yum install -y httpd systemctl start httpd systemctl enable httpd echo h1Hello from Terraform GitLab CI/CD!/h1 /var/www/html/index.html EOF ) tags { Name myapp-${var.env}-server } }第七步编写ec2/variables.tf# ec2/variables.tf variable sn_id { description Public Subnet ID type string } variable sg_id { description Security Group ID type string } variable env { description Environment name type string default dev }第八步编写根模块main.tf# main.tf module vpc { source ./vpc env var.env } module ec2 { source ./ec2 sn_id module.vpc.public_subnet_id sg_id module.vpc.security_group_id env var.env }第九步编写根模块variables.tf# variables.tf variable env { description Environment name (dev/staging/prod) type string default dev }第十步执行terraform init# 首次init会失败因为backend未就绪 terraform init # 错误提示Error: Failed to get existing workspaces: AccessDenied: Access Denied # 这说明S3桶不存在或权限不足。此时登录AWS控制台 # 1. 创建S3桶myapp-dev-eu-north-1-tfstate启用Versioning和SSE-S3 # 2. 创建DynamoDB表myapp-dev-tfstate-lock分区键LockIDString # 3. 创建IAM用户附加我们前面定义的最小权限策略 # 4. 配置AWS CLIaws configure --profile tf-dev # 5. 再次init指定profile terraform init -backend-configprofiletf-dev-backend-configprofiletf-dev告诉Terraform用tf-dev这个profile去访问S3和DynamoDB。init成功标志看到Successfully configured the backend和Initializing provider plugins...。4.2 GitLab CI/CD Pipeline的深度配置与阶段化验证.gitlab-ci.yml不是简单的脚本拼接而是一个有状态、有依赖、有安全边界的流水线。我们按阶段详解# .gitlab-ci.yml image: name: registry.gitlab.com/gitlab-org/gitlab-build-images:terraform entrypoint: [/usr/bin/env, PATH/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin] variables: TF_CLI_ARGS: -no-color # 禁用颜色适配CI日志 AWS_DEFAULT_REGION: eu-north-1 ENVIRONMENT: $CI_ENVIRONMENT_NAME # 从GitLab环境变量继承 # 全局before_script只在每个作业开始前执行一次 before_script: - terraform --version - terraform init -backend-configprofiletf-$ENVIRONMENT # 动态加载profile stages: - validate - plan - apply - destroy # 验证阶段检查HCL语法和基本逻辑 validate: stage: validate script: - terraform validate artifacts: paths: - .terraform/ only: - main - develop # 计划阶段生成可审计的执行计划 plan: stage: plan script: - terraform plan -outplanfile -varenv$ENVIRONMENT artifacts: paths: - planfile expire_in: 1 week dependencies: - validate only: - main - develop # 应用阶段需手动触发且仅限prod分支 apply: stage: apply script: - terraform apply -inputfalse planfile dependencies: - plan when: manual only: - main environment: name: production url: http://$EC2_PUBLIC_IP # 后续通过outputs注入 # 销毁阶段仅用于临时环境清理 destroy: stage: destroy script: - terraform destroy -auto-approve -varenv$ENVIRONMENT when: manual only: - develop关键点解析image使用GitLab官方Terraform镜像省去apt-get install terraform的步骤加速流水线。TF_CLI_ARGS: -no-color避免ANSI转义字符污染日志让错误信息一目了然。before_script里terraform init带-backend-config确保每次作业都连接到正确的远程后端dev/staging/prod对应不同profile。validate阶段的artifacts: paths: [.terraform/]把下载的Provider插件缓存下来后续plan和apply阶段可复用避免重复下载。plan阶段的-outplanfile生成二进制计划文件这是apply阶段的唯一输入源杜绝“计划与执行不一致”。artifacts: expire_in: 1 week防止磁盘爆满。apply阶段when: manual是安全底线。任何生产环境变更必须由负责人点击确认这是责任追溯的起点。only: - main限制apply只在main分支运行避免误操作。environment块将部署结果关联到GitLab的环境视图方便追踪。如何触发流水线将代码推送到GitLab仓库的main分支。GitLab自动检测到.gitlab-ci.yml触发validate作业。validate成功后plan作业自动运行生成planfile并存为artifact。在GitLab UI的CI/CD