一个被问了无数遍的问题:OpenStack 里想一次性拉起一套带网络、虚拟机、安全组的环境,手动敲命令得敲到什么时候?如果用脚本硬拼,网络资源创建失败怎么办、虚机 IP 怎么回传、后面扩容怎么改?我给你的答案是 Heat,而 Heat 的核心载体就是 HOT 模板。这是 OpenStack 编排服务 Heat 的资源描述格式,全称 Heat Orchestration Template,用 YAML 书写,用来声明“我要什么样的云资源组合”。这篇文章我按实际项目里写编排模板的经验,把 HOT 的语法结构、设计思路、实操命令和排错心得完整过一遍,适合刚接触 Heat 的初学者,也适合已经写过一些模板但想系统整理的人。
1. 先搞清楚 HOT 在 Heat 里扮演什么角色
1.1 为什么需要 HOT 模板
Heat 是 OpenStack 里的编排服务,它要做的事情就是把一组相关的云资源当成一个整体去创建、更新和删除。你单独创建一台云主机很简单,但一个典型业务环境往往包含网络、子网、路由、安全组、若干台云主机、浮动 IP、存储卷,它们之间还有依赖关系,比如云主机必须等网络创建完成才能绑定网卡。如果靠手工操作,不仅要记住每一步的依赖顺序,还得自己处理失败后的回滚。HOT 模板就是把这件事变成声明式配置:你在文件里描述最终想要的资源状态,Heat 引擎负责解析依赖、调用各自的 OpenStack 服务、最终把整套环境创建出来。
用“做饭”来类比可能更好理解。手动创建资源就像你在厨房里一边看菜谱一边手忙脚乱地切菜、炒菜、调味,哪个环节出错都得自己救火;HOT 模板相当于你把整个菜谱提前写成一份标准流程,厨师(Heat)照着流程执行,配料(资源参数)齐了就开火,哪一步火候不对还能在出错时把做坏的菜倒掉重来。
1.2 HOT 和 Heat 的老格式 CFN 模板有什么差别
Heat 早期兼容的是 AWS CloudFormation 风格的模板,叫 CFN 格式。后来 OpenStack 社区觉得这种格式偏向 AWS 的写法,很多 OpenStack 特有的资源类型表达起来不够直观,于是推出了 HOT 格式,后缀通常是.yaml或.hot。HOT 在语法上更简洁,比如参数定义用的是parameters而不是Parameters,资源用resources而不是Resources,整体结构更贴合 OpenStack 的术语习惯。现在日常写模板基本都以 HOT 为主,CFN 格式主要用于兼容老项目或者迁移场景,新项目不建议再从 CFN 起步了。
HOT 的版本声明也很有辨识度。模板顶部固定要写heat_template_version: 2015-10-15或更新的版本号。这个版本号决定了哪些语法特性可用,比如conditions条件判断是较新版本才支持的。在 Train 之后的版本里,你还可以看到heat_template_version: yoga这样的写法,照样能识别。
1.3 HOT 能覆盖哪些典型场景
- 环境初始化:比如拉一套带负载均衡、多台 Web 节点、数据库节点的业务环境;
- 定制化镜像配置:搭配
cloud-init或者user_data,在虚机第一次启动时完成软件安装和配置; - 环境克隆:把测试环境模板稍作参数修改,变成生产环境或验收环境;
- 自动扩缩容:结合 Heat 的
OS::Heat::AutoScalingGroup和OS::Heat::ScalingPolicy,在一定告警条件下增加或减少实例数; - 跨层资源编排:网络层、计算层、存储层在同一个模板里统一管理。
如果你只是创建一个单台虚机,用 Horizon 控制台手动点几下就行,杀鸡不必用牛刀。但只要是两个以上资源之间有依赖、需要一套环境反复创建多次、或者希望环境能在必要时一键销毁重建,HOT 就非常值得用。
2. HOT 模板的骨架和关键语法
2.1 最小可用的模板是什么样
先看一个最简例子,不创建任何资源,只作为语法起点:
heat_template_version: 2015-10-15 description: A minimal HOT template. resources: {}这段内容本身没有实际业务价值,但它说明了 HOT 的必要组成:一个版本声明,一个可选的描述,一个resources字段。真实模板里参数、输出、条件都是在这个骨架上扩展。
resources的内容是模板的核心,它是一个映射表,每个 key 是你在模板里用的资源逻辑名。注意,逻辑名不等于 OpenStack 里创建出来的实际资源名。实际资源名由properties里的name字段指定;如果你不指定,Heat 会自动根据栈名和逻辑名拼接生成一个名字。
resources: my_instance: type: OS::Nova::Server properties: flavor: m1.small image: cirros这段表示创建一台 Nova 云主机,flavor 为m1.small,镜像为cirros。这就是一个可直接执行的资源声明。
2.2 参数定义:把容易变的配置暴露出来
一个面向真实使用的模板,不应该把 flavor、镜像 ID、网络 ID 这种经常变化的参数硬编码在资源里面。应该把它们声明为参数,然后在资源里引用。
parameters: image_id: type: string description: Image ID or name to use for instances flavor: type: string default: m1.small network_id: type: string description: Network ID for the instance port参数支持的 type 主要有:string、number、json、comma_delimited_list、boolean。常用约束有constraints,比如设置取值范围、正则校验、长度限制:
parameters: instance_count: type: number constraints: - range: { min: 1, max: 10 } description: Instance count must be between 1 and 10引用参数的方式是{ get_param: image_id },引用后的结果是一个在部署时才会解析的值。这让模板具备很强的复用性——同一套模板,传入不同的参数,就能部署出不同规格的环境。
2.3 资源定义和依赖关系的表达
资源定义是模板里分量最重的部分。每个资源有逻辑名、类型、属性三块。类型决定了 Heat 调用哪个 OpenStack 服务的哪个 API,比如:
OS::Nova::Server:创建云主机OS::Neutron::Net、OS::Neutron::Subnet、OS::Neutron::Router:网络相关OS::Neutron::Port、OS::Neutron::SecurityGroup:端口和安全组OS::Cinder::Volume、OS::Cinder::VolumeAttachment:云硬盘和挂载OS::Heat::Stack:嵌套栈OS::Heat::AutoScalingGroup:弹性伸缩组
依赖关系既有显式的,也有隐式的。显式依赖用depends_on声明:
resources: instance: type: OS::Nova::Server depends_on: [ port ] properties: networks: - port: { get_resource: port }隐式依赖是你在属性里通过get_resource引用了另一个资源的输出,Heat 能根据这个引用自动推断出顺序。实际经验是我更推荐用属性引用表达依赖,能少写很多depends_on。比如云主机要挂到某个端口上,你在networks属性里引用了端口的逻辑名,Heat 自然知道先建端口再建云主机。
get_resource是 HOT 里非常重要的函数。它返回的是被引用资源的一个属性,具体属性名要看资源类型,比如OS::Neutron::Net可以返回show属性里的一系列字段,OS::Nova::Server可以直接返回name、networks等。最常见的用法是把资源本身传给下游资源:
properties: network: { get_resource: app_net }get_attr则是获取资源创建完成后暴露出来的属性,常用于输出。比如:
outputs: instance_ip: description: Floating IP address of the instance value: { get_attr: [ floating_ip, floating_ip_address ] }2.4 输出:栈创建完成后回传关键信息
输出段的作用是把创建完成的资源的关键信息展示给用户,或者传给上层调用方。比如虚机创建好之后,你往往需要知道它的私有 IP 和浮动 IP,在命令行输出里就能直接看到。定义方式:
outputs: private_ip: description: Private IP address of the instance value: { get_attr: [ instance, first_address ] }嵌套栈场景中,父栈可以通过get_attr拿到子栈的outputs,再传给其他资源或展示出来。这种做法在做多环境标准化交付的时候特别有用,比如子栈负责创建数据库环境,父栈只关心子栈输出的连接地址,整体模块化。
2.5 条件判断:按环境决定要不要建某个资源
HOT 从2015-10-15版本开始支持条件语法,适合做“生产环境建浮动 IP、测试环境不建”这类需求。条件定义在conditions下,然后在资源属性里用condition引用。
conditions: create_floating_ip: { equals: [ { get_param: env_type }, "production" ] } resources: floating_ip: type: OS::Neutron::FloatingIP condition: create_floating_ip properties: floating_network: { get_param: external_net }条件里的逻辑可以使用and、or、not、equals组合,功能不复杂,但能显著提高模板的适应性。
3. 从零编写一套 VPC + 多虚机的 HOT 模板
3.1 先理清需求再动手
我每次写模板都会先在纸上画一下资源之间的关系。比如现在要部署一套典型的 Web 环境:一个自定义网络、一个子网、一个路由器连到外部网络,一个安全组放行 SSH 和 HTTP,两台云主机各挂一块数据卷,再绑定一个浮动 IP。画完关系图之后,资源清单和依赖顺序基本就出来了:
- 创建网络
app_net; - 在
app_net下创建子网app_subnet; - 创建路由器
app_router,设置外部网关并添加内部接口; - 创建安全组
web_sg,写入允许 SSH 和 HTTP 的规则; - 创建两台虚机
web_instance_1、web_instance_2,挂到子网,套用安全组; - 创建数据卷并挂载到虚机;
- 给其中一台虚机绑定浮动 IP。
按照这个清单去写模板,就不容易出现“不知道下一个资源该写什么”的问题。
3.2 完整可用的模板示例
下面这份模板是简化并验证过的思路,适合直接拿去做实验。需要注意,镜像、flavor、外部网络名这些需要按实际环境调整。
heat_template_version: 2015-10-15 description: Deploy two web instances with network, router and security group. parameters: image_id: type: string description: Image ID or name used for instances flavor: type: string default: m1.small description: Flavor used for instances external_network: type: string description: External network name or ID for router gateway and floating IP key_name: type: string description: SSH key pair name default: mykey volume_size: type: number default: 20 description: Size of cinder volume in GB resources: app_net: type: OS::Neutron::Net properties: name: app-net app_subnet: type: OS::Neutron::Subnet properties: name: app-subnet network: { get_resource: app_net } cidr: 192.168.100.0/24 gateway_ip: 192.168.100.1 app_router: type: OS::Neutron::Router properties: name: app-router external_gateway_info: network: { get_param: external_network } interfaces: - subnet: { get_resource: app_subnet } web_sg: type: OS::Neutron::SecurityGroup properties: name: web-security-group rules: - protocol: tcp port_range_min: 22 port_range_max: 22 remote_ip_prefix: 0.0.0.0/0 - protocol: tcp port_range_min: 80 port_range_max: 80 remote_ip_prefix: 0.0.0.0/0 web_instance_1: type: OS::Nova::Server properties: name: web-instance-1 image: { get_param: image_id } flavor: { get_param: flavor } key_name: { get_param: key_name } networks: - port: { get_resource: web_port_1 } security_groups: - { get_resource: web_sg } user_data_format: RAW user_data: | #!/bin/bash yum install -y nginx systemctl enable nginx systemctl start nginx web_instance_2: type: OS::Nova::Server properties: name: web-instance-2 image: { get_param: image_id } flavor: { get_param: flavor } key_name: { get_param: key_name } networks: - port: { get_resource: web_port_2 } security_groups: - { get_resource: web_sg } web_port_1: type: OS::Neutron::Port properties: name: web-port-1 network: { get_resource: app_net } fixed_ips: - subnet_id: { get_resource: app_subnet } web_port_2: type: OS::Neutron::Port properties: name: web-port-2 network: { get_resource: app_net } fixed_ips: - subnet_id: { get_resource: app_subnet } volume_1: type: OS::Cinder::Volume properties: size: { get_param: volume_size } name: web-data-1 volume_att_1: type: OS::Cinder::VolumeAttachment properties: instance_uuid: { get_resource: web_instance_1 } volume_id: { get_resource: volume_1 } mountpoint: /dev/vdb floating_ip: type: OS::Neutron::FloatingIP properties: floating_network: { get_param: external_network } port_id: { get_resource: web_port_1 } outputs: web_instance_1_ip: description: Fixed IP of web instance 1 value: { get_attr: [ web_port_1, fixed_ips, 0, ip_address ] } web_instance_2_ip: description: Fixed IP of web instance 2 value: { get_attr: [ web_port_2, fixed_ips, 0, ip_address ] } floating_ip_address: description: Floating IP address value: { get_attr: [ floating_ip, floating_ip_address ] }这份模板里我特意先把端口单独定义出来,而不是让 Nova 直接去自动创建端口。原因很简单:端口单独定义后,我可以在web_port_1上绑浮动 IP,也可以后续在其他资源里精确引用这个 IP。如果你直接让 Nova 自动分配网络,确实省事,但后续再想拿这个 IP 做安全组策略或绑定浮动 IP,就得通过get_attr间接取,语法绕一些,出错概率也高一点。
3.3 写模板时的几个设计细节
网络三件套分开定义,不要图省事使用OS::Neutron::Net的auto_subnet之类的简化写法。生产环境中网络、子网、路由经常需要独立调整,分开定义方便在 update 时只修改其中一部分,而且依赖关系更直观。
安全组规则建议放在安全组资源内部定义。HOT 里也支持独立的OS::Neutron::SecurityGroupRule资源,但规则一多,每个规则一个资源会让模板篇幅膨胀,可读性下降。如果规则很多,我会选择把所有规则都放在rules列表里,除非有特殊需求要按条件创建某条规则。
user_data 里注意格式。Nova 的user_data属性默认需要确认格式是RAW还是EC2。很多镜像期望的是纯 shell 脚本,如果你不写user_data_format: RAW,Heat 可能会用默认格式去解析,导致你的初始化脚本不执行。我第一次踩这个坑的时候排查了很久,最后发现就是少了一行声明。
参数默认值不等于真实资源名。模板中尽量用参数代替硬编码的镜像 ID、flavor 名、网络 ID。因为不同项目、不同区域这些 ID 几乎都不一样,硬编码之后换环境就必须改模板。参数化之后,部署栈时通过-P传参,一个模板通吃多个项目。
4. 用命令行和 Heat 把模板跑起来
4.1 环境准备与语法校验
环境变量是最先要检查的。在命令行操作 Heat 之前,先确认openstack命令能正常连上云环境,执行下面命令查看可用端点:
openstack endpoint list --service orchestration如果能看到 public、internal 的 endpoint,说明编排服务可用。然后可以对模板做语法和结构校验。Heat 提供openstack orchestration template validate命令,在正式创建栈之前先跑一遍:
openstack orchestration template validate --template app_stack.yaml如果返回了完整的模板内容,没有报错,说明 YAML 语法和参数定义基本没问题。如果缺少必填参数,它会直接告诉你哪个参数有问题。养成先 validate 再 create 的习惯,能省掉很多排错时间。
4.2 创建栈
创建栈的命令很简单:
openstack stack create --template app_stack.yaml --parameter image_id=centos-7 --parameter external_network=public --parameter key_name=mykey web-env-stack这里web-env-stack是栈名。执行后可以用openstack stack show web-env-stack查看状态,常见的有CREATE_IN_PROGRESS、CREATE_COMPLETE、CREATE_FAILED。如果失败,用:
openstack stack event list web-env-stack --format json把事件列表里resource_name、resource_status_reason拿出来看,基本能定位到是哪个资源失败、失败原因是什么。这是排查 Heat 问题最主要的手段。
4.3 更新栈:改模板比删了重来更安全
业务环境创建完后,难免要加资源、改安全组规则、换镜像重新部署。直接在原栈上更新是个好选择。Heat 会根据新旧模板的差异决定哪些资源要新建、哪些要更新、哪些要删除。
openstack stack update --template app_stack_v2.yaml --parameter image_id=centos-8 web-env-stack有几个更新时要注意的点:
- 虚机镜像切换时,Heat 默认不会重建虚机,它只会更新能通过 Nova API 直接改的属性。如果你明确要替换虚机,需要在模板里改逻辑名或使用
overwrite相关参数,让旧资源被替换。 - 更新安全组规则时,可能可以原地生效,也可能导致虚机安全组重新关联,留意业务是否受影响。
- 更新网络 CIDR 这类关键参数可能导致依赖它的子网和路由接口重建,底层会有一段资源中断时间,生产环境要选在维护窗口操作。
4.4 删除栈:一键清理全套环境
删除栈时会按依赖顺序删除资源。命令:
openstack stack delete --yes --wait web-env-stack如果你在模板里设置了enable_remove之类的属性,Heat 会遵从。但是有几个坑:
- 云主机如果设置了
delete_termination为 false,或者有数据盘不想被删,资源可能会残留。删除前想清楚哪些数据需要保留。 - 如果资源被外部手动改成不合法状态,导致 Heat 无法正常调用删除 API,栈会一直卡在
DELETE_IN_PROGRESS。这时候检查事件详情,看看是哪个资源卡住。必要时只能手动先删除外部资源,或者使用openstack stack abandon把栈标记为孤儿并停止跟踪。
4.5 查看输出信息
栈创建完成后,输出段的信息需要单独查看:
openstack stack output list web-env-stack openstack stack output show web-env-stack web_instance_1_ip如果模板里输出引用的属性在资源创建时不可用,输出会显示为空或者查询报错。这时候可以等状态稳定后再查一次。
5. 常见问题与排查技巧实录
5.1 资源创建一直卡在 IN_PROGRESS
这种现象多半是依赖链上的某个资源在等待一个永远无法满足的条件。最常见的是 Neutron 路由器接口。OS::Neutron::Router的interfaces属性在 HOT 里虽然好用,但它需要子网接口能够成功添加;如果外部网关所选的外部网络没有正确配置,或者子网 CIDR 和已有路由冲突,接口会一直卡住。我处理这个问题的思路是:先用openstack stack event list看是哪个资源卡住,再用openstack router show <router_name>手工确认路由器状态,最后在openstack subnet show里看接口是否真的加上去了。如果发现是外部网络配置问题,直接修正模板中的external_gateway_info参数,再重新 update 栈。
5.2 参数校验提示镜像或 flavor 不存在
很多时候不是模板问题,而是 Heat 所在项目没有权限看到你传的镜像或 flavor。还有一种是镜像名在不同项目间重名,Heat 解析时取到了错误镜像。我的建议是参数优先传 UUID,不要只传名称。尤其是在多项目共用 OpenStack 环境的场景下,同名镜像在不同项目里指向不同实体,光靠名称根本不可控。在模板的 description 里也顺手写清楚这个镜像用于什么场景,后续维护的人不会一头雾水。
5.3 get_attr 取不到值
最常见的原因是在资源还没完全 ready 时就尝试读取属性。Heat 的依赖推导基于资源间引用关系,但如果你在outputs里直接引用了一个早期资源的属性,而那个属性要等某个后期动作完成后才填充,就会出现取不到或取到空值的情况。
比如浮动 IP 的floating_ip_address属性,在绑定端口前可能为空。解决办法是先确认资源状态,如果还是取不到,把浮动 IP 的绑定动作放到显式依赖后面,或者直接在OS::Neutron::FloatingIP创建时就把port_id绑定好,这样属性在创建完成后一定可用。
5.4 更新栈时资源被意外替换
Heat 判断资源是否需要替换有一套规则。逻辑名不变、类型不变、大多数属性变了,它会尝试原地 update;但如果逻辑名变了、类型变了,或者某些不可变属性变了,则必须删除重建。我遇到过最坑的情况:某个资源逻辑名没变,只是改了name属性,但它实际对应的是 Neutron 网络,而 Neutron 不允许重命名网络,于是 Heat 选择了删除重建,导致整个依赖链上的资源全部重建。这种问题很难完全避免,只能在做大版本变更前认真看openstack stack update --dry-run的结果,确认哪些资源会被替换,再决定要不要执行。
5.5 嵌套栈的父栈创建失败,子栈残留
如果用了OS::Heat::Stack嵌套子栈,父栈失败回滚时,默认应该会级联删除子栈。但我遇到过子栈被手动修改过,或者子栈依赖的外部资源被锁定,导致删除不干净。现在我的习惯是嵌套子栈尽量做成无状态资源,父栈回滚时能快速清理,不在子栈里保留需要人工确认的数据。
5.6 热模板常见坑速查表
| 问题现象 | 可能原因 | 建议处理方案 |
|---|---|---|
| 虚机 user_data 没执行 | 缺user_data_format: RAW | 在 server 属性中显式声明 |
| 参数传了但没生效 | 资源属性里没用get_param引用 | 检查引用写法 |
| 某个资源事件报 403 | 项目权限不足,或资源类型不可用 | 确认角色和 quota 配置 |
| 输出为空 | 属性引用时间过早或资源未 ready | 调整依赖,绑定完成后再取属性 |
| 资源一直删不掉 | 外部手动改过资源属主 | 调查后用栈 abandon 处理 |
6. 版本差异与社区现状
Heat 是 OpenStack 里存在已久的老项目,但很多人误以为它没什么发展了。实际上 Heat 的模板版本一直在演进,新版 OpenStack 里常见的版本号有rocky、train、yoga、2023.1等。较新的版本在条件语法、资源类型覆盖、嵌套栈的错误传递上都有改进。
在写模板的时候,先确定你所在环境的 Heat 版本能支持到什么程度。比如conditions从2015-10-15版本开始支持,repeat循环语句在更晚的版本才可用。如果团队里有多套环境,版本不统一,最好把模板的版本声明统一写到较低能兼容的版本,否则可能出现低版本环境无法解析高版本模板的情况。
还有一点值得关注,社区里容器化、Kubernetes 编排火热,很多人问 Heat 会不会被替代。从我实际使用看,Heat 的价值在于和 Neutron、Nova、Cinder 这些 OpenStack 原生服务的深度集成,尤其是网络和安全组编排,这是很多外部编排工具做不到的。在传统虚机为主的企业私有云里,Heat 仍然是自动化部署环境的第一选择;而容器场景更适合直接把 K8s 作为编排层,Heat 在这类场景下更多承担“创建 K8s 节点所依赖的基础环境”这个角色。
7. 生产环境使用 Heat 的个人建议
写模板永远比读模板容易,但读模板的能力才是把项目长期维护下去的关键。团队里如果多人维护同一套模板,我强烈建议在最开头写好模块注释,说清楚这套模板部署什么环境、哪些参数必填、哪些资源是核心资源、哪些扩展资源允许删除。YAML 不长的时候注释显得多余,但模板写到几百行之后,注释能救你的命。
另一个建议是把公共片段抽成嵌套模板或自定义资源。如果你的多个环境栈都要创建同一套基础网络三件套,把那部分抽成子栈,父栈统一引用。这样网络策略调整时只改子栈模板,不必在每一套环境里分别改。这个重构做完之后,维护成本下降非常明显。
我踩过最深的坑,是生产环境里为了图快,把模板里所有的name属性都省略,指望 Heat 自动生成资源名。结果有一次排查网络问题,Neutron 里看到的资源名和模板逻辑名完全对不上,管理员从 Web 控制台根本看不出来哪个资源属于哪个栈,只能靠标签和经验去匹配。后来我再写模板,必填name属性的资源一律明确命名,就算名字由参数拼接,也要保证可读性。
最后说一个细节:使用openstack stack update前,一定要先跑一次不带实际更新的 dry-run 检查。Heat 提供的--dry-run参数能帮你预览这次更新会新增、更新、替换哪些资源,而不会真的动环境。我见过太多人 skip 了这一步,把端口名一改,结果整个网络链重建了。花一分钟看检查结果,比事后恢复环境节省的时间多得多了。
8. 最后分享一个小技巧
把 HOT 模板纳入版本管理绝对是个被低估的好习惯。我自己的项目目录里每个环境栈都有独立的目录,模板按日期迭代命名,Git 仓库里保留每一次改动记录。当你需要回滚一次变更时,直接找到上一个版本的模板重新 update 栈,比你在脑子里回忆“当初模板长啥样”靠谱一万倍。
还有一个小技巧是善用openstack stack output show获取部署后的 IP 和地址信息,再结合自动化脚本做环境信息汇总。写一个简单的 Bash 脚本,先create stack,再循环output show,最后把 IP、浮动 IP、端点信息统一输出到一个环境清单文件,这套流程用来交付验收环境特别顺手。