系列专栏声明:比较流水,主要是写一些踩坑的点,和实践中与文档差距较大的地方的思考。这个专栏的典型特征可能是 次佳实践
,争取能在大量的最佳实践中生存。
TL;DR
- 本期基于阿里云,主要介绍用阿里云资源编排服务
ROS,Resource Orchestration Service
和Custom Container
做Serverless
的踩坑;不包括Terraform
,不包括 AWS 等其它厂商。 - 以 Spring Boot 类比,一个执行持续交付的企业,应该会选择
1 Service + 1 Function = 1 原 Method
的方案;一个执行双周迭代的企业,应该会选择1 Service + 1 Function = 1 原 Controller
的方案 - 发新版的方式是点修改,然后把
Function
的CustomContainerConfig
的Image
的版本号 +1。
最近突然发现原来 FC,Function Compute
是支持用自定义容器部署的,翻了一下这个功能是 2020-09 上线的,突然就觉得去年一年白折腾了;包括 ECI,Elastic Container Instance
,Midway / Egg-Layer
部署模式,都不需要了,Custom Container
就是我一直在找的那个解决方案。又仔细翻了一下产品日志,云效是 2021-03 支持构建镜像并推送到私有仓库的,尝试甩锅是因为云效跟进得太慢导致我没有在内容农场中找到这个宝藏。
一、部署目标
最终的目标肯定是 IaC
,但在这个演进过程中得有取舍,哪些是不得不优先自动化的,哪些是可以暂时用 ClickOps
来过渡的。如果一味的高举 IaC 的口号,那么最先自动化的一定是那些 最容易 被自动化的,而不是 最应该 被自动化的。
关于如何使用 云效 Flow
拉取代码,并构建镜像发布到 镜像仓库 ACR
参考 上一篇。
二、部署在 函数计算 FC
里的最终架构应该是什么样子
以 Spring Boot 类比,直觉的想法是,Service 对应原微服务的 Application,Function 对应的是 Controller 的一个 Method,实践下来并不是这样。
首先 FC 的很多重要配置都是在 Service
这个粒度的,比如 VpcConfig
,LogConfig
,TraceConfig
,NasConfig
,所以 Service
这个粒度是最重要的需要仔细思考架构的地方,可以假想对应为一个拆得比较干净的微服务中台 原子
Application。但是现实中的微服务大多拆得不干净,如果是做现存应用的无痛迁移的话,实践上 Application 会对应多个 Service。其次,Method 的粒度又太细了,不好管理,所以实践上 Function
对应的是 Controller
。EnvironmentVariables
是配置在 Function
上的,所以如果有一些参数不便共享,也可以使用 Function 来隔离。
接下来直觉的想法肯定是,一个 Service
对应多个 Function
,实践下来这样还是不行的,因为现存应用的迁移并没有那么无痛,甚至是很痛,业务方期待的其实还真的是 Method
粒度的渐进式迁移,通过网关,依次将每个接口流量打到新的架构上去观察。所以一个执行持续交付的企业,应该会选择 1 Service + 1 Function = 1 原 Method
的方式做迁移;一个执行双周迭代的企业,应该会选择 1 Service + 1 Function = 1 原 Controller
的方式做迁移。至于迁移全部完成后怎么去做合并,那是另一个故事了。
最后,由于我的项目中只存在 Http 类的接口需求,所以只需要 Http 类型的 Trigger。这里有一个限制,其它类型的 Trigger 可以在 Function 启动之后增删,但 Http 类型的 Trigger 必须在创建 Function 时一并创建,所以我最终的选型是 1 Service + 1 Function + 1 Http Trigger = 1 原 Controller
。
三、ROS
的最小可用够用集
先吐一个坑点,说是支持 JSON 和 YAML,但文档和示例只有 JSON,而且低代码交互的部分明显对 JSON 支持更好,实践上讲认为只支持 JSON
就对了。
ROS 模板文档。首先想象一下,最终应用是对着一个 YAML 启动的,这个 YAML 就是该应用的 Declarative IaC,通过编排系统的 Reconcile 能力,保证应用的状态和 YAML 的期望是一致的。然后,对于一组架构一致或类似的应用 YAML,把其中的关键参数扣掉改成占位符,等着用户去输入,这就是模板。ROS
把应用的 YAML 叫做 资源栈 Resource Stack
,把模板叫做 Template
。Template 是有专门的交互去管理的,真的是 YAML 格式,既可以在线编辑,也可以上传下载,也就意味着你可以 GitOps。Resource Stack
没有这个能力,你不能从一个手工写好的填好参数的 YAML 直接启动,必须要先抽象出模板,第一步使用模板,第二步在交互中填入参数。这些参数,比如 VpcId,ROS 是不会帮你管理的,所以我猜实际上企业还是需要自建 CMDB?
{
"ROSTemplateFormatVersion" : "2015-09-01", // 非常神奇,原来这么早就开始搞了
"Description" : "",
"Resources" : {
"ServiceA": {
"Type": "ALIYUN::FC::Service",
"Properties": {
"ServiceName": { // 这里的命名要按接口文档来
"Ref": "ServiceName" // 这里 ref 的是 Parameter 里面定义的「占位符」处由用户输入的值,这里的命名自己定,比如不同的 Type 有同名的,就可以自定义命名成 ServiceNameForServiceA
},
...
}
},
"FunctionA": { "Type": "ALIYUN::FC::Function", "DependsOn": "Service", "Properties": {} },
"TriggerA": { "Type": "ALIYUN::FC::Trigger", "DependsOn": "Function", "Properties": {} },
},
"Parameters" : {}, // 参数,每种资源都有自己独立的文档详解需要哪些参数
"Metadata" : {}, // 例如存放用于可视化的布局信息
"Outputs" : {}// 用于输出一些资源属性等有用信息。可以通过API或控制台获取输出的内容
}
关于 Resource
,这里是 Declarative
的部分,就是你对最终希望启动的资源达到的状态的描述。ServiceA
是 变量命名
,不需要带有 Service 字样,"Type": "ALIYUN::FC::Service"
才是指定资源类型。如果需要一组启动多个 Service,那命名就应该形如 ServiceA,ServiceB;Function,Trigger 及其它也一样。这个 变量命名
是用来在配置文件内 编程
的,好奇怪的说法,我也没研究过 Terraform
,不知道这个理解是否正确。比如在 Output
里,由于 Service 是系统拉起来的,所以 ServiceId 是实时生成的,那么如果后面需要使用到这个值,就可以用 "Fn::GetAttr": [ "ServiceA", "ServiceId" ]
来获取这个值。
Type
指定了资源类型,每种 Type 都有专门的 文档 描述该类型有哪些参数必填或选填,从这里面挑你需要的参数放到 Properties
里面,在从模板启动的时候会要求用户提供这些参数,作为输入来启动资源。
"Parameters": {
"TriggerName": {
"Type": "String",
"Default": "http-index"
},
...
}
Parameters
是用来做低代码自动生成交互的,Type: String 会被渲染成 <input type="text" />
,Type: Boolean 会被渲染成 <input type="radio" />
等等。另外有一个 AssociationProperty 的概念,可以渲染成对接阿里云资源接口的 <select />
方便手选,而不是复制粘贴。不知道 Terraform
怎么处理低代码的,等有空研究一下。
MetaData
是用来对 Parameters
做 <fieldset />
的,具体翻文档吧。
Outputs
是用来输出参数的,相当于 return
或者 export
,即向下游声明这些变量是可以用的,目前我没有用到上下游的概念,都是一批次启动的,所以没有研究。
关于 Mappings,Conditions 自己翻文档吧。
另外资源栈还有一个 嵌套
的概念,这个命名也有点奇怪,看上去是复用的意思,但是我没有想象出来实际的场景,所以没有研究。
四、通过 ROS
定义 Resource Stack
并启动详解
为了排版方面,以下就大部分写 YAML 了,请记得上面提到过实际 JSON 支持更好,建议只使用 JSON。
Service
的配置如下,大部分都是字面意思,细节翻下文档:
Resources:
Service:
Type: 'ALIYUN::FC::Service'
Properties:
ServiceName:
Ref: ServiceName
Description:
Ref: ServiceDescription
Role:
Ref: Role
VpcConfig:
VpcId:
Ref: VpcId
SecurityGroupId:
Ref: SecurityGroupId
VSwitchIds:
Ref: VSwitchIds
LogConfig:
Project:
Ref: LogProject
Logstore:
Ref: LogStore
InternetAccess:
Ref: InternetAccess
TracingConfig:
Type:
Ref: TracingType
Params:
Endpoint:
Ref: TracingEndpoint
Parameters:
ServiceName:
Type: String
ServiceDescription:
Type: String
Role:
Type: String
VpcId:
Type: String
AssociationProperty: "ALIYUN::ECS::VPC::VPCId"
SecurityGroupId:
Type: String
AssociationProperty: "ALIYUN::ECS::SecurityGroup::SecurityGroupId"
AssociationPropertyMetadata:
RegionId: "cn-hangzhou" # 这里图省事写死了
VpcId: "${VpcId}" # 这里是和上面的 VpcId 下拉框联动的
VSwitchIds:
Type: "CommaDelimitedList" # 会被渲染成多选的 <select />,对应文档里的 Type: List,这里用 Type: String 会报错
AssociationProperty: "ALIYUN::ECS::VSwitch::VSwitchId"
AssociationPropertyMetadata:
RegionId: "cn-hangzhou"
VpcId: "${VpcId}"
LogProject:
Type: String
LogStore:
Type: String
InternetAccess:
Type: Boolean
AllowedValues:
- 'true'
- 'false'
Default: 'true'
TracingType:
Type: String
Default: Jaeger
# 这个值开通链路追踪服务以后去设置里找,目前似乎只支持 Jaeger
TracingEndpoint:
Type: String
Default: http://tracing-analysis-dc-hz-internal.aliyuncs.com/adapt_{{ secret }}_{{ secret }}/api/traces
MetaData:
'ALIYUN::ROS::Interface':
ParameterGroups:
- Parameters:
- ServiceName
- ServiceDescription
- Role
- VpcConfig
- LogConfig
- InternetAccess
- TracingConfig
Label:
default: Service
注意其中 VpcConfig
这个例子,接口语法是支持多层的,单参数的低代码定义部分只支持单层平铺的:
Resources:
VpcConfig:
VpcId
SecurityGroupId
VSwitchIds
Parameters:
VpcId:
Type: String
SecurityGroupId:
Type: String
VSwitchIds:
Type: CommaDelimitedList
Function
的配置如下:
Resources:
Function:
Type: 'ALIYUN::FC::Function'
DependsOn: Service # 有启动的先后顺序的
Properties:
ServiceName:
Ref: ServiceName
# 这里是有点取巧的,因为我用的是 1 Service + 1 Function 的设计,所以这里直接用名字去找就好了
# 更严格的做法我猜应该是 Fn::GetAttr [Service, ServiceName]
FunctionName:
Ref: FunctionName
MemorySize:
Ref: MemorySize
EnvironmentVariables:
Ref: EnvironmentVariables
CustomContainerConfig:
Image:
Ref: CustomContainerImage
Timeout:
Ref: Timeout
CAPort:
Ref: CAPort
Runtime:
Ref: Runtime
Handler:
Ref: Handler
Parameters:
FunctionName:
Type: String
MemorySize:
Type: Number
MinValue: 256
MaxValue: 3072
Default: 256
EnvironmentVariables:
Type: Json
# 敏感参数在这里,比如数据库链接
# Serverless 不是常驻的,每次启动还要去连 Vault 不合适,需要研究
CustomContainerImage:
Type: String # "填你的私有 ACR"
Timeout:
Type: Number
MinValue: 1
MaxValue: 600
Default: 10
CAPort:
Type: Number
Default: 8080
Runtime:
Type: String
Default: custom-container
# 本文关键,指定自定义容器模式,默认的应该是代码模式
Handler:
Type: String
Default: index.handler
# 似乎不重要,这个是给默认代码模式用的,但是是必填项,那就按密码模式写了
Metadata:
'ALIYUN::ROS::Interface':
ParameterGroups:
- Parameters:
- FunctionName
- MemorySize
- EnvironmentVariables
- CustomContainerConfig
- Timeout
- CAPort
- Handler
- Runtime
Label:
default: Function
Trigger
的配置如下:
Resources:
Trigger:
Type: 'ALIYUN::FC::Trigger'
DependsOn: Function
Properties:
ServiceName:
Ref: ServiceName
FunctionName:
Ref: FunctionName
TriggerType:
Ref: TriggerType
TriggerName:
Ref: TriggerName
TriggerConfig:
AuthType:
Ref: TriggerHttpAuthType
Methods:
Ref: TriggerHttpMethods
Parameters:
TriggerType:
Type: String
Default: http # 本文关键
TriggerName:
Type: String
Default: http-index
TriggerHttpAuthType:
Type: String
Default: "anonymous"
TriggerHttpMethods":
Type: "CommaDelimitedList",
Default: "GET,POST,PUT,DELETE,HEAD,PATCH"
MetaData:
'ALIYUN::ROS::Interface':
ParameterGroups:
- Parameters:
- TriggerName
- TriggerType
- TriggerHttpAuthType
- TriggerHttpMethods
Label:
default: Trigger
以上,拆成 3 段是为了表述方便,实际是拼成一个 YAML 的 Template
,新建 Resource Stack
的时候选中或上传这个 Template
,然后在低代码生成的交互中输入对应的参数,一路点下一步就启动了。
于是第三个坑点就是,只有第一次启动的时候的 YAML(包括 Template 和输入)是对的,才能正常启动,然后如果有变动才能点修改。如果第一次失败了,那么只能删除重建。非常奇怪的设计,简直可以想象产品跪在开发面前说你们顺手把这个功能修复了好么,开发回答又不是不能用.jpg。
对应的,发新版的方式是点修改,然后把 Function
的 CustomContainerConfig
的 Image
的版本号 +1。
五、其它没有提到的零散的坑点
funcraft
没啥用,而且官方应该已经弃坑了。serverless-dev
似乎也没啥用,太全家桶了,看上去没有运维该有的简洁感。cdk 可能有用,看上去有 IDE 插件,待研究。
六、关于 Custom Container
做 Serverless
再多说几句
Container
的优势是可移植,旧代码和架构选型都不需要大改,也不用担心某个特性厂商是否支持,基本可以直接怼上去。FaaS
号称的优势是冷启动会快一点,但既然都准备上 Serverless
了,肯定是权衡过的,应该会选择一些保持基线的方式去兜底,比如可以搭着预留实例一起用,而不是在冷启动上花大力气。
FaaS
的生态还是不够成熟,有太多新知识要学,动不动就推倒重来。Container
天生就是来解决开发和运维的分工问题的,这里只需要运维 格局打开
,不需要配合也能怼上去的。当然不需要配合也只是个比喻,开发还是需要学习一些简单易落地的准则,如《王四条》,但由于学习曲线如此平坦就像不需要学习一样。
大量的手工重复劳动根本不是问题,学习曲线太陡峭才是,程序员最爱的事情就是复制粘贴了。判断好的架构的指标应该是看能不能支持专业分工,支持线性招聘,支持复制粘贴。云原生的本质是 IaC
,人类花了这么久的学术理论研究,终于学会了用 Code 描述解决方案,怎么又想着改回 可视化
了。
参考文献
- 阿里云函数计算发布新功能,支持容器镜像,加速应用 Serverless 进程
- 什么是阿里云云开发平台
- 开发函数计算的正确姿势——轻松解决大依赖部署,“错误”示范
- 阿里云田涛涛:高效智能的云,CloudOps让运维更简单
- 云原生基础设施,云原生软件架构,云原生应用交付与运维,和本文直接关系不大,但作为综述写得很棒,值得一看
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。