OpenStack Heat 编排模板(HOT)是我在私有云项目里从按天手动建虚拟机走向自动化部署时,第一个真正让我觉得“编排该有的样子”的东西。如果你还停留在控制台里一个一个点网络、点路由、点安全组,再创建实例,然后又手动去绑定浮动 IP 的地步,那你花在等待和重复点击上的时间,足够把一套环境重搭好几遍。这篇内容我会从 HOT 模板的语法结构讲起,再用一个真实可跑通的例子把网络、计算资源和输出串起来,最后聊一聊我在生产环境里排错、拆模板、处理依赖关系的经验。适合正在用 OpenStack、想用 Heat 替代手工操作,或者已经在写 HOT 但总被各种属性卡住的工程师。
1. 手动建资源的日子,我为什么走到了 Heat 编排这一步
1.1 依赖顺序与原子化部署,这两个痛点戳中了我
先说我当时最崩溃的场景。一个业务要上线,需要两个后端实例、一个负载均衡、一组安全组规则,还要把新业务接入到已有的子网里。按控制台操作,顺序大概是:先建安全组,再建端口,再建实例,实例启动完成后拿到内网 IP,再配置负载均衡的后端成员。这中间最烦的不是步骤多,而是“顺序错了就白干”。端口没建好,实例绑定不了网卡;安全组规则引用了还没创建的安全组,页面直接报错;实例起来了 IP 地址不像预期的网段,又得删了重建。
到了这一步我才真正理解,Heat 的价值从来不是“多一个建资源的按钮”,而是把创建资源和理顺依赖关系这两件事打包在一起做。在一个 HOT 模板里,我可以同时声明网络、子网、路由器、端口、安全组、实例和浮动 IP,Heat 会根据资源之间的引用关系自动决定创建顺序。比如端口声明时引用了子网的资源名,那么 Heat 就会先建子网再建端口,不需要人工干预。
另外一个好处是原子性。模板提交后,要么整组资源全部创建成功,要么失败时回滚到初始状态。这在测试环境里太重要了。以前手动建到一半发现镜像名写错了,得一个个清理;现在用 Heat 一个 stack 提交上去,某个资源失败,我改好模板重新再跑一次就行,不会留下一堆半成品资源占着配额。从运维效率角度看,这个“整栈生命周期管理”远比单个资源创建 API 来得实用。
1.2 Heat 和 Terraform、Ansible 的关系,不是选边而是分工
很多朋友会问:现在有 Terraform,也有 Ansible,为什么还要学 Heat?我自己的理解是,它们并不完全重叠。Terraform 在跨云统一编排上有优势,Ansible 在配置管理和应用部署上更强,但 Heat 是 OpenStack 的“亲儿子”,它直接对接 OpenStack 的编排服务,原生支持 Neutron、Nova、Cinder、Octavia 等资源类型,而且在业务需要深度使用 OpenStack 特性时,很多资源类型只有 Heat 原生模板才有完整映射。
举一个例子。用 Terraform 管理 OpenStack 资源时,很多底层属性要经过 provider 的封装,遇到某些特殊资源类型时支持度不全。而 Heat 直接面向 OpenStack API 设计,HOT 里能声明的资源覆盖了几乎所有服务组件。还有一点很实际:OpenStack 自带的 Horizon 界面就有堆栈管理页面,团队成员即使不熟悉命令行,也可以在界面上看到 stack 的状态、资源和事件。对小型运维团队来说,这个原生可视化能力省去了一套额外的状态存储和展示系统。
所以在我的项目里,分工是这样的:Heat 负责云资源的生命周期,Ansible 负责资源创建完成后的配置下发,Terraform 只有在需要跨云统一管理时才引入。如果只是管理一套 OpenStack 环境,Heat 反而更直接、更不容易出现状态不同步的问题。
2. HOT 模板的分层拆解:先看懂头部、参数和资源三块
2.1 heat_template_version 到底怎么选
打开一个 HOT 文件,第一眼看到的肯定是heat_template_version。这个字段不只是版本号,它决定了模板里可用的函数、属性和语法范围。OpenStack 官方维护了一个版本日期列表,例如2016-10-14、2018-03-02、2023-09-06等。很多人图省事随便写一个,结果在新版本 OpenStack 上遇到函数不支持,或者在老版本上用了新语法直接报错。
我的建议是:先看部署环境里 Heat 服务的版本,然后用你那个版本所支持的最新日期。怎么查?登录到控制节点执行heat-manage --version,或者直接看源码里heat/engine/hot/template.py中HOT_SPEC_VERSION的取值。实在查不到,稳妥的做法是写一个相对保守的日期,比如2018-03-02,这个版本覆盖了绝大多数常用函数,兼容性也好。需要注意的是,日期版本没有“向下兼容”的默认承诺,老版本引擎解析新版本模板可能直接拒绝,所以生产环境里模板版本和 Heat 服务版本要保持一致。
如果只是自己搭实验环境,直接用随 OpenStack 发行版一起发布的示例模板里面的版本号最省心。那些示例都是经过测试的,至少不会在版本字段上翻车。
2.2 parameters 与 parameter_groups 的设计习惯
HOT 里参数区是整个模板的“输入接口”。写好参数,模板才能在不同环境间复用。我见过不少同事把镜像 ID、网络 ID、规格、密钥对名字直接硬编码在资源属性里,结果换一个环境就要从头改一遍模板。参数设计的第一原则就是:只要外部环境可能变化的值,都尽量做成参数。
参数类型除了常见的string、number、boolean,还有comma_delimited_list和json,后者很适合传递复杂结构。比如安全组规则列表,用 json 类型接收一组对象,再用repeat函数去展开,比逐个写资源要灵活得多。
这里还有一个我比较坚持的习惯:给参数都写default。为什么?因为部署时使用参数文件还好,如果有人在命令行直接跑stack create,少填一个参数就会报参数缺失。给了默认值以后,模板没传参也能跑起来,这在小规模验证时特别方便。当然,密码这类敏感参数不要写在默认值里,建议用外部的 secret 服务或者运行时注入的方式。
parameter_groups是很多人忽略的部分。它用来定义参数在界面上的展示分组,也可以影响命令行--parameter的提示顺序。模板里参数一多,分组就显得很值钱。像网络参数和计算规格参数分成两个 group,运维提交堆栈时一眼就能看清该填什么。
2.3 resources 与 outputs 的正确写法
resources 区是模板的核心。每个资源有三个关键信息:资源名(自定义)、资源类型、属性集。资源名的写法和命名规范直接影响到可读性。我习惯按照“前缀_业务_类型”来命名,比如web_server、db_server、lb_port,这样在 event 列表定位问题时非常方便。资源类型是 Heat 调度到具体 OpenStack 服务的关键,比如OS::Nova::Server对应 Nova,OS::Neutron::Net对应 Neutron,OS::Cinder::Volume对应 Cinder。类型写错是低级错误,但也是高频错误,提交后看到Resource CREATE failed: Unknown Type时,先检查这个字段。
资源属性要注意的是,不同资源类型的属性名称并不完全等同于 OpenStack API 的参数名。比如创建一个 Nova 实例,使用端口而不是网络时,属性要写networks: [{port: {get_resource: web_port}}],而不是直接填网络 ID。这些细节只有翻官方资源文档或者看示例才能掌握,靠记忆很容易漏。
outputs 区是模板的“出口”。创建完堆栈后,我们可以通过输出拿到实例的内网 IP、浮动 IP、端口 ID 等结果。output 使用value和description两个字段,描述信息建议写清楚,因为堆栈列表页面会直接展示给查看的人。我一般至少输出浮动 IP、内网 IP 和负载均衡的监听地址,方便后续对接配置工具。
3. 一个从空项目跑起来的 HOT 实战模板
3.1 先画拓扑,再写参数
我先描述一个最简单的真实场景:给一个 Web 服务建一套独立网络环境,包括一个隔离网络、一个子网、一个路由器、一个安全组、一台云主机和一个浮动 IP,确保这台机器能通过 SSH 访问,也能访问外网。拓扑确认后,参数区需要的东西就清晰了:镜像、规格、外部网络、密钥对。
这里的外部网络很关键。OpenStack 环境中通常存在一个共享的外部网络,用来给实例分配浮动 IP。这个网络的 ID 或者名称每个环境不一样,所以必须做成参数。同理,镜像和规格也做成参数,因为测试环境和生产环境很可能不同。
参数组部分,我把镜像、规格、密钥作为“计算组”,把外部网络作为“网络组”。这样提交堆栈时,输入界面分组明确,不用在一长串参数里翻找。
3.2 写网络、端口与虚拟机资源
下面是一个可以直接用于实验的 HOT 模板骨架。我没有写很复杂的业务配置,目的是把关键的依赖关系展示清楚。
heat_template_version: 2023-09-06 description: Web 服务最小环境示例 parameter_groups: - label: 网络参数 parameters: - external_network - label: 实例参数 parameters: - image - flavor - ssh_key_name parameters: external_network: type: string description: 用于分配浮动 IP 的外部网络 image: type: string default: cirros description: 实例使用的镜像名称或 ID flavor: type: string default: m1.small description: 实例规格 ssh_key_name: type: string description: 用于 SSH 登录的密钥对名称 resources: web_net: type: OS::Neutron::Net properties: name: web_net web_subnet: type: OS::Neutron::Subnet properties: name: web_subnet network_id: { get_resource: web_net } cidr: 192.168.100.0/24 dns_nameservers: - 114.114.114.114 web_router: type: OS::Neutron::Router properties: name: web_router external_gateway_info: network: { get_param: external_network } router_interface: type: OS::Neutron::RouterInterface properties: router_id: { get_resource: web_router } subnet_id: { get_resource: web_subnet } web_sg: type: OS::Neutron::SecurityGroup properties: name: web_sg rules: - protocol: tcp port_range_min: 22 port_range_max: 22 remote_ip_prefix: 0.0.0.0/0 - protocol: icmp remote_ip_prefix: 0.0.0.0/0 web_port: type: OS::Neutron::Port properties: name: web_port network_id: { get_resource: web_net } fixed_ips: - subnet_id: { get_resource: web_subnet } security_groups: - { get_resource: web_sg } web_server: type: OS::Nova::Server properties: name: web-server-01 image: { get_param: image } flavor: { get_param: flavor } key_name: { get_param: ssh_key_name } networks: - port: { get_resource: web_port } web_floating_ip: type: OS::Neutron::FloatingIP properties: floating_network: { get_param: external_network } port_id: { get_resource: web_port } outputs: instance_ip: description: 实例内网 IP value: { get_attr: [web_port, fixed_ips, 0, ip_address] } floating_ip: description: 实例浮动 IP value: { get_attr: [web_floating_ip, floating_ip_address] }这个例子有几个值得注意的地方。安全组规则直接写在OS::Neutron::SecurityGroup的rules列表中,省去了单独创建安全组规则的资源。端口固定 IP 指定子网,避免 DHCP 分配的 IP 和预期网段不一致。虚拟机通过端口接入网络而不是直接填网络 ID,这是我在生产环境里的标准写法:用端口可以单独控制安全组和固定 IP,后续做负载均衡时也能把流量指向端口。
3.3 部署、验证与销毁
模板写好后,部署很简单。确认环境变量已经加载 OpenStack 的 RC 文件,然后执行:
openstack stack create -t web.yaml web-stack \ --parameter external_network=public \ --parameter image=cirros \ --parameter flavor=m1.small \ --parameter ssh_key_name=my-key \ --wait--wait参数非常关键。不加它的话,命令只是提交堆栈就返回了,真正创建结果要再看 stack 状态。加了--wait会一直等待堆栈进入最终状态,然后打印结果,对脚本自动化更友好。
创建完成后,查看输出:
openstack stack show -c outputs web-stack如果一切顺利,outputs里会出现预期键和对应的值。直接拿到浮动 IP 就能 SSH 登录实例。销毁环境和创建一样简单:
openstack stack delete --yes --wait web-stack删除是逆序执行的,会先删浮动 IP 和虚拟机,再删端口、安全组、路由器和网络。这也是为什么我说 Heat 比手工清理安全:依赖关系由引擎自己推导,不会出现“这个资源还被子网占用,删不掉”之类的问题。
3.4 把大模板拆成多个嵌套堆栈
单模板能跑,但模板一大就会遇到维护问题。一个堆栈里有几十个资源,输出变量互相引用,改一处又要重新创建整个环境。这时候就该拆成嵌套堆栈了。
嵌套堆栈的使用非常简单:外层模板定义一个OS::Heat::Stack资源,通过template属性指向子模板文件,用parameters传入参数。比如把网络部分单独拆成network.yaml,外层模板里这样写:
resources: network_stack: type: OS::Heat::Stack properties: template: { get_file: network.yaml } parameters: external_network: { get_param: external_network }子模板里通过 outputs 把创建好的子网 ID、路由 ID 吐出来,外层模板再用get_attr取用:
resources: web_port: type: OS::Neutron::Port properties: network_id: { get_attr: [network_stack, web_net_id] }拆分的最大好处是模块化。网络部分、计算部分、存储部分可以独立测试和更新。团队协作时,不同人维护不同模块的模板,最后在主模板里组合。嵌套堆栈会让 tracing 稍微复杂一点,因为事件列表需要进到子堆栈里看,但我觉得这个代价绝对值得。
4. 依赖与函数:HOT 里容易卡住的四个点
4.1 get_resource 和 get_param 的取舍
HOT 里最常用的两个函数是get_resource和get_param。get_param就是读取参数,任何在 parameters 区定义的值都通过它访问。get_resource则是引用同一个模板里已经声明的资源。
这两个函数的区别,我把它类比成变量引用和函数调用:get_param只是取值,不产生依赖;get_resource不仅取值,还建立了依赖关系。比如子网声明时network_id: { get_resource: web_net },Heat 会隐式地在 web_net 创建成功后,才开始创建 web_subnet。
因此,如果你的模板里有一个资源只是“顺带引用”某个值,并不需要等它先创建,就要避免依赖关系导致的无谓等待。比如给实例传一个静态的 metadata,用get_param就行,不需要引用某个资源。反过来,如果这个值来自另一个资源的属性,那就必须用get_resource或者get_attr,让 Heat 知道谁先谁后。
4.2 get_attr 的坑与解决
get_attr是获取资源创建完成后暴露出来的属性。最典型的用法是取浮动 IP 的地址,或者取端口的固定 IP。它的写法是一个列表:第一个元素是资源名,后面是属性路径。
比如取端口固定 IP:
{ get_attr: [web_port, fixed_ips, 0, ip_address] }这里fixed_ips是一个列表,我们要取第一个元素的ip_address字段,所以路径里要有索引0。很多人第一次写的时候会漏掉索引,直接写[web_port, fixed_ips, ip_address],结果拿到的是一整个列表对象,不是字符串,后续用它做字符串拼接就会报错。
另一个坑是属性依赖。如果一个资源要引用另一个资源的属性,那么 Heat 必须等属性提供方创建完成并更新属性后才能继续。这在某些场景下会拖慢部署速度,尤其是实例创建后需要等 cloud-init 完成才暴露某个属性的时候。这种情况下,单靠资源属性不够,得靠等待条件来处理。等待条件这块我放在下面单独讲。
4.3 显式依赖与等待条件的边界
Heat 提供了depends_on属性,可以让两个没有引用关系的资源也建立先后顺序。但我的经验是,depends_on要慎用。它能解决顺序问题,但没法解决“等到实例里应用真正启动完成”这类更高级的需求。
比较典型的是:一台虚拟机起来了,但它内部的 Web 服务还没起来,外部依赖它的资源就开始配置,结果失败。这种情况下,depends_on和get_attr都解决不了,需要OS::Heat::WaitCondition和OS::Heat::SoftwareConfig配合。
大致的思路是:虚拟机 user_data 里写入一段信号脚本,应用启动完成后,脚本向 Heat 的一个等待条件发送成功信号。Heat 等到这个信号才认为资源创建成功。这个机制能让整个堆栈的创建节奏和业务应用的真实就绪状态同步。
OS::Heat::WaitCondition的配置比较复杂,我在这里不展开全部参数,只提醒一点:timeout不要设置太短。我在实验环境里遇到过因为应用启动稍慢,等待条件超时,整个堆栈被标记失败的情况。生产环境建议给足余量,至少是平时启动时间的 2 倍。
4.4 条件和循环在 HOT 里能做什么
HOT 提供条件函数if、equals,通过条件决定某些资源是否创建、某些参数是否生效。这在多环境复用同一个模板时特别有用。比如测试环境不建负载均衡,生产环境才建,就可以用一个enable_lb的 boolean 参数控制。
循环功能让我印象更深刻。repeat函数能基于一个comma_delimited_list或者 json 参数批量生成资源。举个例子,接收一组端口规范,然后循环创建多个安全组规则,模板长度可以大幅缩减。
不过条件越多,模板越难调试。我通常建议把条件的数量控制在个位数,而且每个条件都配上注释。条件嵌套多了以后,排错的成本会急剧上升,在业务初期没必要追求过度抽象。
5. 栈失败后的排错链路:事件、回滚和日志
5.1 从 stack show 到 event list 的完整排查路径
你在生产环境肯定遇到过CREATE_FAILED那一刻。慌没有用,按顺序来。
第一步,看堆栈状态:
openstack stack show my-stack看到状态是一回事,更重要的是找到哪个资源出的问题。接着看事件列表:
openstack stack event list my-stack事件列表会按时间倒序展示每个资源的状态变迁。找到最近一个状态为CREATE_FAILED的事件,那一行有resource_name、resource_status_reason和physical_resource_id。resource_status_reason是最有价值的线索,它通常会明确告诉你失败原因,比如镜像不存在、端口冲突、配额不足、参数类型不对等等。
查资源详情是第三步:
openstack stack resource list my-stack openstack stack resource show my-stack web_server --with-detail--with-detail会输出更完整的资源属性,有时候能直接看到 OpenStack 服务返回的错误信息。如果这里还不够,就要去 Heat 引擎日志和服务组件日志里找了。控制节点上journalctl -u heat-engine是最常用的手段,日志里会打出完整的异常堆栈。
5.2 我日常遇到最高频的几个模板错误
模版写久了,踩坑也有规律。我整理几个最常见的问题:
| 错误现象 | 根本原因 | 解决思路 |
|---|---|---|
| Resource CREATE failed: NotFound | 资源类型写错,或镜像、网络、密钥对不存在 | 先用 openstack image list / network list / keypair list 检查名称和 ID |
| 参数缺失或类型错误 | 模板参数声明与传入值不匹配 | 检查 parameters 声明和使用处的写法,注意 number 类型不要传字符串 |
| 端口/IP 冲突 | 网段规划重叠 | 子网 CIDR 不要和已有网络重合,检查现有网络规划 |
| 安全组规则创建失败 | remote_ip_prefix 格式错误或协议值不支持 | 确认协议是 tcp/udp/icmp 等,前缀必须是合法 CIDR |
| 实例创建后连接不上 | user_data 脚本问题或安全组没放行端口 | 先查 console log,再检查安全组规则是否匹配 |
| WaitCondition timeout | 信号没发出来或超时太短 | 查看 user_data 执行日志,确认请求地址和签名正确 |
这些错误里,最隐蔽的是镜像和网络 ID 写错。因为 OpenStack 允许同名资源存在,名称相同但 ID 不同,用名称解析时结果不确定。我强烈建议在模板参数里统一使用 ID,不用名称。名称交给参数描述去解释,运行时解析成 ID,这样可以避免很多环境差异问题。
5.3 排错时的小技巧与基线建议
调试模板我有一套非常朴素但有效的流程。先在测试环境用最简参数跑:用一个最小的镜像、最便宜的规格、单个子网,把网络和实例跑通。跑通后再逐步增加组件:安全组规则复杂化、多实例、负载均衡、嵌套堆栈。每加一个组件,重新创建一次堆栈。这样定位问题永远在最后加入的那层。
如果堆栈已经失败并回滚了,想保留现场排查怎么办?在提交堆栈时加--disable-rollback。加上这个参数后,失败的资源不会自动删除,你可以保留已经创建的资源来检查现场。我会在测试环境里打开这个开关,生产环境保持默认回滚,这要根据团队习惯来定。不过我要提醒,禁用回滚后,失败的资源会残留,手动清理时要格外小心,避免误删同环境里其他正常资源。
还有一个习惯:模板提交前先用 YAML 校验工具检查格式。HOT 对缩进极其敏感,一个空格不对整个模板就废了。用python -c "import yaml,sys; yaml.safe_load(open('web.yaml'))"这类简单检查能提前滤掉语法错误。OpenStack 也提供模板校验命令,openstack orchestration template validate -t web.yaml,我更推荐用这个,它会同时检查参数逻辑和资源类型合法性,比纯 YAML 解析检查得更多。
6. 收尾:我在生产环境里坚持的几个模板习惯
写到这,前面骨架已经搭起来了。我补充几个自己实践出来的习惯,谈不上标准答案,但确实帮我少踩了不少坑。
第一个习惯是给模板建目录规范。每个业务至少包含主模板、网络子模板、计算子模板、参数文件parameters.yaml,并且用 git 管理。堆栈的创建时间、参数、最终输出都记录在案,出问题能对着历史版本对比,而不是凭空猜。
第二个习惯是模板尽量保持“一次性创建”的可重复性。有些资源,比如密钥对和镜像,我不在 HOT 模板里创建,而是提前在环境中准备好,参数里只传引用。这样同一个模板删除再重建时,不会因为密钥对已经存在而冲突。可以重复执行的模板才是好模板。
第三个习惯是留意资源命名。我给资源名都加上业务前缀,并且保持全局唯一。Heat 内部使用资源名做依赖识别,修改资源名会造成堆栈更新时的资源重建,所以在模板上线前就定好命名,别频繁更换。
最后,别把密码和敏感配置写进模板和参数文件。模板文件会被版本库记录,一旦泄露就是安全隐患。敏感信息用 Heat 支持的 secret 参数类型或者外部密钥服务处理,实现同样的自动化效果但更安全。
Heat 和 HOT 的边界比很多人想象得宽,它不只是基础设施编排工具,更是整个 OpenStack 环境自动化运维的底座。把模板看成业务环境的“说明书”,版本化、模块化、可重复,你会发现自己已经很久没进控制台点鼠标创建资源了。