SaltStack Formula自动化构建AWS VPC基础设施

发布时间:2026/7/5 18:11:01
SaltStack Formula自动化构建AWS VPC基础设施 1. 项目概述用SaltStack自动化构建AWS VPC——不是写脚本是建基础设施的“施工图纸”你有没有在AWS控制台里点过上百次鼠标只为配好一个VPC子网、路由表、NAT网关、安全组、IGW、EIP……每新建一个环境都要重复一遍这套“点点点”流程。更糟的是开发、测试、预发、生产四套环境配置稍有差异上线前一小时发现测试环境少开了一个端口临时改配置心跳加速手心冒汗——这种经历我干了七年运维和云平台工程至少踩过23次坑。SaltStack VPC公式SaltStack Formulas这个标题背后根本不是教你怎么写几行YAML而是一套把AWS网络基础设施当“建筑蓝图”来管理的方法论它让VPC从“手动搭积木”变成“自动浇筑混凝土”所有配置可版本化、可复现、可审计、可回滚。关键词里的AWS VPC是目标对象SaltStack是执行引擎而Formulas才是灵魂——它不是代码是声明式基础设施的“配方说明书”。适合三类人正在被多环境网络配置压得喘不过气的SRE想把IaC落地但又不想被Terraform状态文件绑架的中小团队以及刚学完AWS基础、正卡在“怎么让配置不随人走”的DevOps新人。它解决的不是“能不能做”而是“能不能每次做得一模一样且出了问题5分钟内拉出上一版重来”。我去年用这套方案重构了公司6个业务线的VPC体系部署时间从平均47分钟压缩到92秒配置漂移率归零。下面拆解的全是我在真实产线反复打磨过的硬核细节。2. 整体设计思路与方案选型逻辑为什么是SaltStack Formula而不是Terraform或CloudFormation2.1 核心矛盾声明式 vs. 过程式谁更适合企业级VPC治理很多人第一反应是“VPC不就该用Terraform吗”——这恰恰是最大误区。Terraform强在资源编排弱在状态治理。举个真实案例某次紧急修复运维直接在控制台删掉了一个被Terraform管理的NAT网关Terraform下次apply时不会报错而是默默重建一个新NAT并更新路由表——结果旧NAT的流量还在跑新NAT空转监控告警全失效。我们花了38分钟定位根源就是Terraform的状态文件和真实云环境脱节。而SaltStack Formula的设计哲学完全不同它不维护“状态快照”而是持续校验强制收敛。Salt的state.apply命令每次执行都会调用AWS API实时查询当前VPC结构比如aws ec2 describe-subnets --filters Namevpc-id,Valuesvpc-xxxx再比对Formula中定义的期望状态如cidr_block: 10.10.20.0/24只对差异项执行操作。这意味着哪怕有人偷偷在控制台改了安全组规则下一次state.apply就会自动把它打回原形。这不是“防君子不防小人”而是把人为误操作纳入系统防御闭环。2.2 SaltStack Formula的不可替代性YAML即文档Git即审计日志SaltStack Formula本质是标准化的YAML模板集合目录结构严格遵循约定saltstack-formula-aws-vpc/ ├── vpc/ # 主模块 │ ├── init.sls # 入口文件定义VPC核心参数 │ ├── subnets.sls # 子网定义公有/私有/数据库专用 │ ├── routing.sls # 路由表与关联 │ └── security.sls # 安全组规则 ├── pillar/ # 敏感数据隔离区VPC ID、密钥等 │ └── example.sls # 环境变量示例 └── README.md # 配方使用说明含参数表这个结构的价值在于YAML文件本身既是代码也是架构文档。开发看subnets.sls就能知道生产环境有3个私有子网每个子网的AZ分布和CIDR安全团队直接审计security.sls里的ingress_rules列表确认是否禁用了SSH公网访问。更重要的是所有变更必须走Git PR流程——谁在什么时候修改了哪个子网的ACL规则Git历史里清清楚楚。我们曾用这个特性快速定位一次合规审计问题安全团队发现某VPC的数据库子网意外开放了3306端口通过git blame vpc/subnets.sls直接查到是DBA在凌晨2点合并的PR5分钟内回滚。这种“代码即审计日志”的能力是Terraform的tfstate文件永远做不到的。2.3 为什么不用CloudFormation——模板膨胀与调试地狱CloudFormation模板动辄上千行JSON/YAML一个VPC模板常包含200行参数定义。更致命的是调试体验CFN堆栈创建失败错误信息只显示CREATE_FAILED你需要翻10层嵌套日志才能找到是哪个安全组规则语法错了。而SaltStack Formula的调试是线性的salt-call state.apply vpc.subnets --log-leveldebug输出会清晰告诉你“第47行map.jinja未找到vpc_cidr变量”甚至标出具体文件路径。我们做过对比测试同样配置一个含6个子网、3个路由表、2个NAT网关的VPCCFN模板维护成本是SaltStack Formula的3.2倍基于Jira工时统计。尤其当需要动态生成子网CIDR如根据VPC主CIDR自动计算10.10.0.0/16下的10.10.10.0/24、10.10.20.0/24SaltStack的Jinja2模板引擎天然支持数学运算CFN却要写复杂的Fn::Select和Fn::Split组合可读性归零。2.4 方案边界SaltStack Formula管什么不管什么必须划清红线SaltStack Formula只负责基础设施的静态结构定义绝不碰应用层逻辑。它会确保VPC存在且CIDR为10.10.0.0/16us-east-1a有公有子网10.10.10.0/24关联IGWus-east-1b有私有子网10.10.20.0/24路由指向NAT网关默认安全组禁止所有入站流量但它绝不会部署EC2实例那是ec2-formula的事配置RDS参数组那是mysql-formula的职责管理IAM策略需单独iam-formula这种“单一职责”设计让每个Formula像乐高积木一样可插拔。我们生产环境同时运行着aws-vpc-formula、aws-eks-formula、aws-rds-formula它们通过Pillar数据共享VPC ID和子网ID但彼此零耦合。上周升级EKS集群时我只改了aws-eks-formula的Kubernetes版本参数VPC结构纹丝不动——这才是企业级IaC该有的稳定性。3. 核心细节解析与实操要点从YAML到真实VPC的12个关键决策点3.1 VPC主CIDR规划别迷信10.0.0.0/8用/16才是生产级起点新手常犯的致命错误为图省事用10.0.0.0/8作为VPC CIDR。看似空间大实则埋雷。AWS要求VPC CIDR必须是连续地址块而10.0.0.0/8包含1677万个IP其中10.0.0.0/1665536个IP常被本地IDC占用。一旦未来要建VPN连接两个10.0.0.0/16网段必然冲突。我们的生产规范是所有VPC强制使用/16掩码且起始地址避开常见网段。例如# pillar/vpc.sls vpc: cidr: 10.10.0.0/16 # ✅ 避开10.0.0.0/16IDC常用、172.16.0.0/12Docker默认 name: prod-vpc计算依据10.10.0.0/16提供65534个可用IP足够支撑500台EC2。若真需要更大规模应采用VPC对等连接VPC Peering而非扩大单个VPC——这是AWS官方推荐的扩展模式。实测中我们曾因CIDR规划不当导致跨区域灾备失败重做VPC耗时17小时。现在所有新VPC都走自动化检查SaltStack在apply前会调用aws ec2 describe-vpcs验证CIDR是否与其他已存在VPC重叠冲突则立即中止。3.2 子网划分策略AZ感知业务分层拒绝“一刀切”子网不是简单按AZ平分CIDR。我们采用三级分层法AZ感知层每个AZ至少部署1个公有子网1个私有子网避免单点故障业务分层层公有子网只放ALB/NAT私有子网按业务域切分web、app、db安全隔离层数据库子网启用enable_dns_hostnames: false彻底阻断DNS解析对应YAML实现# vpc/subnets.sls {% set azs [us-east-1a, us-east-1b, us-east-1c] %} {% set vpc_cidr salt[pillar.get](vpc:cidr, 10.10.0.0/16) %} # 公有子网每个AZ一个用于ALB和NAT {% for az in azs %} public-subnet-{{ az }}: aws_subnet.present: - name: {{ az }}-public - vpc_id: {{ salt[pillar.get](vpc:id) }} - cidr_block: {{ network.calc_subnet(vpc_cidr, loop.index0*2, 24) }} # 自动计算10.10.10.0/24, 10.10.20.0/24... - availability_zone: {{ az }} - map_public_ip_on_launch: true - tags: Name: {{ az }}-public Tier: public {% endfor %} # 数据库私有子网仅在2个AZ部署启用加密 {% for az in azs[0:2] %} db-subnet-{{ az }}: aws_subnet.present: - name: {{ az }}-db - vpc_id: {{ salt[pillar.get](vpc:id) }} - cidr_block: {{ network.calc_subnet(vpc_cidr, loop.index0*210, 24) }} # 10.10.110.0/24, 10.10.120.0/24 - availability_zone: {{ az }} - map_public_ip_on_launch: false - enable_dns_hostnames: false # 关键安全开关 - tags: Name: {{ az }}-db Tier: db Encryption: enabled {% endfor %}提示network.calc_subnet是自定义的Jinja2过滤器输入10.10.0.0/16、偏移量10、掩码24输出10.10.10.0/24。这比硬编码CIDR强100倍——当VPC主CIDR从10.10.0.0/16升级到10.20.0.0/16所有子网自动重新计算无需人工改20个文件。3.3 NAT网关部署为什么必须用弹性IPEIP且每个AZ独立部署很多教程教你在单个AZ部署NAT网关供全VPC使用这是严重反模式。NAT网关是AZ绑定资源若us-east-1a的NAT宕机us-east-1b的私有子网将完全失联。我们的方案是每个AZ部署独立NAT网关独立EIP。YAML关键片段# vpc/nat.sls {% for az in azs %} nat-gateway-{{ az }}: aws_nat_gateway.present: - name: nat-{{ az }} - subnet_id: {{ salt[pillar.get](vpc:subnets:public, {})[az] }} # 关联本AZ公有子网 - allocation_id: {{ salt[pillar.get](vpc:eips:nat, {})[az] }} # 绑定本AZ EIP - tags: Name: nat-{{ az }} AZ: {{ az }} # 路由表关联私有子网只走本AZ NAT private-route-table-{{ az }}: aws_route_table.present: - name: rtb-private-{{ az }} - vpc_id: {{ salt[pillar.get](vpc:id) }} - routes: - destination_cidr_block: 0.0.0.0/0 nat_gateway_id: {{ salt[pillar.get](vpc:nat_gateways, {})[az] }} - subnet_ids: - {{ salt[pillar.get](vpc:subnets:private, {})[az] }} # 仅关联本AZ私有子网 {% endfor %}EIP的必要性在于NAT网关重启后IP会变而EIP是静态的。我们通过Pillar预分配EIP# pillar/eip.sls vpc: eips: nat: us-east-1a: eipalloc-0a1b2c3d4e5f67890 us-east-1b: eipalloc-0b2c3d4e5f67890a1 us-east-1c: eipalloc-0c3d4e5f67890a1b2注意EIP必须在NAT网关创建前分配且需在相同AZ。我们用SaltStack的require_in确保执行顺序aws_eip.present→aws_nat_gateway.present。3.4 安全组精细化控制用“最小权限矩阵”替代“全通规则”安全组不是防火墙它是实例级别的状态化包过滤器。新手常写0.0.0.0/0放行所有端口这是重大风险。我们的做法是为每个业务层定义专属安全组并用矩阵式规则控制流量。例如Web层安全组# vpc/security.sls web-sg: aws_security_group.present: - name: web-sg - description: Web servers security group - vpc_id: {{ salt[pillar.get](vpc:id) }} - rules: # 入站只允许ALB健康检查和HTTPS - ip_permissions: - ip_protocol: tcp from_port: 443 to_port: 443 sources: - {{ salt[pillar.get](alb:security_group_id) }} # ALB安全组ID - ip_protocol: tcp from_port: 80 to_port: 80 sources: - {{ salt[pillar.get](alb:security_group_id) }} # 出站只允许访问App层安全组 - ip_permissions_egress: - ip_protocol: tcp from_port: 8080 to_port: 8080 sources: - {{ salt[pillar.get](app:security_group_id) }}关键技巧用安全组ID而非IP段做源/目标。这样当App层实例扩缩容时安全组ID不变规则自动生效。我们曾因此避免一次DDoS攻击扩散攻击者攻破Web层后因出站规则限制无法扫描App层内网端口。3.5 路由表设计为什么默认路由表必须锁定自定义路由表才是王道AWS为每个VPC创建默认路由表默认允许所有子网互通。这在生产环境是灾难——数据库子网本该只响应App层请求却可能被Web层意外访问。我们的铁律禁用默认路由表所有子网强制关联自定义路由表。实现方式# vpc/routing.sls # 删除默认路由表的所有关联保留其存在但清空子网 default-route-table: aws_route_table.absent: - name: default - vpc_id: {{ salt[pillar.get](vpc:id) }} - purge_subnets: true # 关键参数解除所有子网关联 # 创建专用路由表 web-route-table: aws_route_table.present: - name: rtb-web - vpc_id: {{ salt[pillar.get](vpc:id) }} - routes: - destination_cidr_block: 0.0.0.0/0 gateway_id: {{ salt[pillar.get](vpc:igw_id) }} # 指向IGW - subnet_ids: - {{ salt[pillar.get](vpc:subnets:public, {})|first }} # 仅关联公有子网注意aws_route_table.absent的purge_subnets: true会主动解除子网关联而非等待AWS后台清理。这是防止路由混乱的关键一步。3.6 标签Tags体系用标签驱动自动化而非人工记忆标签不是装饰品是自动化系统的“神经末梢”。我们在所有资源打上4层标签标签键示例值用途EnvironmentprodSaltStack Pillar选择依据Teampayment成本分摊到具体团队ManagedBysaltstack区分手工创建与自动化创建资源TTL30d自动清理过期测试环境YAML中统一注入# vpc/init.sls {% set common_tags { Environment: salt[pillar.get](env), Team: salt[pillar.get](team), ManagedBy: saltstack, TTL: salt[pillar.get](ttl, never) } %} # 所有资源均引用common_tags vpc-resource: aws_vpc.present: - name: {{ salt[pillar.get](vpc:name) }} - cidr_block: {{ salt[pillar.get](vpc:cidr) }} - tags: {{ common_tags }}这套标签体系让后续动作水到渠成aws ec2 describe-instances --filters Nametag:Environment,Valuesstaging一键查所有测试实例aws rds describe-db-instances --filters Nametag:TTL,Values7d自动清理7天前的测试RDS。3.7 Pillar数据隔离敏感信息不进Git用GPG加密环境变量注入Pillar是SaltStack的“秘密保险箱”但很多人直接把AWS密钥写进pillar/aws.sls这是高危操作。我们的生产方案是Pillar文件只存占位符真实密钥通过环境变量注入。目录结构pillar/ ├── top.sls # 环境映射 ├── base/ │ └── aws.sls # 定义占位符access_key: {{ salt[environ.get](AWS_ACCESS_KEY_ID) }} └── prod/ └── vpc.sls # 生产环境具体值执行时# 在CI/CD中设置环境变量 export AWS_ACCESS_KEY_ID$(vault read -fieldvalue secret/aws/prod/key) export AWS_SECRET_ACCESS_KEY$(vault read -fieldvalue secret/aws/prod/secret) salt-call state.apply vpc --pillar-rootpillar实操心得我们曾因Pillar文件误提交密钥导致GitHub泄露紧急启用了Vault GPG双加密。现在所有Pillar中的敏感字段都用{{ salt[gpg.decrypt](encrypted_string) }}包裹解密密钥由CI/CD系统动态注入。3.8 错误处理机制SaltStack的“原子性”如何保障VPC创建不半途而废SaltStack的State系统天然具备原子性单个State如aws_vpc.present要么全成功要么全失败。但跨State依赖如先建VPC再建子网需要显式声明。我们用require和onfail构建韧性链路# vpc/init.sls vpc-create: aws_vpc.present: - name: {{ salt[pillar.get](vpc:name) }} - cidr_block: {{ salt[pillar.get](vpc:cidr) }} - tags: {{ common_tags }} # 子网创建强依赖VPC存在 subnet-create: aws_subnet.present: - name: public-subnet-1a - vpc_id: {{ salt[pillar.get](vpc:id) }} - require: - aws_vpc: vpc-create # 必须vpc-create成功才执行 # 若VPC创建失败自动触发清理 vpc-cleanup: cmd.run: - name: echo VPC creation failed, cleaning up... - onfail: - aws_vpc: vpc-create实测中当VPC CIDR与现有VPC冲突时vpc-create失败subnet-create被跳过vpc-cleanup也不会执行因onfail只针对本State但整个流程在3秒内终止不会留下残缺资源。3.9 性能优化批量API调用与并发控制SaltStack默认串行执行State创建6个子网要调6次AWS API耗时翻倍。我们启用并发# /etc/salt/master state_top_saltenv: base top_file_merging_strategy: same # 关键配置 state_aggregate: True # 合并同类State state_verbose: False # 关闭冗余日志并在State中启用批量# vpc/subnets.sls # 使用salt[aws.query]批量创建而非aws_subnet.present单个调用 batch-subnets: module.run: - name: aws.query - func: create_subnets - kwargs: VpcId: {{ salt[pillar.get](vpc:id) }} SubnetCidrBlocks: - 10.10.10.0/24 - 10.10.20.0/24 - 10.10.30.0/24 AvailabilityZone: us-east-1a实测效果子网创建从12.3秒降至2.1秒。3.10 权限最小化IAM角色策略精确到API级别SaltStack执行需要AWS权限但绝不能给AdministratorAccess。我们的生产策略精确到API{ Version: 2012-10-17, Statement: [ { Effect: Allow, Action: [ ec2:CreateVpc, ec2:DescribeVpcs, ec2:ModifyVpcAttribute, ec2:CreateSubnet, ec2:DescribeSubnets ], Resource: * }, { Effect: Allow, Action: ec2:CreateTags, Resource: arn:aws:ec2:*:*:vpc/* } ] }提示Describe*权限必不可少——SaltStack每次执行都要先查现状。我们曾因漏配DescribeVpcs导致State无限重试日志刷屏。3.11 版本兼容性SaltStack 3000与AWS API的适配陷阱SaltStack 3000版本废弃了aws_ec2模块全面转向aws模块。但AWS API也在演进例如DescribeSubnets在2023年新增Filters参数。我们的应对策略所有Formula锁定SaltStack 3006版本LTS在State中添加API版本检测# vpc/init.sls check-aws-api-version: module.run: - name: aws.query - func: describe_regions - kwargs: Filters: # 如果此参数被忽略说明API太旧 - Name: endpoint, Values: [ec2.us-east-1.amazonaws.com] - onfail: - cmd.run: api-version-warning建立内部API兼容矩阵表每月同步AWS更新日志。3.12 测试验证用InSpec编写基础设施合规性检查Formula部署完不等于万事大吉必须验证。我们用InSpec编写VPC合规检查# test/vpc_spec.rb describe aws_vpc(prod-vpc) do it { should exist } its(cidr_block) { should eq 10.10.0.0/16 } end describe aws_subnets.where{ vpc_id vpc-xxxx } do its(count) { should be 6 } # 至少6个子网 its(map_public_ip_on_launch.count) { should eq 3 } # 公有子网数 end describe aws_security_group(web-sg) do it { should exist } its(ip_permissions.count) { should eq 2 } # 仅2条入站规则 endCI/CD中集成inspec exec test/ --target aws:// --controls vpc_spec.rb失败则阻断发布。4. 实操过程与核心环节实现从零开始部署一个生产级VPC的完整流水线4.1 环境准备SaltStack Master与AWS凭证的最小化配置第一步不是写YAML而是搭建SaltStack执行环境。我们放弃传统Master-Minion架构采用SaltStack SSH模式——无Agent、免维护、权限可控。安装步骤# 在跳板机Jump Host执行 curl -fsSL https://bootstrap.saltproject.io | sudo sh -s -- -P -x python3 sudo systemctl enable salt-master sudo systemctl start salt-master # 配置SSH密钥非密码登录 ssh-keygen -t rsa -b 4096 -f /etc/salt/pki/master/ssh/salt-ssh-key -N ssh-copy-id -i /etc/salt/pki/master/ssh/salt-ssh-key.pub ec2-userjump-host-ipAWS凭证不存本地而是通过IAM角色授予跳板机# 跳板机上执行自动获取临时凭证 aws sts get-caller-identity # 验证角色生效实操心得我们曾用密码登录导致SaltStack日志泄露凭证现在所有跳板机强制使用IAM角色SSM Session Manager彻底杜绝密钥硬编码。4.2 目录初始化创建符合SaltStack Formula规范的项目结构在Git仓库中初始化标准结构mkdir -p aws-vpc-formula/{vpc,pillar,tests} touch aws-vpc-formula/{vpc/init.sls,vpc/subnets.sls,pillar/top.sls} echo base: aws-vpc-formula/pillar/top.sls echo *: aws-vpc-formula/pillar/top.sls echo - vpc aws-vpc-formula/pillar/top.sls关键点pillar/top.sls必须存在否则SaltStack找不到Pillar数据。我们用脚本自动校验#!/bin/bash # validate-structure.sh if ! grep -q vpc pillar/top.sls; then echo ERROR: pillar/top.sls missing vpc reference exit 1 fi if [ ! -f vpc/init.sls ]; then echo ERROR: vpc/init.sls not found exit 1 fi4.3 编写核心VPC State从init.sls到完整的网络骨架vpc/init.sls是入口文件定义VPC主体# vpc/init.sls include: - vpc.subnets - vpc.routing - vpc.security # VPC资源定义 vpc-main: aws_vpc.present: - name: {{ salt[pillar.get](vpc:name, default-vpc) }} - cidr_block: {{ salt[pillar.get](vpc:cidr, 10.10.0.0/16) }} - enable_dns_support: true - enable_dns_hostnames: true - tags: Name: {{ salt[pillar.get](vpc:name, default-vpc) }} Environment: {{ salt[pillar.get](env, dev) }} ManagedBy: saltstack # 记录VPC ID到Pillar供后续State使用 vpc-id-output: module.run: - name: pillar.set_val - m_name: vpc:id - v: {{ salt[aws.query](describe_vpcs, Filters[{Name:tag:Name,Values:[salt[pillar.get](vpc:name)]}])[Vpcs][0][VpcId] }} - require: - aws_vpc: vpc-main注意module.run调用pillar.set_val动态写入VPC ID这是跨State传递数据的关键技巧。4.4 Pillar数据注入用环境变量驱动多环境部署pillar/vpc.sls不写死值而是用环境变量# pillar/vpc.sls vpc: name: {{ salt[environ.get](VPC_NAME, dev-vpc) }} cidr: {{ salt[environ.get](VPC_CIDR, 10.10.0.0/16) }} igw_id: {{ salt[environ.get](IGW_ID, ) }} subnets: public: us-east-1a: {{ salt[environ.get](PUBLIC_SUBNET_A, ) }} us-east-1b: {{ salt[environ.get](PUBLIC_SUBNET_B, ) }} private: us-east-1a: {{ salt[environ.get](PRIVATE_SUBNET_A, ) }}CI/CD中注入# .gitlab-ci.yml stages: - deploy deploy-prod: stage: deploy script: - export VPC_NAMEprod-vpc - export VPC_CIDR10.20.0.0/16 - export AWS_PROFILEprod - salt-call state.apply vpc --pillar-rootpillar environment: production4.5 执行部署从本地测试到生产发布的全流程本地验证Dry Run# 模拟执行不真实调用AWS salt-call state.apply vpc testTrue --log-levelwarning # 查看将要创建的资源 salt-call state.show_sls vpc --outjson | jq .[] | select(.resultnull)生产执行# 设置生产环境变量 export VPC_NAMEpayment-prod-vpc export VPC_CIDR10.30.0.0/16 export AWS_PROFILEpayment-prod # 执行带详细日志 salt-call state.apply vpc --log-levelinfo --state-verboseFalse # 验证结果 salt-call state.show_low_sls vpc | grep -E (name|result)实操心得我们规定所有生产部署必须加--log-levelinfo日志存档30天。曾靠日志快速定位一次NAT网关创建失败日志显示AllocationId not found立刻查Pillar中EIP ID拼写错误。4.6 自动化测试用InSpec验证VPC合规性编写测试用例后在CI中执行# 安装InSpec curl -L https://omnitruck.chef.io/install.sh | sudo bash -s -- -P inspec # 执行测试 inspec exec tests/vpc_spec.rb \ --target aws:// \ --input-file pillar/vpc.sls \ --reporter json:reports/vpc-report.json # 解析报告 cat reports/vpc-report.json | jq .profiles[].controls[].results[] | select(.statusfailed)失败示例{ status: failed, code_desc: aws_vpc payment-prod-vpc should exist, message: expected AwsVpc payment-prod-vpc to exist }此时立即触发告警通知SRE介入。4.7 变更管理Git PR驱动的VPC演进流程所有VPC变更必须走Git Flow开发者fork仓库创建feature/vpc-add-db-subnet分支修改vpc/subnets.sls添加数据库子网定义提交PRCI自动运行salt-call state.apply vpc testTrueDry Runinspec exec tests/vpc_spec.rb合规检查yamllint vpc/*.slsYAML语法检查通过后SRE审核PR重点关注CIDR是否与现有子网冲突用ipcalc工具验证安