基础设施| 即代码的过去和未来
基础设施即代码(IaC)是软件开发中的一个令人着迷的领域。虽然作为一门学科它还相对年轻,但在其短暂的发展历程中,它已经经历了几次具有划时代意义的变化。我认为,它是当今软件开发创新中最热门的领域之一,参与者很多,从大型科技公司到年轻的初创企业,都在创造新的方法,如果完全实现,有可能彻底改变我们编写和部署软件的方式。
在本篇文章中,对 IaC 这一主题进行深入探讨:它是什么,它能带来什么好处,它已经经历了哪些具有颠覆性的转变,以及未来可能会发生什么样的变化。
#01
什么是 IaC?
让我们从解释这个概念开始。基础设施即代码是一个涵盖一系列实践和工具的术语,旨在将应用程序开发中的严谨性和经验应用到基础设施供应和维护的领域。
这里的 “基础设施” 是故意模糊的,但我们可以把它定义为在环境中运行一个特定的应用程序所需要的一切,但这并不是应用程序本身的一部分。一些常见的例子包括:服务器、配置、网络、数据库、存储等等。在本文后面我们还会看到更多的例子。
IaC 的实践与运行时代码的实践相呼应。这些实践包括:使用源代码控制进行版本管理、自动化测试、持续集成/持续交付(CI/CD)部署流程、快速反馈的本地开发等。
遵循 IaC 的实践可以带来以下好处:
性能:如果需要提供或更改大量基础设施,IaC 将始终比人工手动执行相同操作更快。
可重复性:人类在可靠地重复执行相同任务方面往往表现不佳。如果我们需要重复进行一百次相同的操作,很可能会分心并在过程中出错。IaC 不会受到这个问题的影响。
文档化:你的 IaC 可以作为你系统结构的文档。当维护系统的团队规模扩大时,这就变得至关重要了 —— 你不希望依赖于部落知识,或者只有少数几个团队成员了解系统基础设施的工作原理。最重要的是,与传统文档不同,这份文档永远不会过时。
审计历史:有了 IaC,由于你对 IaC 的版本控制与你的应用代码相同(有时被称为 GitOps),它为你提供了历史记录,你可以查看你的基础设施是如何随时间变化的,如果任何变化导致问题,有办法回滚到一个安全点。
可测试性:IaC 可以像应用程序代码一样进行测试。你可以对其进行单元测试、集成测试和端到端测试。
接下来,让我们谈谈 IaC 工具在实践开始以来所经历的主要阶段。
#02
第一代:声明式,主机配置
代表:Chef、Puppet、Ansible
第一代 IaC 工具都是关于主机配置的。这很有意义,因为软件系统的基础设施,在其最低的抽象层次上,由单个机器组成。因此,这个领域的第一批工具集中在配置这些机器上。
这些工具管理的基础设施资源是 Unix 中熟悉的概念:文件、来自 Apt 或 RPM 等软件包管理器的 users、groups、permissions、init services 等等。
下面是一个创建 Java 服务的 Ansible playbook 例子:
– hosts: app
tasks:
– name: Update apt-get
apt: update_cache=yes– name: Install Apache
apt: name=apache2 state=present– name: Install Libapache-mod-jk
apt: name=libapache2-mod-jk state=present– name: Install Java
apt: name=default-jdk state=present– name: Create Tomcat node directories
file: path=/etc/tomcat state=directory mode=0777
– file: path=/etc/tomcat/server state=directory mode=0775– name: Download Tomcat 7 package
get_url: url=http://apache.mirror.digionline.de/tomcat/tomcat-7/v7.0.92/bin/apache-tomcat-7.0.92.tar.gz dest=’/etc/tomcat’
– unarchive: src=/etc/tomcat/apache-tomcat-7.0.92.tar.gz dest=/etc/tomcat/server copy=no– name: Configuring Mod-Jk & Apache
replace: dest=/etc/apache2/sites-enabled/000-default.conf regexp=’^</VirtualHost>’ replace=”JkMount /status status \n JkMount /* loadbalancer \n JkMountCopy On \n </VirtualHost>”– name: Download sample Tomcat application
get_url: url=https://tomcat.apache.org/tomcat-7.0-doc/appdev/sample/sample.war dest=’/etc/tomcat/server/apache-tomcat-7.0.92/webapps’ validate_certs=no– name: Restart Apache
service: name=apache2 state=restarted– name: Start Tomcat nodes
command: nohup /etc/tomcat/server/apache-tomcat-7.0.92/bin/catalina.sh start
本操作手册的抽象层次是一台以 Linux 为操作系统的单一计算机。我们声明我们想要安装的 Apt 软件包,我们想要创建的文件(创建它们的方式有多种:直接在给定的路径下创建目录,从给定的 URL 下载,从存档中提取文件,或根据正则表达式替换编辑现有文件),我们想要运行的系统服务或命令等等。
实际上,如果你稍微看一下,你会发现这个 playbook 与 Bash 脚本非常相似。主要的区别是,playbook 是声明式的 —— 它描述了它希望发生的事情,比如在机器上安装给定的 Apt 软件包。这与脚本不同,脚本包含要执行的命令。
虽然这个区别很小,但它很重要;它使 playbook 具有幂等性,这意味着,即使它在中间某个地方失败了(也许 tomcat。apache。org 有暂时的故障,导致从它那里的下载失败),你可以重新启动它,之前成功执行的步骤会识别到这一点,并在不做任何事情的情况下通过,这通常不是 Bash 脚本的情况。
现在,这些工具对于推进软件开发行业的发展起着至关重要的作用,不可忽视。但是,它们只能在单个主机层面上运行,这有巨大的局限性。这就意味着你不得不手动管理这些主机,这在很大程度上抵消了 IaC 所带来的好处,或者你需要将这些工具与能够管理主机的其他工具结合使用,比如用于本地开发的 Vagrant,或者用于管理共享环境(如生产环境)的 OpenStack。
举个例子,如果你想创建一个经典的三层架构,你需要创建三种类型的虚拟机,每种类型的虚拟机都有自己的 Ansible playbook,根据它们在架构中的角色来配置这些主机。
IaC 工具的下一阶段将摆脱这种限制。
#03
第二代:声明式,云计算
代表:CloudFormation、Terraform、Azure Resource Manager
2000 年代中期,云计算的引入是软件开发史上的一个里程碑事件。在许多方面,我认为我们仍在深度消化它所带来的革命性影响。
突然间,主机管理的诸多问题得到了解决。你不需要运行和操作你自己的 OpenStack 集群来自动管理虚拟机;云供应商将为你处理这一切。
但更重要的是,云计算立即提高了我们设计系统的抽象水平。不再只是给主机分配不同的角色那么简单。
如果你需要发布 – 订阅资源,那么就没有必要去配置一台虚拟机,并在其上安装 Apt 的 ZeroMQ 包;相反,你可以直接使用 Amazon SNS。如果你想存储一些文件,你也不需要指定一堆主机作为你的存储层;相反,你可以创建一个 S3 存储桶。诸如此类,不一而足。
我们进入了配置管理服务的阶段,而不再是将主机配置置于首要位置。由于上一代的工具被设计成只在单个主机的层面上工作,因此我们需要一种全新的方法。
为了解决这个问题,像 CloudFormation 和 Terraform 这样的工具应运而生。它们和第一代工具一样,都采用声明式设计;但不同之处在于,它们操作的抽象层级不再是单一机器上的文件和软件包,而是各种属于不同托管服务的独立资源,以及这些资源的属性和它们之间的相互关系。
例如,这里有一个 CloudFormation 模板,定义了一个由 SQS 队列触发的 AWS Lambda 函数:
AWSTemplateFormatVersion : 2010-09-09
Resources:
LambdaFunction:
Type: AWS::Lambda::Function
Properties:
Code:
S3Bucket: my-source-bucket
S3Key: lambda/my-java-app.zip
Handler: example.Handler
Role: !GetAtt LambdaExecutionRole.Arn
Runtime: java17
Timeout: 60
MemorySize: 512
MyQueue:
Type: AWS::SQS::Queue
Properties:
VisibilityTimeout: 120
LambdaFunctionEventSourceMapping:
Type: AWS::Lambda::EventSourceMapping
Properties:
BatchSize: 10
Enabled: true
EventSourceArn: !GetAtt MyQueue.Arn
FunctionName: !GetAtt LambdaFunction.Arn
LambdaExecutionRole:
Type: AWS::IAM::Role
Properties:
AssumeRolePolicyDocument:
Version: ‘2012-10-17’
Statement:
– Effect: Allow
Principal:
Service:
– lambda.amazonaws.com
Action:
– sts:AssumeRole
Policies:
– PolicyName: allowLambdaLogs
PolicyDocument:
Version: ‘2012-10-17’
Statement:
– Effect: Allow
Action:
– logs:*
Resource: arn:aws:logs:*:*:*
– PolicyName: allowSqs
PolicyDocument:
Version: ‘2012-10-17’
Statement:
– Effect: Allow
Action:
– sqs:ReceiveMessage
– sqs:DeleteMessage
– sqs:GetQueueAttributes
– sqs:ChangeMessageVisibility
Resource: !GetAtt MyQueue.Arn
这个 CloudFormation 模板与我们之前看到的 Ansible playbook 差别很大。它并未提及任何文件、程序包或初始化服务;而是使用了托管服务的语言。我们配置的资源类型是AWS::Lambda::Function和AWS::SQS::Queue。我们并未定义这些服务将在何处运行,也未定义如何配置这些主机 —— 我们所关心的是,云供应商所提供的托管服务能否被正确使用。
然而,它与 Ansible 的共同点在于其声明性质。我们不需要编写对 SQS API 的调用来创建一个队列 —— 我们只需要声明我们需要一个队列,并将 VisibilityTimeout 属性设置为 120,部署引擎(在这个例子中是 CloudFormation)会负责确定需要调用哪些 AWS API 来实现这个目标。如果我们后来决定修改队列(比如我们想将超时时间设为 240,而不是 120),或者完全删除它,我们只需修改模板,引擎便会自动找出需要的 API 调用来更新或者删除队列。
这些工具是 IaC 发展过程中的一个巨大的里程碑,这大大提升了前一代的抽象水平。然而,它们也存在一些缺陷。
第一个问题是,为了实现其声明性质,这些工具使用了自定义的 DSL(领域特定语言),例如,在 CloudFormation 中,这种语言可能是 JSON 或 YAML 格式。这就意味着所有的通用编程语言功能,比如变量、函数、循环、if 语句、类等,在这种 DSL 中都无法使用。因此,没有简单的办法来减少重复代码。
举个例子,如果我们想要在我们的应用中配置不止一个,而是三个具有相同设置的队列,我们无法简单地编写一个循环来执行三次;我们必须把相同的定义复制和粘贴三次,这并不理想。同时,这也意味着我们无法将模板划分为逻辑单元;我们无法将一部分资源指定为存储层,另一部分资源指定为前端层等。所有的资源都属于一个扁平的命名空间。
这些工具的另一个问题是,虽然它们肯定比第一代的主机配置更高级,但它们仍然需要你详细指定在系统中使用的所有资源的所有细节。例如,你可能已经注意到,在上面的模板示例中,除了我们主要关注的 Lambda 和 SQS 资源,我们还有事件映射和 IAM 资源。这是连接 SQS 和 Lambda 所需的 “粘合剂”,而正确配置这些 “粘合剂” 资源并非易事。
举例来说,你需要向执行函数的 IAM 角色授予一组非常特定的权限(sqs:ReceiveMessage、sqs:DeleteMessage、sqs:GetQueueAttributes 和 sqs:ChangeMessageVisibility),才能成功地从特定队列触发它。
从某种程度上来说,这是一个非常低级的问题;然而,由于 DSL 中缺乏抽象工具,我们实际上没有任何工具可以隐藏这些实现细节。所以,每次你需要创建一个由 SQS 队列触发的新 Lambda 函数,你别无选择,只能复制包含这四个权限的代码段。因此,这些模板往往会很快变得冗长,并包含大量重复内容。
#04
第三代:命令式,云计算
代表:AWS CDK、Pulumi、SST
例如,让我们看看相当于上述 CloudFormation 模板的云开发工具包程序(在这个例子中我将使用 TypeScript,但任何其他 CDK 支持的语言看起来都非常相似):
第二代工具的所有缺陷都可以追溯到它们使用了一种自定义的 DSL,这种语言缺乏我们在使用通用编程语言时习惯的抽象工具,如变量、函数、循环、类、方法等。
因此,第三代 IaC 工具的主要思想非常简单:如果通用编程语言已经具备了这些功能,那么我们为什么不使用它们来定义基础设施,而要使用自定义的 JSON 或 YAML DSL?
例如,让我们看看相当于上述 CloudFormation 模板的云开发工具包程序(在这个例子中我将使用 TypeScript,但任何其他 CDK 支持的语言看起来都非常相似):
class LambdaStack extends cdk.Stack {
constructor(scope: cdk.Construct, id: string, props?: cdk.StackProps) {
super(scope, id, props);const func = new lambda.Function(this, ‘Function’, {
code: lambda.Code.fromBucket(
s3.Bucket.fromBucketName(this, ‘CodeBucket’, ‘my-source-bucket’),
‘lambda/my-java-app.zip’),
handler: ‘example.Handler’,
runtime: lambda.Runtime.JAVA_17,
});const queue = new sqs.Queue(this, ‘Queue’, {
visibilityTimeout: cdk.Duration.minutes(2),
});func.addEventSource(new lambda_events.SqsEventSource(queue));
}
}const app = new cdk.App();
new LambdaStack(app, ‘LambdaStack’);
这个 CDK 代码的第一个有趣之处在于,它比其对应的 CloudFormation 模板要短得多 —— 大约 20 行 TypeScript,而 YAML 大约有 60 行,所以大概是 3 比 1 的比例。这是一个非常简单的例子;当你的基础设施越来越复杂时,这个比例就会越来越大 —— 我见过有些情况下比例高达 30 比 1。
其次,CDK 代码的级别比 CloudFormation 模板要高得多。请注意,如何从队列中触发函数的细节被 addEventSource() 方法和 SqsEventSource 类优雅地封装了起来。这两个 API 都是类型安全的 —— 你不能错误地将一个 SNS 主题传递给 SqsEventSource,因为编译器不允许这样。
还请注意,我们不必在代码中的任何地方提到 IAM —— CDK 为我们处理了所有这些细节,所以我们不必知道需要哪 4 个确切的权限来允许一个函数被队列触发。
所有这些都是因为高级编程语言允许我们构建抽象概念。我可以把一段重复的或复杂的代码,放在一个类或函数中,并为我的项目提供一个干净、简单的 API,这个 API 巧妙地封装了所有混乱的实现细节,就像 CDK 团队创建和维护的 SqsEventSource 类那样。
如果这是其他项目可能受益的东西,我可以把我的抽象概念打包成它所使用的编程语言的库,并通过我的语言的包管理器分发出去,比如 JavaScript/TypeScript 的 npmjs.com,或 Java 的 Maven Central,这样其他人就可以依赖它,就像我们分发应用程序代码的库一样。我甚至可以把它添加到 constructs.dev 的可用开源 CDK 库目录中,这样就更容易找到它。
#05
第四代:Infrastructure from Code
代表:Wing、Dark、Eventual、Ampt、Klotho
虽然第三代 IaC 工具是一个巨大的飞跃,使云计算更容易被使用(我在这里可能有偏见,因为我是 AWS 的 CDK 团队的前成员,但我认为这种说法很接近事实),但它们仍然有改进的空间。
他们的第一个缺点是,它们在很大程度上是在单个云服务的层面上运作的。因此,虽然他们使使用 Lambda 或 SQS 变得很容易,但你仍然需要知道这些服务是什么,以及为什么你会考虑使用它们。
现在是云计算时代,我们已经看到每个供应商提供的服务数量激增。仅 AWS 就有 200 多种。在如此多样化的选择中,选择适合自己要求的服务变得越来越难。我应该在 AWS Lambda、AWS EKS 或 AWS AppRunner 上运行我的容器吗?我应该使用 Google Cloud Functions 还是 Google Cloud Run?在什么情况下,这一个比那一个更适合?
大多数开发人员对每个云计算供应商的产品没有特别详细的了解,特别是由于这些产品往往经常变化,新的服务(或现有服务的新功能)不断推出,旧的服务被淘汰。但他们确实对系统设计的基本原理有很好的理解。
因此,他们知道他们需要一个无状态的 HTTP 服务,在负载均衡器后面进行水平扩展,一个 NoSQL 文档存储,一个缓存层,一个静态网站前端,等等。第三代的工具对他们来说太低级了;理想情况下,他们希望用这些高级别的系统架构术语来描述他们的基础设施,然后将如何在给定的云供应商上最好地实现这种架构的细节委托给他们的 IaC 工具。
第三代工具的第二个缺点是,它们将 IaC 与应用程序代码完全分开。例如,在上面的 CDK 的例子中,Lambda 函数的代码与它的基础设施定义完全脱节。而且,虽然 CDK 有资产的概念,允许这两种类型的代码在同一个版本控制仓库中存在,但它们仍然不能相互对接。从某种意义上说,这就是重复 —— 我的应用程序代码使用了 SQS 队列,这对我的 IaC 提出了一个隐含的要求,即正确配置该队列。
但是,就像所有的重复和隐含要求一样,当双方意外地不同步时(例如,如果我从我的基础设施代码中删除了队列,但忘记更新我的应用程序代码以不再使用它),这可能会导致问题,而且在我部署我的更改之前,我的语言的编译器并不能帮助我捕获这些错误,可能会引发问题。
第四代 IaC 工具的目标是解决上述两个问题。它们的主要理念是,在云计算时代,基础设施代码和应用程序代码之间的区别已经变得没有太大意义。因为两者都在使用托管服务的语言,我在应用程序代码中想使用的任何资源,都需要在我的基础设施代码中存在,就像我们在 Lambda 和 SQS 的例子中看到的一样。
因此,这些工具将两者统一起来。它们不再是独立的基础设施和应用程序代码,而是消除了前者,只保留了应用程序代码,而基础设施则完全来自应用程序代码。由于这个原因,这种方法被称为 Infrastructure from Code,而不是 Infrastructure as Code。
让我们来看看 IfC 工具的两个例子。
Eventual
第一个是 Eventual,一个 TypeScript 库,它定义了现代云应用的几个通用构建模块:Service、API、Workflow、Task、Event 以及其他一些东西。你可以从这些通用构件中创建一个任意复杂的应用程序,把它们组合在一起,就像乐高积木一样。
Eventual 部署引擎知道如何将这些构建模块转换为 AWS 资源,如 Lambda 函数、API 网关、StepFunction 状态机、EventBridge 规则等。这种转换的细节被库的抽象所隐藏,因此,作为它的用户,你无需关心这些细节 —— 你只需使用所提供的构件模块,部署由库处理。
下面是一个简单的例子,显示 Event、Subscription、Task、Workflow 和 API:
import { event, subscription, task, workflow, command } from “@eventual/core”;// define an Event
export interface HelloEvent {
message: string;
}
export const helloEvent = event<HelloEvent>(“HelloEvent”);// get notified each time the event is emitted
export const onHelloEvent = subscription(“onHelloEvent”, {
events: [helloEvent],
}, async (event) => {
console.log(“received event:”, event);
});// a Task that formats the received message
export const helloTask = task(“helloTask”, async (name: string) => {
return `hello ${name}`;
});// an example Workflow that uses the above Task
export const helloWorkflow = workflow(“helloWorkflow”, async (name: string) => {
// call the Task to format the message
const message = await helloTask(name);// emit an Event, passing it some data
await helloEvent.emit({
message,
});return message;
});// create a REST API for POST /hello <name>
export const hello = command(“hello”, async (name: string) => {
// trigger the above Workflow
const { executionId } = await helloWorkflow.startExecution({
input: name,
});return { executionId };
});
Wing
另一种方法是创建一个全新的通用编程语言,该语言不仅仅在单台机器上执行,而是从一开始就设计成在云上分布式运行。Wing 就是由 Monada 公司创建的一种这样的语言,该公司的联合创始人是 AWS CDK 的创建者 Elad Ben-Israel。
Wing 通过引入执行阶段的概念成功地将基础设施代码和应用程序代码合并在一起。默认情况下,Preflight 对应于 “构建时间”,在这个阶段执行基础设施代码;Inflight 对应于 “运行时间”,应用程序代码在云上运行。
通过 Wing 编译器实现的复杂的引用机制,Inflight 代码可以使用 Preflight 代码中定义的对象。然而,Inflight 阶段不能创建新的 Preflight 对象,只能使用这些对象明确标有修饰符的特定 API Inflight。Wing 编译器会确保你的程序遵守这些规则,所以如果你试图破坏这些规则,它就会编译失败,并为你快速提供关于应用程序正确性的反馈。
因此,我们上面看到的那个由队列触发的无服务器函数的例子,在 Wing 中看起来会是下面这样的:
bring cloud;let queue = new cloud.Queue(timeout: 2m);
let bucket = new cloud.Bucket();queue.addConsumer(inflight (item: str): str => {
// get an item from the bucket with the name equal to the message
let object = bucket.get(item);
// do something with ‘object’…
});
这段代码相当高级 —— 我们甚至没有在任何地方明确提到无服务器函数资源,我们只是在一个匿名函数中写了我们的应用代码,用 Inflight 修改器进行了注释。该匿名函数被部署在无服务器函数中,并在云上执行(或在 Wing 附带的本地模拟器中执行,以提供快速开发体验)。
还要注意的是,我们不能在应用代码中错误地使用错误的资源。例如,错误地使用 SNS 主题而不是 SQS 队列,因为在 Preflight 的代码中没有定义 Topic 对象,所以我们没有办法在 Inflight 的代码中引用它。同样,你也不能在 Preflight 的代码中使用 bucket.get() 方法,因为那是一个 Inflight 的专用 API。这样一来,语言本身就可以防止我们犯很多错误,如果基础设施和应用代码是分开的,这些错误就不会被发现。
如果你想了解更多关于 Infrastructure from Code 的新趋势,我推荐这篇来自 Ala Shiban 的文章,他是这个领域另一个工具 Klotho 的联合创始人。
https://klo.dev/state-of-infrastructure-from-code-2023
#06
总结
这就是 IaC 领域的历史和最新发展。小编认为这值得密切关注,因为它是当今软件工程中最热门的领域之一,甚至在一些产品中还将最新的人工智能进展纳入其中,比如 EventualAI 和 Pulumi Insights。
相信在不久的将来,这个领域将会涌现出许多新的方法,这些方法将对我们编写和发布软件的方式产生深远的影响。