配置语言的黄金时代

{"type":"doc","content":[{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"我认为我们当前所认知的DevOps即将走到尽头。至少,其中的Ops会如此。随着云基础设施成为应用程序关注的重点,越来越多的ops任务由云本身完成或内置于应用程序中。剩下的就是供应和管理应用程序所需的基础设施。这关系到所有的相关附属内容,例如安全性和网络。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"对于一般用例来说,所有这些正在形成的模式都将使基础设施供应与应用程序本身难以区分。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"现在,在大多数公司中,传统的IT团队已经将自己更名为DevOps,并广泛采用AWS,而不是本地的VMWare集群。他们使用Terraform而不是bash脚本,并且通常更为敏捷,采用了许多开发实践。他们都是些熟悉网络的专业人员,了解IAM在AWS中的工作方式。他们负责搭建网络和基础设施环境,保障其安全性,并将其移交给使用它们的应用程序。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"开发人员多半会觉得这样很不错,因为他们不想学习AWS IAM或VPC的复杂之处。我回想起了在2000年进入这个行业时如何处理数据库的方方面面。那时,应用程序不会涉及数据库的结构,由DBA在生产系统上运行数据库脚本。这些脚本将创建数据库、表、索引,这差不多是整个数据库结构了。然后,开发人员将这些映射到他们的代码中,只要在确定的模式(由其他人管理)上运行该应用程序,则执行DML。如今,我对基础设施有相同的看法。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"只要ORM和其他各种框架开始管理这一领域,这些问题就会被捆绑到应用程序中。没有理由认为在基础设施上不会发生同样的事情。唯一的问题是,我们没有合适的框架来做这件事,但现在我们正在开始得到它们。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"大规模处理应用程序基础设施(我认为这与管理广告、电子邮件、金融系统之类的核心业务服务基础设施不同)的需求出现在虚拟化时代,始于CFEngine。向那些不了解CFEngine的人介绍一下,CFEngine是我们今天所要了解的配置管理系统中的第一批产品其中的一款,在它之后是Puppet、Chef和其他的配置管理系统。CFEngine出现的年代,大多数供应都是手工完成的,或者是按照现有文档的一步一步的操作说明来完成的。该提议的核心是马克·伯吉斯(Mark Burgess)撰写的一份白皮书"},{"type":"link","attrs":{"href":"https:\/\/cosminilie.ro\/posts\/evolution-of-configuration-languages\/#fn:1","title":"","type":null},"content":[{"type":"text","text":"1"}]},{"type":"text","text":"。其理论的主线是:"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"blockquote","content":[{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"当今的计算机系统是脆弱的、不可靠的。在计算机系统运维的每个阶段,都有人类参与维护和维修。这么高度的人类参与在未来将不可能维持下去。同等复杂度甚至更复杂的生物和社会系统具有自我修复过程,这对其生存至关重要。如果我们未来的计算机系统想要在复杂而不利的环境中生存下去,就有必要模拟这样的系统。"}]}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"如何构建能大规模运行并共享源自于免疫系统的一些思想的自我修复系统?为破解该难题涌现出了第一批招法,CFEngine正是其中之一。为了实现这一点,它祭出3个重要法宝:1)它使用DSL来描述所需的状态,而不是过程式的语言。这将尝试抽象组件,以使管理员能够进行参数化和重用。2) CFEngine具有聚合语义,即描述一个系统应该是什么样子的,当系统处于那种状态时CFEngine就变成惰性的。3)在几个管理单元独立工作、几乎没有机会交流的情况下,它可以防止任务重复和进程挂起。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"使用这些特性,可以实现一个自校正和自修复系统,从而得到一个可保持容错性的系统。现在有了AWS,我们可以通过利用多区域性的服务来设计一个表现有相同属性的系统。从本质上讲,如果精心设计,这些服务可以将这些属性传递给应用程序。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"在此期间或不久之后,出现了许多其他工具,每一种工具的侧重点是最初那份价值主张的不同方面。其中,可能最受欢迎的是Puppet和Chef。他们各有所长。在我就职的公司,我们使用Puppet来处理基础设施配置,主要的原因是非编程人员更容易理解它。从系统管理员的视角来看,在不深入编码的情况下完成某些工作是很具吸引力的。随着时间的推移,这被证明是一个错误的选择,它对于我们来说弊大于利。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"Puppet有它自己的DSL、它自己的术语和特性。它有自己的工具生态系统、仪表板和扩展,可以帮助你在管理基础设施方面走得很远。它的优势是处理各系统之间不需要分布式协调的单独的服务器系统。可以通过导出资源和PuppetDB在多个服务器之间进行协调,但对我来说,这总是让人觉得很不爽(现在可能和当时有所不同了,但我已经好几年没有关注这个领域了)。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"codeblock","attrs":{"lang":"java"},"content":[{"type":"text","text":"#https:\/\/github.com\/voxpupuli\/puppet-minecraft\/blob\/master\/manifests\/user.pp\n\nclass minecraft::user {\n group { $minecraft::group:\n ensure => present,\n system => true,\n }\n\n user { $minecraft::user:\n ensure => present,\n gid => $minecraft::group,\n home => $minecraft::install_dir,\n managehome => true,\n system => true,\n require => Group[$minecraft::group],\n }\n\n # Ensures deletion of install_dir does not break module, setup for plugins\n $dirs = [$minecraft::install_dir, \"${minecraft::install_dir}\/plugins\"]\n\n file { $dirs:\n ensure => directory,\n owner => $minecraft::user,\n group => $minecraft::group,\n require => User[$minecraft::user],\n }\n}\n"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"在处理简单的基础设施组件时,这完全没有问题,我们可以做大量的自动化来管理数百台服务器。但像大多数事情一样,让你痛苦不堪的往往是一些极端情况。由于Puppet语言是一种DSL,简单的问题开始变成大问题。比如,无法做到基本的for循环,甚至连字符串操作也做不到。大多数配置语言都存在这些问题。最后,你可能还会遇到这样的情况:你需要扩展它们以涵盖特定的用例,通常,要做到这一点,就需要编写真正的代码。如果不使用“真正的”语言,你能做的事情也就会十分有限,所以我们可能从一开始就应该选择合适的做法。现在回想起来,也许Chef会是一个更好的选择。至少使用它的时候,我学到的很多东西可以迁移到我职业生涯的其他地方(Chef使用ruby而不是他们自己的DSL)。我对Ansible和Salt没有太多的经验,但我觉得它们都有同样的缺点和相似的优点。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"在配置语言的领域中,有一类稍微有所不同,那就是Terraform和AWS Cloud Formation。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"他们都借鉴了配置管理系统的遗风,并试图与他们的内部观点保持同步,即所见应该与事实相一致。除了表达意图的方式(仍然使用DSL,而不是非常成熟的语言)之外,主要的区别在于它们的设计定位是云提供商层。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"正如Puppet和Chef非常擅长管理机器上的典型资源(服务、包、配置文件),Terraform和AWSCloud Formation非常擅长管理云服务。它们是基于云基础设施的概念打造的。这并不意味着你不能使用Puppet、Chef和其他第二代配置语言做同样的事情。虽说如此,但由于Terraform和AWSCloud Formation 非常快速地适应了云的现实情况,再加上它们设法通过云赚钱的方式等等原因,Terraform成为了基于DSL的云基础设施管理领域无可争议的王者,而Cloud Formation则在AWS领域独霸天下。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"所有这些工具都采纳了CFEngine中最好的地方,其中最重要的是收敛状态的概念。他们会把你表达的意图,与机器进行比较,找出任何依赖关系和步骤顺序,使资源达到它想要的状态。通常,它们还包含一个编译阶段,在此阶段,它们将DSL映射到内部逻辑并创建执行计划。这还将捕捉基本的错误。这些都是经过实践检验过的好想法,现在已经成为处理基础设施的默认方式。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"然而,随着我们的进步,我们消费和处理基础设施组件的方式正在发生根本性的变化。现在你可以利用AWS服务了。你可以构建一个非常复杂的应用程序,使用CloudFront来进行静态内容分发,使用Lambda的API网关来构建API路由并向其添加业务功能,可以通过Cognito来处理身份管理。在后台,这些Lambda函数可以与整个基础设施生态系统直接交互,如RDS或DynamoDB。你可以通过Redshift与分析系统交互,也可以通过QuickSight展示可视化数据。除了使用AWS EMR或Glue处理具有步骤函数的工作流驱动、异步批处理、ETL任务之外,还可以由Lambda处理后台任务。由于AWS服务很好地集成在一起,只需相对较少的粘合代码即可组合这些服务以实现巨大的业务价值。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"当前,正在涌现出新的应用程序类别,它们试图更好地适应这种环境。比方说,由AWS称之为无服务器的这一类。使用Terraform 或Cloud Formation为这些类型的应用提供服务可能不会那么顺畅。在这样一个紧密集成的模型中,你的基础设施将随着应用程序的发展而发展,因此这可能是最重要的应用程序关注点之一。AWS在这里推行的是SAM模型,但我可以预见到未来类似Ruby-on-Rails或Django的框架将把AWS基础设施视为数据库。那么,像“rails migrate”之类的对云会有什么影响呢?它对数据模式会有什么影响呢?这对于Rails开发人员来说,可能相当不错。无论是好是坏,我认为我们正在沿着这条路走下去。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"那么我们做到了吗?我不这么想。随着一些常见抽象概念尘埃落定,你在所有云中可以使用它们了,我们才可能会做到。大家做过一些尝试,比如go 世界的“go-cloud”,它试图在最常见的标准之上构建一个抽象,但这是一场艰苦的战斗。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"目前,我把赌注押在Pulumi和他们的自动化api之上。让我们稍稍回溯一下。Pulumi是一个框架(你可以称它为配置语言框架),它允许你用诸如javascript、typescript、python、go、c#之类的主流语言编写代码。它使用的仍然是与其他配置语言相同的概念,而且大多数支持实际上是建立在Terraform之上的。它真正有趣的是,既然你在写代码,就真的是在写代码。你可以利用你喜欢的所有包和你喜欢的所有编程范例,以及语言生态系统中的IDE和构建工具。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"他们在网站上展示的第一个例子是:"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"codeblock","attrs":{"lang":"java"},"content":[{"type":"text","text":"\/\/ Create a serverless REST API\nimport * as awsx from \"@pulumi\/awsx\";\n\n\/\/ Serve a simple REST API at `GET \/hello`.\nlet app = new awsx.apigateway.API(\"my-app\", {\n routes: [{\n path: \"\/hello\",\n method: \"GET\",\n eventHandler: async (event) => {\n return {\n statusCode: 200,\n body: JSON.stringify({ hello: \"World!\" }),\n };\n },\n }],\n});\n\nexport let url = app.url;\n"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"它所做的,是在AWS中创建一个带有“\/hello”路由的API网关,并将其代理给一个用javascript编写的AWS Lambda函数。这个lambda函数只返回200编码和一个HTML体,其中包含一个JSON对象,内容为:{hello: \"World\"!}。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"让我们来看另一个例子:"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"codeblock","attrs":{"lang":"java"},"content":[{"type":"text","text":"import * as pulumi from \"@pulumi\/pulumi\";\nimport * as aws from \"@pulumi\/aws\";\nimport * as awsx from \"@pulumi\/awsx\";\n\nexport = async () => {\n const config = new pulumi.Config(\"aws\");\n const providerOpts = { provider: new aws.Provider(\"prov\", { region: config.require(\"envRegion\") }) };\n\n const vpc = awsx.ec2.Vpc.getDefault(providerOpts);\n\n \/\/ Create a security group to let traffic flow.\n const sg = new awsx.ec2.SecurityGroup(\"web-sg\", { vpc }, providerOpts);\n\n const ipv4egress = sg.createEgressRule(\"ipv4-egress\", {\n ports: new awsx.ec2.AllTraffic(),\n location: new awsx.ec2.AnyIPv4Location(),\n });\n const ipv6egress = sg.createEgressRule(\"ipv6-egress\", {\n ports: new awsx.ec2.AllTraffic(),\n location: new awsx.ec2.AnyIPv6Location(),\n });\n\n \/\/ Creates an ALB associated with the default VPC for this region and listen on port 80.\n const alb = new awsx.elasticloadbalancingv2.ApplicationLoadBalancer(\"web-traffic\",\n { vpc, external: true, securityGroups: [ sg ] }, providerOpts);\n const listener = alb.createListener(\"web-listener\", { port: 80 });\n\n \/\/ For each subnet, and each subnet\/zone, create a VM and a listener.\n const publicIps: pulumi.Output[] = [];\n const subnets = await vpc.publicSubnets;\n for (let i = 0; i < subnets.length; i++) {\n const getAmiResult = await aws.getAmi({\n filters: [\n { name: \"name\", values: [ \"ubuntu\/images\/hvm-ssd\/ubuntu-trusty-14.04-amd64-server-*\" ] },\n { name: \"virtualization-type\", values: [ \"hvm\" ] },\n ],\n mostRecent: true,\n owners: [ \"099720109477\" ], \/\/ Canonical\n }, { ...providerOpts, async: true });\n\n const vm = new aws.ec2.Instance(`web-${i}`, {\n ami: getAmiResult.id,\n instanceType: \"m5.large\",\n subnetId: subnets[i].subnet.id,\n availabilityZone: subnets[i].subnet.availabilityZone,\n vpcSecurityGroupIds: [ sg.id ],\n userData: `#!\/bin\/bash\n echo \"Hello World, from Server ${i+1}!\" > index.html\n nohup python -m SimpleHTTPServer 80 &`,\n }, providerOpts);\n publicIps.push(vm.publicIp);\n\n alb.attachTarget(\"target-\" + i, vm);\n }\n\n \/\/ Export the resulting URL so that it's easy to access.\n return { endpoint: listener.endpoint, publicIps: publicIps };\n};\n"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"这是一个更复杂的示例2("},{"type":"link","attrs":{"href":"https:\/\/cosminilie.ro\/posts\/evolution-of-configuration-languages\/#fn:2","title":"","type":null},"content":[{"type":"text","text":"https:\/\/cosminilie.ro\/posts\/evolution-of-configuration-languages\/#fn:2"}]},{"type":"text","text":"),但是如果感觉太复杂,可以使用Fargate3("},{"type":"link","attrs":{"href":"https:\/\/cosminilie.ro\/posts\/evolution-of-configuration-languages\/#fn:3","title":"","type":null},"content":[{"type":"text","text":"https:\/\/cosminilie.ro\/posts\/evolution-of-configuration-languages\/#fn:3"}]},{"type":"text","text":")作为计算部分,这个版本会更简单。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"让我们来看看它是做了什么。首先构建一个内部的Pulumi上下文,以了解在AWS中使用哪个区域,之后,它将配置AWS VPC的网络部分。这相当重要,因为它非常体贴地考虑了所要做的(甚至是手工要做的)AWS内部网络的领域知识。尽管如此,只需要一行代码,即可干净利落地完成。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"codeblock","attrs":{"lang":"plain"},"content":[{"type":"text","text":"import * as pulumi from \"@pulumi\/pulumi\";\nimport * as aws from \"@pulumi\/aws\";\nimport * as awsx from \"@pulumi\/awsx\";\n\nexport = async () => {\n const config = new pulumi.Config(\"aws\");\n const providerOpts = { provider: new aws.Provider(\"prov\", { region: config.require(\"envRegion\") }) };\n\n const vpc = awsx.ec2.Vpc.getDefault(providerOpts);\n\n \/\/ Create a security group to let traffic flow.\n const sg = new awsx.ec2.SecurityGroup(\"web-sg\", { vpc }, providerOpts);\n\n const ipv4egress = sg.createEgressRule(\"ipv4-egress\", {\n ports: new awsx.ec2.AllTraffic(),\n location: new awsx.ec2.AnyIPv4Location(),\n });\n const ipv6egress = sg.createEgressRule(\"ipv6-egress\", {\n ports: new awsx.ec2.AllTraffic(),\n location: new awsx.ec2.AnyIPv6Location(),\n });\n\n \/\/ Creates an ALB associated with the default VPC for this region and listen on port 80.\n const alb = new awsx.elasticloadbalancingv2.ApplicationLoadBalancer(\"web-traffic\",\n { vpc, external: true, securityGroups: [ sg ] }, providerOpts);\n const listener = alb.createListener(\"web-listener\", { port: 80 });\n\n \/\/ For each subnet, and each subnet\/zone, create a VM and a listener.\n const publicIps: pulumi.Output[] = [];\n const subnets = await vpc.publicSubnets;\n for (let i = 0; i < subnets.length; i++) {\n const getAmiResult = await aws.getAmi({\n filters: [\n { name: \"name\", values: [ \"ubuntu\/images\/hvm-ssd\/ubuntu-trusty-14.04-amd64-server-*\" ] },\n { name: \"virtualization-type\", values: [ \"hvm\" ] },\n ],\n mostRecent: true,\n owners: [ \"099720109477\" ], \/\/ Canonical\n }, { ...providerOpts, async: true });\n\n const vm = new aws.ec2.Instance(`web-${i}`, {\n ami: getAmiResult.id,\n instanceType: \"m5.large\",\n subnetId: subnets[i].subnet.id,\n availabilityZone: subnets[i].subnet.availabilityZone,\n vpcSecurityGroupIds: [ sg.id ],\n userData: `#!\/bin\/bash\n echo \"Hello World, from Server ${i+1}!\" > index.html\n nohup python -m SimpleHTTPServer 80 &`,\n }, providerOpts);\n publicIps.push(vm.publicIp);\n\n alb.attachTarget(\"target-\" + i, vm);\n }\n\n \/\/ Export the resulting URL so that it's easy to access.\n return { endpoint: listener.endpoint, publicIps: publicIps };\n};\n\n"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"这一句执行完成之后,你将有一个默认的VPC,包括私有和公共子网,设置一个互联网网关和路由表,这样指定的公共子网就有它的默认路由(0.0.0.0)指向互联网网关。当我们在公共子网中创建EC2实例时,它们将可以从internet访问,并具有出站internet连接,而私有子网中的实例将只能在VPC中访问,不可以访问internet。这是AWS推荐的设置,默认情况下是安全的。接下来,它创建一个安全组(以及AWS EC2特性,它的工作原理类似于防火墙规则),只允许通过ipv6和ipv4向附加了安全组的资源发送web流量。具体在本例中,它将成为一个负载均衡器(Application Load Balancer是AWS产品名称,即应用程序负载均衡器)。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"codeblock","attrs":{"lang":"java"},"content":[{"type":"text","text":" \/\/ Create a security group to let traffic flow.\n const sg = new awsx.ec2.SecurityGroup(\"web-sg\", { vpc }, providerOpts);\n\n const ipv4egress = sg.createEgressRule(\"ipv4-egress\", {\n ports: new awsx.ec2.AllTraffic(),\n location: new awsx.ec2.AnyIPv4Location(),\n });\n const ipv6egress = sg.createEgressRule(\"ipv6-egress\", {\n ports: new awsx.ec2.AllTraffic(),\n location: new awsx.ec2.AnyIPv6Location(),\n });\n\n \/\/ Creates an ALB associated with the default VPC for this region and listen on port 80.\n const alb = new awsx.elasticloadbalancingv2.ApplicationLoadBalancer(\"web-traffic\",\n { vpc, external: true, securityGroups: [ sg ] }, providerOpts);\n const listener = alb.createListener(\"web-listener\", { port: 80 });\n\n"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"现在我们有了自己的负载均衡器,我们将等待子网的创建(参见await关键字)。一旦完成,我们就可以遍历所有公共子网,并在每个子网中使用ubuntu AMI创建一个EC2实例。出于测试目的,我们将使用userData脚本注入一个小的bash脚本来创建HTML页面。这将启动一个python嵌入式web服务器来为它提供服务。在这里,我们可以做任何事情(例如,从s3获取一个spring boot应用程序或者任何类型的应用程序并启动和运行它)。最后,我们将把EC2实例附加到ELB上,这样就完成了。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"codeblock","attrs":{"lang":"java"},"content":[{"type":"text","text":"const subnets = await vpc.publicSubnets;\n for (let i = 0; i < subnets.length; i++) {\n const getAmiResult = await aws.getAmi({\n filters: [\n { name: \"name\", values: [ \"ubuntu\/images\/hvm-ssd\/ubuntu-trusty-14.04-amd64-server-*\" ] },\n { name: \"virtualization-type\", values: [ \"hvm\" ] },\n ],\n mostRecent: true,\n owners: [ \"099720109477\" ], \/\/ Canonical\n }, { ...providerOpts, async: true });\n\n const vm = new aws.ec2.Instance(`web-${i}`, {\n ami: getAmiResult.id,\n instanceType: \"m5.large\",\n subnetId: subnets[i].subnet.id,\n availabilityZone: subnets[i].subnet.availabilityZone,\n vpcSecurityGroupIds: [ sg.id ],\n userData: `#!\/bin\/bash\n echo \"Hello World, from Server ${i+1}!\" > index.html\n nohup python -m SimpleHTTPServer 80 &`,\n }, providerOpts);\n\n alb.attachTarget(\"target-\" + i, vm);\n }\n"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"继续,在本例中,我们创建了一个AWS VPC,并根据AWS最佳实践配置了所有网络。我们建立了一个负载均衡器,确保它不允许非期望的流量,在每个AWS可用性区域部署了几个AWS EC2实例以获得容错性(这也是AWS的最佳实践),然后部署了我们的网页。不需要专门的教育、培训,不需要启用专门的人才,是么?我们在没有“DevOps”工程师的情况下做到了这一点。当然,与任何领域特定的框架一样,需要一些该领域的知识,但是一旦你学习了一些SDK,云与你正在使用的任何其他框架没有什么不同。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"现在,所有这些都弄好了,但你如何将它融入到你自己的应用中呢?不幸的是,这个问题的答案仍在研究中。但是考虑到它很可能与应用程序使用相同的语言,所以让所有东西都在相同的repo中会更合理。它仍然需要一个单独的工具来运行(Pulumi),但你可以把它看作是该工具链中的另一个工具。如果是这样的话,若不使用构建应用程序和在云基础设施中所用的程序语言,还有什么意义呢?例如,如果我不得不使用一个单独的工具,那么它与使用Terraform并没有什么不同。这就是Pulumi自动化api的由来。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"codeblock","attrs":{"lang":"java"},"content":[{"type":"text","text":"import { InlineProgramArgs, LocalWorkspace } from \"@pulumi\/pulumi\/x\/automation\";\nimport * as aws from \"@pulumi\/aws\";\nimport * as awsx from \"@pulumi\/awsx\";\nimport * as pulumi from \"@pulumi\/pulumi\";\nimport * as mysql from \"mysql\";\n\nconst process = require('process');\n\nconst args = process.argv.slice(2);\nlet destroy = false;\nif (args.length > 0 && args[0]) {\n destroy = args[0] === \"destroy\";\n}\n\n\n\nconst run = async () => {\n \/\/ This is our pulumi program in \"inline function\" form\n const pulumiProgram = async () => {\n const vpc = awsx.ec2.Vpc.getDefault();\n const subnetGroup = new aws.rds.SubnetGroup(\"dbsubnet\", {\n subnetIds: vpc.publicSubnetIds,\n });\n\n \/\/ make a public SG for our cluster for the migration\n const securityGroup = new awsx.ec2.SecurityGroup(\"publicGroup\", {\n egress: [\n {\n protocol: \"-1\",\n fromPort: 0,\n toPort: 0,\n cidrBlocks: [\"0.0.0.0\/0\"],\n }\n ],\n ingress: [\n {\n protocol: \"-1\",\n fromPort: 0,\n toPort: 0,\n cidrBlocks: [\"0.0.0.0\/0\"],\n }\n ]\n });\n\n \/\/ example only, you should change this\n const dbName = \"hellosql\";\n const dbUser = \"hellosql\";\n const dbPass = \"hellosql\";\n\n \/\/ provision our db\n const cluster = new aws.rds.Cluster(\"db\", {\n engine: \"aurora-mysql\",\n engineVersion: \"5.7.mysql_aurora.2.03.2\",\n databaseName: dbUser,\n masterUsername: dbName,\n masterPassword: dbPass,\n skipFinalSnapshot: true,\n dbSubnetGroupName: subnetGroup.name,\n vpcSecurityGroupIds: [securityGroup.id],\n });\n\n const clusterInstance = new aws.rds.ClusterInstance(\"dbInstance\", {\n clusterIdentifier: cluster.clusterIdentifier,\n instanceClass: \"db.t3.small\",\n engine: \"aurora-mysql\",\n engineVersion: \"5.7.mysql_aurora.2.03.2\",\n publiclyAccessible: true,\n dbSubnetGroupName: subnetGroup.name,\n });\n\n return {\n host: pulumi.interpolate`${cluster.endpoint}`,\n dbName,\n dbUser,\n dbPass\n };\n };\n\n \/\/ Create our stack \n const args: InlineProgramArgs = {\n stackName: \"dev\",\n projectName: \"databaseMigration\",\n program: pulumiProgram\n };\n\n \/\/ create (or select if one already exists) a stack that uses our inline program\n const stack = await LocalWorkspace.createOrSelectStack(args);\n\n console.info(\"successfully initialized stack\");\n console.info(\"installing plugins...\");\n await stack.workspace.installPlugin(\"aws\", \"v3.6.1\");\n console.info(\"plugins installed\");\n console.info(\"setting up config\");\n await stack.setConfig(\"aws:region\", { value: \"us-west-2\" });\n console.info(\"config set\");\n console.info(\"refreshing stack...\");\n await stack.refresh({ onOutput: console.info });\n console.info(\"refresh complete\");\n\n if (destroy) {\n console.info(\"destroying stack...\");\n await stack.destroy({ onOutput: console.info });\n console.info(\"stack destroy complete\");\n process.exit(0);\n }\n\n console.info(\"updating stack...\");\n const upRes = await stack.up({ onOutput: console.info });\n console.log(`update summary: \\n${JSON.stringify(upRes.summary.resourceChanges, null, 4)}`);\n console.log(`db host url: ${upRes.outputs.host.value}`);\n console.info(\"configuring db...\");\n\n \/\/ establish mysql client\n const connection = mysql.createConnection({\n host: upRes.outputs.host.value,\n user: upRes.outputs.dbUser.value,\n password: upRes.outputs.dbPass.value,\n database: upRes.outputs.dbName.value\n });\n\n connection.connect();\n\n console.log(\"creating table...\")\n\n \/\/ make sure the table exists\n connection.query(`\n CREATE TABLE IF NOT EXISTS hello_pulumi(\n id int(9) NOT NULL,\n color varchar(14) NOT NULL,\n PRIMARY KEY(id)\n );\n `, function (error, results, fields) {\n if (error) throw error;\n console.log(\"table created!\")\n console.log('Result: ', JSON.stringify(results));\n console.log(\"seeding initial data...\")\n });\n \n \/\/ seed the table with some data to start\n connection.query(`\n INSERT IGNORE INTO hello_pulumi (id, color)\n VALUES\n (1, 'Purple'),\n (2, 'Violet'),\n (3, 'Plum');\n `, function (error, results, fields) {\n if (error) throw error;\n console.log(\"rows inserted!\")\n console.log('Result: ', JSON.stringify(results));\n console.log(\"querying to veryify data...\")\n });\n\n \n \/\/ read the data back\n connection.query(`SELECT COUNT(*) FROM hello_pulumi;`, function (error, results, fields) {\n if (error) throw error;\n console.log('Result: ', JSON.stringify(results));\n console.log(\"database, tables, and rows successfuly configured!\")\n });\n\n connection.end();\n};\n\nrun().catch(err => console.log(err));\n"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"就代码行数而言,这个例子比较大,但要点是:我们是在相同的代码库中创建的基础结构和表结构。我们来看一下。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"第一部分负责AWS中的网络设置,并创建一个允许所有访问的安全组。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"codeblock","attrs":{"lang":"java"},"content":[{"type":"text","text":" const vpc = awsx.ec2.Vpc.getDefault();\n const subnetGroup = new aws.rds.SubnetGroup(\"dbsubnet\", {\n subnetIds: vpc.publicSubnetIds,\n });\n\n \/\/ make a public SG for our cluster for the migration\n const securityGroup = new awsx.ec2.SecurityGroup(\"publicGroup\", {\n egress: [\n {\n protocol: \"-1\",\n fromPort: 0,\n toPort: 0,\n cidrBlocks: [\"0.0.0.0\/0\"],\n }\n ],\n ingress: [\n {\n protocol: \"-1\",\n fromPort: 0,\n toPort: 0,\n cidrBlocks: [\"0.0.0.0\/0\"],\n }\n ]\n });"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"基础设施部分的第二部分使用Aurora创建数据库实例:"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"codeblock","attrs":{"lang":"java"},"content":[{"type":"text","text":" \/\/ example only, you should change this\n const dbName = \"hellosql\";\n const dbUser = \"hellosql\";\n const dbPass = \"hellosql\";\n\n \/\/ provision our db\n const cluster = new aws.rds.Cluster(\"db\", {\n engine: \"aurora-mysql\",\n engineVersion: \"5.7.mysql_aurora.2.03.2\",\n databaseName: dbUser,\n masterUsername: dbName,\n masterPassword: dbPass,\n skipFinalSnapshot: true,\n dbSubnetGroupName: subnetGroup.name,\n vpcSecurityGroupIds: [securityGroup.id],\n });\n\n const clusterInstance = new aws.rds.ClusterInstance(\"dbInstance\", {\n clusterIdentifier: cluster.clusterIdentifier,\n instanceClass: \"db.t3.small\",\n engine: \"aurora-mysql\",\n engineVersion: \"5.7.mysql_aurora.2.03.2\",\n publiclyAccessible: true,\n dbSubnetGroupName: subnetGroup.name,\n });\n\n return {\n host: pulumi.interpolate`${cluster.endpoint}`,\n dbName,\n dbUser,\n dbPass\n };\n };\n"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"现在,所有这些都被封装在Pulumi“栈”中,运行以创建基础设施:"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"codeblock","attrs":{"lang":"java"},"content":[{"type":"text","text":"\/\/ Create our stack \n const args: InlineProgramArgs = {\n stackName: \"dev\",\n projectName: \"databaseMigration\",\n program: pulumiProgram\n };\n\n \/\/ create (or select if one already exists) a stack that uses our inline program\n const stack = await LocalWorkspace.createOrSelectStack(args);\n\n \/\/load pulumi plugins\n await stack.workspace.installPlugin(\"aws\", \"v3.6.1\");\n \n \/\/set the aws region and logging\n await stack.setConfig(\"aws:region\", { value: \"us-west-2\" });\n await stack.refresh({ onOutput: console.info });\n\n \/\/in case we run the program with the destroy flag to remove everythig\n if (destroy) {\n console.info(\"destroying stack...\");\n await stack.destroy({ onOutput: console.info });\n console.info(\"stack destroy complete\");\n process.exit(0);\n }\n\n \/\/wait for the infra to be provisioned\n const upRes = await stack.up({ onOutput: console.info });\n\n"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"一旦有了基础设施,我们就可以开始使用它了:"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"codeblock","attrs":{"lang":"java"},"content":[{"type":"text","text":" \/\/ establish mysql client\n const connection = mysql.createConnection({\n host: upRes.outputs.host.value,\n user: upRes.outputs.dbUser.value,\n password: upRes.outputs.dbPass.value,\n database: upRes.outputs.dbName.value\n });\n\n connection.connect();\n\n\n \/\/ make sure the table exists\n connection.query(`\n CREATE TABLE IF NOT EXISTS hello_pulumi(\n id int(9) NOT NULL,\n color varchar(14) NOT NULL,\n PRIMARY KEY(id)\n );\n `, function (error, results, fields) {\n if (error) throw error;\n console.log(\"table created!\")\n console.log('Result: ', JSON.stringify(results));\n console.log(\"seeding initial data...\")\n });\n \n \/\/ seed the table with some data to start\n connection.query(`\n INSERT IGNORE INTO hello_pulumi (id, color)\n VALUES\n (1, 'Purple'),\n (2, 'Violet'),\n (3, 'Plum');\n `, function (error, results, fields) {\n if (error) throw error;\n console.log(\"rows inserted!\")\n console.log('Result: ', JSON.stringify(results));\n console.log(\"querying to veryify data...\")\n });\n\n \n \/\/ read the data back\n connection.query(`SELECT COUNT(*) FROM hello_pulumi;`, function (error, results, fields) {\n if (error) throw error;\n console.log('Result: ', JSON.stringify(results));\n console.log(\"database, tables, and rows successfuly configured!\")\n });\n"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"该模型真正的亮点是在创建“无服务器”应用程序的时候。在此,开发人员已经创建了非常好的抽象,它使工作流平滑地工作,并向我们展示了未来的方向:"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"codeblock","attrs":{"lang":"java"},"content":[{"type":"text","text":"\/\/ Define a new GET endpoint backed by a lambda function and a static folder mapped to \/ which will be saved in s3.\nconst api = new awsx.apigateway.API(\"test\", {\n routes: [{\n path: \"\/\",\n localPath: \"www\",\n },\n {\n path: \"\/test\",\n method: \"GET\",\n eventHandler: async (event) => {\n \/\/ This code runs in an AWS Lambda anytime `\/test` is hit.\n return {\n statusCode: 200,\n body: \"Hello, API Gateway!\",\n };\n },\n }],\n})\n"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"它所做的是创建一个具有两个路由的API网关,一个用于根端点(\/),另一个用于\/test端点。当这个程序运行时,\/ 路由将从本地www目录上传s3 bucket中的内容。\/test端点的背后是一个lambda函数,其中的上下文取自事件处理程序代码块。这例子,但是考虑到我们到目前为止所讨论的内容,它还是非常吸引人的。虽然到目前为止我的大多数例子都是以Pulumi为基础,但它们并不是朝着唯一这个方向发展的。例如,AWS推出了“AWS CDK”"},{"type":"link","attrs":{"href":"https:\/\/cosminilie.ro\/posts\/evolution-of-configuration-languages\/#fn:4","title":"","type":null},"content":[{"type":"text","text":"4"}]},{"type":"text","text":"或云开发工具包。这允许你用你选择的语言编写代码,它将在运行时被“合成”进云结构堆栈。甚至还有一个“构造库”,允许你使用已经由AWS创建并将其包含在你的代码库中的组件。这些组件将许多复杂性(例如网络)抽象为易于使用的小单元,它们是安全的,并遵循了最佳实践。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"codeblock","attrs":{"lang":"java"},"content":[{"type":"text","text":"import * as core from \"@aws-cdk\/core\";\nimport * as apigateway from \"@aws-cdk\/aws-apigateway\";\nimport * as lambda from \"@aws-cdk\/aws-lambda\";\nimport * as s3 from \"@aws-cdk\/aws-s3\";\n\nexport class WidgetService extends core.Construct {\n constructor(scope: core.Construct, id: string) {\n super(scope, id);\n\n const bucket = new s3.Bucket(this, \"WidgetStore\");\n\n const handler = new lambda.Function(this, \"WidgetHandler\", {\n runtime: lambda.Runtime.NODEJS_10_X, \/\/ So we can use async in widget.js\n code: lambda.Code.asset(\"resources\"),\n handler: \"widgets.main\",\n environment: {\n BUCKET: bucket.bucketName\n }\n });\n\n bucket.grantReadWrite(handler); \/\/ was: handler.role);\n\n const api = new apigateway.RestApi(this, \"widgets-api\", {\n restApiName: \"Widget Service\",\n description: \"This service serves widgets.\"\n });\n\n const getWidgetsIntegration = new apigateway.LambdaIntegration(handler, {\n requestTemplates: { \"application\/json\": '{ \"statusCode\": \"200\" }' }\n });\n\n api.root.addMethod(\"GET\", getWidgetsIntegration); \/\/ GET \/\n }\n}"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"这就是AWS CDK样品的工作原理,就像我们在Pulumi上所做的一样。甚至Terraform也在朝着这个方向发展,它有一个基于AWS CDK的项目,你可以用typescript和python编写脚本。这些构造在底层使用了Terraform模块,用于跨多个云提供商提供基础设施。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"不管是好是坏,我认为我们正朝着一个方向前进,在最好的情况下,基础设施将与代码共存,就像构建文件与代码共存一样。但是,我认为大家会尝试更进一步,将基础设施代码集成到实际的应用程序中。所有这些都将由应用程序在运行时自行管理。现在这里有一个光谱,像大多数东西一样,不同的应用程序将处于其中某个地方。应用程序的类型将起到决大多数的决定作用。例如,我发现很难想象这对由Postgres实例支持的单体java应用程序的影响会像在AWS中运行的无服务器应用程序的影响那么大。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"对于DevOps工程师来说,其含义现在也会有所不同。一些公司的理解是创建一个负责构建ci管道的DevOps工程师角色,与之类似,有些公司的开发人员将接管整个职责,并使用本文提到的工具和实践来完成所有工作。作为职业生涯中大部分时间都在参与DevOps的从业者,我的建议是学习typescript并熟悉Pulumi。若不出所料,它会取得成功的,只是还需要些时日。如果这没有发生,至少,你可以随时切换成前端开发人员,因为我相信对他们的需求量会不断增长的。不过,AWS在用户体验方面做得很烂,可能不会来找你工作。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"horizontalrule"},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"numberedlist","attrs":{"start":1,"normalizeStart":1},"content":[{"type":"listitem","attrs":{"listStyle":null},"content":[{"type":"paragraph","attrs":{"indent":0,"number":1,"align":null,"origin":null},"content":[{"type":"link","attrs":{"href":"https:\/\/www.usenix.org\/legacy\/event\/lisa98\/full_papers\/burgess\/burgess.pdf","title":"","type":null},"content":[{"type":"text","text":"Computer Immunology - Mark Burgess"}]},{"type":"link","attrs":{"href":"https:\/\/cosminilie.ro\/posts\/evolution-of-configuration-languages\/#fnref:1","title":"","type":null},"content":[{"type":"text","text":"[return]"}]}]}]},{"type":"listitem","attrs":{"listStyle":null},"content":[{"type":"paragraph","attrs":{"indent":0,"number":2,"align":null,"origin":null},"content":[{"type":"link","attrs":{"href":"https:\/\/github.com\/pulumi\/pulumi-awsx\/tree\/master\/nodejs\/examples\/alb\/ec2Instance","title":"","type":null},"content":[{"type":"text","text":"https:\/\/github.com\/pulumi\/pulumi-awsx\/tree\/master\/nodejs\/examples\/alb\/ec2Instance"}]},{"type":"link","attrs":{"href":"https:\/\/cosminilie.ro\/posts\/evolution-of-configuration-languages\/#fnref:2","title":"","type":null},"content":[{"type":"text","text":"[return]"}]}]}]},{"type":"listitem","attrs":{"listStyle":null},"content":[{"type":"paragraph","attrs":{"indent":0,"number":3,"align":null,"origin":null},"content":[{"type":"link","attrs":{"href":"https:\/\/github.com\/pulumi\/pulumi-awsx\/tree\/master\/nodejs\/examples\/alb\/fargate","title":"","type":null},"content":[{"type":"text","text":"https:\/\/github.com\/pulumi\/pulumi-awsx\/tree\/master\/nodejs\/examples\/alb\/fargate"}]},{"type":"link","attrs":{"href":"https:\/\/cosminilie.ro\/posts\/evolution-of-configuration-languages\/#fnref:3","title":"","type":null},"content":[{"type":"text","text":"[return]"}]}]}]},{"type":"listitem","attrs":{"listStyle":null},"content":[{"type":"paragraph","attrs":{"indent":0,"number":4,"align":null,"origin":null},"content":[{"type":"link","attrs":{"href":"https:\/\/aws.amazon.com\/cdk\/","title":"","type":null},"content":[{"type":"text","text":"https:\/\/aws.amazon.com\/cdk\/"}]},{"type":"link","attrs":{"href":"https:\/\/cosminilie.ro\/posts\/evolution-of-configuration-languages\/#fnref:4","title":"","type":null},"content":[{"type":"text","text":"[return]"}]}]}]}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","marks":[{"type":"strong"}],"text":"原文链接:"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"link","attrs":{"href":"https:\/\/cosminilie.ro\/posts\/evolution-of-configuration-languages","title":"xxx","type":null},"content":[{"type":"text","text":"The golden age of configuration languages"}]}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"译者简介:冬雨,小小技术宅一枚,从事研发过程改进及质量改进方面的工作,关注编程、软件工程、敏捷、DevOps、云计算等领域,非常乐意将国外新鲜的IT资讯和深度技术文章翻译分享给大家,已翻译出版《深入敏捷测试》、《持续交付实战》。"}]}]}
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章