亚马逊AWS官方博客

推陈出新:使用CDK快速部署并持续发布CloudFront CDN服务

引言:

Amazon CloudFront 是亚马逊云科技在2008年推出的一项内容分发网络 (CDN) 服务,对于如何部署和使用 Amazon CloudFront 服务,相信对于绝大多数的亚马逊云科技的使用者来说都不会太陌生。您可以通过界面以鼠标点击的方式一步步配置并发 CDN 服务;也可以通过命令行加参数的方式配置并发布 CDN 服务;如果想实现可重复性的自动化批量部署 CDN 服务,以减少人工配置鼠标点击所带来的繁琐步骤并降低可能引发的配置错误的风险,您还可以通过编写 CloudFormation 模版以代码方式去快速发布您的 CDN 服务(Infrastructure as code,IaC 基础架构即代码)。从某种程度上来说,以 CloudFormation 模版部署的方式虽然可以提升 CDN 服务的部署效率和部署质量,但同时也给使用者带来了一个新的技术门槛 —— 熟悉并掌握 AWS CloudFormation 各个功能模块和编写语法。即使 CloudFormation 的模版是由有一定编程经验的开发者去编写,但由于它和其他编程语言不尽相同(使用 JSON 或 YAML声明式语言描述要创建和配置的 AWS 资源),对于开发者来说仍然会有一定的学习成本。

有没有一种更好的方式去兼顾 IaC 所带来的便利,同时又能降低 CloudFormation 模版编写的难度呢?答案就是 AWS CDK。AWS Cloud Development Kit (AWS CDK)是一种开源软件开发框架,可帮助您使用熟悉的编程语言(如TypeScript、Python、Java 和 .NET等)以熟悉的语法结构对亚马逊云科技的云资源进行建模,并生成对应的 CloudFormation 模版,从而完成最终的云资源部署。同时 AWS CDK 还提供高级组件,使用经过验证的默认值预置云资源,因此您无需像使用 CloudFormation 模版那样,需要预先具备很多云环境专业知识,才能构建云应用程序。

在这篇博客里我们就来一起实践一下如何使用 AWS CDK 去快速部署一套包含 Flask Web 应用程序的基础架构环境以及与其对应的 CloudFront CDN 分发服务;其中在部署 CloudFront 的同时我们还使用 CDK 为 CloudFront 部署了 Lambda@Edge 以及 CloudFront Functions,并通过两段示例代码来展现在CloudFront边缘侧用户自定义功能的函数计算能力;除此之外,我们还将一起实践如何通过 CDK Pipeline 来实现 CloudFront CDN 服务的持续发布。

实践一: CDK 快速部署 CloudFront CDN 服务

本次实践环节通过 AWS CDK 所部署的应用系统以及 CloudFront CDN 服务的整体架构如下图所示:

架构概述:

  • Flask Web 应用的主程序托管在 EC2 实例上,访问 EC2 时该程序会实时产生新的内容,应用程序所需的静态文件,如 css 文件、图片文件等托管在 S3 存储桶内。
  • CloudFront 创建一个 Distribution(分发),并为其添加两个源站,分别为 EC2 实例以及 S3 存储桶;其中一个源站通过为 Distribution 创建的默认 Behavior(行为)指向EC2实例源站,缓存策略设置为不缓存EC2实例所返回的内容,但缓存Lambda@Edge被触发后由Lambda@Edge所生成的内容;另一个源站通过为 Distribution 创建的第二个行为指向S3存储桶,缓存策略为缓存所有内容。
  • 为 CloudFront Distribution 创建一个 CloudFront Function函数,该函数会在默认 Behavior(行为)的View Request 阶段被触发,并根据 ”CloudFront-Viewer-Country” 标头来判断是否执行URL重定向;为CloudFront Distribution 创建一个 Lambda@Edge函数,该函数会在 默认 Behavior(行为)的Origin Request 阶段被触发,并根据 ”CloudFront-Viewer-Country” 标头来判断是否返回一个自定义页面或回源 EC2。

接下来,就让我们一起先来实践一下如何通过 AWS CDK 构建上述应用系统包括CloudFront CDN服务所需的资源模版,并完成最终的资源快速部署:

AWS CDK 运行环境准备:

使用 AWS CDK 进行资源模版的构建,您的系统环境需要具备一些先决条件,比如安装 CDK 运行所依赖的 Node.js 编译环境(10.13.0或以上版本),安装 Node包管理工具 npm,为当前系统环境配置所需的AWS Credentials (AKSK),安装您在使用 CDK 过程中所使用的具体编程语言环境(如本次实践我们会用到的 TypeScript),安装 AWS CDK Toolkit 工具软件,以及安装 CDK 应用开发过程中所使用的 IDE等,具体请参见 AWS CDK Prerequisites 完成 CDK 运行环境的预配置。除此之外,本次实践中我们还会用到git,请确保您的系统已经安装好git客户端程序。

示例代码及代码功能详解:

示例代码:

下载本次实践所会用到的示例代码,命令行输入以下命令:

wget https://gdm-share---s3---cn-north-1.amazonaws.com.rproxy.goskope.com.cn/cloudfrontcdk.zip
unzip cloudfrontcdk.zip
tree -a CloudfrontCdkDemo

示例代码目录结构如下:

CloudfrontCdkDemo
├── bin (CDK 应用入口程序)
│   └── cloufront_cdk_demo.ts
├── cdk.json (告诉 CDK toolkit 如何去运行 CDK 应用的配置文件)
├── flask-demo (Flask Web 应用程序文件)
│   ├── app
│   │   ├── app.py
│   │   ├── requirements.txt
│   │   ├── start.sh
│   │   └── templates
│   │       └── index.html
│   └── static
│       ├── AWS_logo_RGB.png
│       └── css
│           └── mystyle.css
├── functions (Lambda@Edge 代码和CloudFront Function 代码)
│   ├── cffunc
│   │   └── cffunc.js
│   └── lambda
│       └── index.js
├── .gitignore (告诉 Git 哪些文件不需要&需要添加到版本管理中的配置文件)
├── lib (CDK 应用所包含的应用资源模版和CloudFront资源模版)
│   ├── cloudfront_cdk-stack.ts
│   └── infra_cdk-stack.ts
├── .npmignore (告诉 npm 哪些文件不需要&需要添加到npm publish打包中的配置文件)
├── package.json (npm模块配置列表)
├── README.md
└── tsconfig.json (TypeScript 配置信息)

代码功能详解:

“bin/cloufront_cdk_demo.ts” 文件是 CDK 应用的程序主入口,在这段代码里定义了名为 ”app” 的 CDK 应用程序,同时还为这个CDK 应用定义其包含两个Stack资源模版,分别为 ”InfraCdkStack” 和 ” CloudfrontCdkStack”。关于何为 CDK App,何为 CDK Stack,以及 CDK 一些基本概念和组成,请参考 CDK 核心概念介绍。定义 App 及 Stack 的代码段如下所示:

……

const app = new cdk.App();
new InfraCdkStack(app, 'InfraCdkStack', {
  env: { account: process.env.CDK_DEFAULT_ACCOUNT, region: process.env.CDK_DEFAULT_REGION }
});

new CloudfrontCdkStack(app, 'CloudfrontCdkStack', {
  env: { account: process.env.CDK_DEFAULT_ACCOUNT, region: process.env.CDK_DEFAULT_REGION }
});

”lib/” 目录下存放该 CDK 应用所包含的两个 Stack 资源模版,其中 ”lib/infra_cdk-stack.ts” 为 Flask Web 应用程序运行所需的基础架构资源:包括运行 Flask Web 主程序的 EC2 实例,该程序在每次访问 EC2 时会产生一些动态内容,如 EC2 主机名,EC2 当前系统时间;还包括用于存储 Flask Web 应用所需静态文件(css,图片)的 S3 存储桶。当部署该 Stack 时,CDK 会通过 CDK 库中的 “aws-s3-deployment. BucketDeployment” construct 将放置在 ”flask-demo/” 目录下的 “static”目录和其中的静态文件部署至新创建好的指定的S3存储桶中,同时 CDK 还会通过 CDK 库中的 ”aws-s3-assets.Asset” construct将 ”flask-demo/app” 下的Flask Web应用程序和运行脚本打包为 .zip 文件上传至保存 CDK 部署所需 Assets 的S3 存储桶中,以便后续运行该应用程序的 EC2 实例下载使用。该部分示例代码如下:

……

/* Create a S3 bucket to hold flask static content */
    const staticBucket = new s3.Bucket(this, 'AssetsBucket', {
      removalPolicy: cdk.RemovalPolicy.DESTROY
    });
  
    new s3deploy.BucketDeployment(this, 'StaticAssets', {
      sources: [s3deploy.Source.asset('./flask-demo/static')],
      destinationBucket: staticBucket,
      destinationKeyPrefix: 'static'
    }); // Upload static content to S3 bucket for Flask website
    
    const appAssets = new assets.Asset(this, 'AppAssets', {
      path: './flask-demo/app'
}); // Upload Flask app files as a zip file to assets bucket for EC2 to download and run

……

在部署好所需的 S3 存储桶以及部署所需的 Assets 之后,CDK 会紧接着创建运行 Flask 应用程序所需的 EC2 实例,并通过EC2 user data 从 S3存储桶下载Flask应用程序和运行脚本,执行相应的命令运行Flask Web应用。该部分示例代码如下:

……

/* Create an EC2 to run flask app which generates the dynamic content */ 
    const vpc = ec2.Vpc.fromLookup(this, 'VPC', {isDefault: true,});
    
    const amznLinux = ec2.MachineImage.latestAmazonLinux({
      generation: ec2.AmazonLinuxGeneration.AMAZON_LINUX_2
    });
    
    const appInstance = new ec2.Instance(this, 'Instance',{
      vpc: vpc,
      instanceType: ec2.InstanceType.of(ec2.InstanceClass.T3, ec2.InstanceSize.MICRO),
      machineImage: amznLinux,
      keyName:'demo' // You need to modify the value of keyName with your own key-pairs name!
    });
    
    appAssets.grantRead(appInstance.role);
    appInstance.userData.addS3DownloadCommand({
      bucket: appAssets.bucket,
      bucketKey: appAssets.s3ObjectKey,
      localFile: '/tmp/app.zip'
    });
    appInstance.userData.addCommands('cd /tmp && unzip -o app.zip && chmod +x start.sh && ./start.sh && rm /var/lib/cloud/instance/sem/config_scripts_user');
    
    appInstance.connections.allowFromAnyIpv4(ec2.Port.tcp(22), 'Allow ssh from internet');
appInstance.connections.allowFromAnyIpv4(ec2.Port.tcp(80), 'Allow http from internet');

……

部署后用于存储静态文件的 S3 存储桶名 (bucketName) 以及运行应用程序的 EC2 公共域名 (instancePublicDnsName)我们存储在 AWS Systems Manager Parameter Store中,以方便后续部署的CloudfrontCdkStack 引用。需要说明一点的就是,绝大多数情况下,您的应用程序资源和您的CloudFront CDN资源可能往往是通过不同的CDK Stack或者不同的CDK App在不同的Region乃至不同的账号中部署,所以在这里我们没有使用常规的 CDK 跨不同 Stack 引用资源的能力(实质是利用 CloudFoamtion Stack export Fn::ImportValue,需要所有的 Stack 都部署在同一个的账号下的同一个region,同时通过定义不同Stack的属性和参数来跨 Stack 引用资源,该部分代码可参见此Repo),而是采用更加灵活的 SSM Parameter Store 方案,同时借助 CDK Custom Resources 功能模块,我们还可以实现跨 Region 乃至跨账号的参数引用(跨 Region 引用SSM 参数代码示例请参见 ”lib/infra_cdk-stack.ts” 中最后的代码注释部署)。本次实践该部分所用代码示例如下:

……

/* Store S3 bucket arn and EC2 public domain name in the SSM Parameter Store */ 
    new ssm.StringParameter(this, 'BuckerName', {
      description: 'S3 Bucket Name',
      parameterName: 's3BucketName',
      stringValue: staticBucket.bucketName
    });
    new ssm.StringParameter(this, 'EC2PublicDomainName', {
      description: 'EC2 Public Domain Name',
      parameterName: 'ec2PublicDnsName',
      stringValue: appInstance.instancePublicDnsName
});

……

“lib/cloudfront_cdk-stack.ts” 为部署 CloudFront 以及其绑定的 Lambda@Edge 和 CloudFront Functions的资源模版,通过读取 SSM Parameter Store 中的S3存储桶名以及EC2 公共域名,CloudFront可以获取源站信息,同时 CDK 为 CloudFront 创建访问 S3 存储桶所需的OAI (Origin Access Identity),以及其对应的S3 存储桶策略。该部分示例代码如下:

……
/* Import the existing S3 bucket as CloudFront's S3 origin*/
    const s3BucketName = ssm.StringParameter.fromStringParameterName(this, 'BuckerName', 's3BucketName').stringValue;

    const s3Origin = s3.Bucket.fromBucketName(this, 'ImportBucket', s3BucketName);
    
    const oai = new cloudfront.OriginAccessIdentity(this, 'OAI'); // Create an OAI for CloudFront to use
    
    const policyStatement = new iam.PolicyStatement({
      actions:    [ 's3:GetObject' ],
      resources:  [ s3Origin.arnForObjects("*") ],
      principals: [ oai.grantPrincipal ],
    }); // S3 bucket policy for granting OAI to access S3 objects 
    
    new s3.BucketPolicy(this, 'cloudfrontAccessBucketPolicy', {
      bucket: s3Origin,
    }).document.addStatements(policyStatement); // Add OAI granted bucket policy to S3 (It will override the existing bucket policy if had) 
    
    const httpOrigin = ssm.StringParameter.fromStringParameterName(this, 'EC2PublicDomainName', 'ec2PublicDnsName').stringValue; // Import EC2 public Domain Name as CloudFront's Http Origin
    
……

在 “lib/cloudfront_cdk-stack.ts” 示例代码中的 ”Example 1” 中,我们通过CDK 库中的 ”aws-cloudfront. Distribution” construct, ”aws-cloudfront-origins.HttpOrigin” construct, 以及 ”aws-cloudfront-origins.S3Origin” construct 为 CloudFront 分别绑定不同的源站并为其设定默认 Behavior(行为)和新增Behavior(行为)所需参数,比如 ”viewProtocolPolicy” 以及 ”cachePolicy” 等。该部分示例代码如下:

……

/* Example 1: Create a stand CF distribution with 2 behaviors which use above EC2 and S3 as origins */
    const distribution = new cloudfront.Distribution(this, 'myDist', {
      defaultBehavior: {
        origin: new origins.HttpOrigin(httpOrigin,{
          protocolPolicy:cloudfront.OriginProtocolPolicy.HTTP_ONLY
        }),
        viewerProtocolPolicy: cloudfront.ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
        cachePolicy: cloudfront.CachePolicy.CACHING_DISABLED
      },
      additionalBehaviors: {
        '/static/*': {
          origin: new origins.S3Origin(s3Origin, {originAccessIdentity: oai}),
          viewerProtocolPolicy: cloudfront.ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
          cachePolicy: cloudfront.CachePolicy.CACHING_OPTIMIZED
        }
      }
});

……

在示例代码的 “Example 2” 中,我们在 “Example 1” 的基础上首先为 CloudFront 绑定的 Lambda@Edge设定新的缓存策略源请求策略,新的缓存策略设定:DefaultTTL 0 (秒),MinTTL 0 (秒),MaxTTL 3600 (秒),同时配合 Lambda@Edge 代码段中对于 ”Cache-Control” 标头的添加值 ”max-age=3600”,我们可以实现,当触发 Lambda@Edge时,如果直接回源 EC2 则返回内容不缓存,如果返回的是 Lambda@Edge 生成页面内容则缓存 3600秒; 为了使得 Lambda@Edge 能够根据 ”CloudFront-Viewer-Country” 标头做判断,我们还需要为 CloudFront 的源请求策略设置 ”CloudFront-Viewer-Country” 的白名单。设置好新的缓存策略和源请求策略后,我们通过特定的 ”aws-cloudfront/lib/experimental.EdgeFunction” construct为 CloudFront 部署所需的 Lambda@Edge,Lambda@Edge代码请参见 “functions/lambda/index.js”。使用 “EdgeFunction” 相比使用常规的 ”aws-lambda.Function” 的好处在于,您无需在定义 Stack 时指定 Lambda 所属的 Stack 的部署区域为 “us-east-1”(注:Lambda@Edge 的部署必须在 us-east-1),“EdgeFunction” 会帮助您自动将 Lambda 函数部署在 “us-east-1”,并为其添加成为Lambda@Edge所需的必要属性。此外示例代码 ”Example 2” 中,我们也提供了如何通过常规的 ”aws-lambda.Function” 部署 Lambda@Edge,请参考代码中的注释部分(注:使用常规 ”aws-lambda.Function” 部署,需要设定运行该部分代码的 Stack 参数 “.env.region” 为 “us-east-1”)。部署好的新的缓存策略,源请求策略,以及Lambda@Edge,我们可以通过 CDK Escape Hatches来实现对于 construct 底层资源的配置覆盖,从而为关联 EC2源站的默认Behavior(行为)添加新的配置参数及Lambda@Edge 函数。该部分示例代码如下:

……

/* Example 2: Basing on Example 1, create a L@E function and a CF distribution, then associate the L@E function to a specific behavior of CF distribution */
    const customCachePolicy = new cloudfront.CachePolicy(this, 'customCachePolicy', {
      cachePolicyName: 'customCachePolicy-Lambda',
      comment: 'Lambda will modify the TTL via "cache-control" header',
      defaultTtl: cdk.Duration.seconds(0), 
      minTtl: cdk.Duration.seconds(0),
      maxTtl:cdk.Duration.seconds(3600),
      enableAcceptEncodingBrotli: true,
      enableAcceptEncodingGzip: true,
      headerBehavior: cloudfront.CacheHeaderBehavior.allowList('CloudFront-Viewer-Country')
    }); // Create a custom cache policy reserved for L@E
    
    const customOriginRequestPolicy = new cloudfront.OriginRequestPolicy(this, 'customOriginRequestPolicy', {
      originRequestPolicyName: 'customOriginRequestPolicy-Lambda',
      comment: 'Pass the "CloudFront-Viewer-Country" header to origin',
      headerBehavior: cloudfront.OriginRequestHeaderBehavior.allowList('CloudFront-Viewer-Country')
}); // Create a custom origin request policy reserved for L@E

const lambdaFunc = new EdgeFunction(this, 'LambdaFunction', {
      runtime: lambda.Runtime.NODEJS_14_X,
      handler: 'index.handler',
      code: lambda.Code.fromAsset('./functions/lambda'),
      stackId: 'LambdaEdgeStack'
    });
    
    const dist = distribution.node.defaultChild as cloudfront.CfnDistribution;
    dist.addPropertyOverride('DistributionConfig.DefaultCacheBehavior.CachePolicyId', customCachePolicy.cachePolicyId);
    dist.addPropertyOverride('DistributionConfig.DefaultCacheBehavior.OriginRequestPolicyId', customOriginRequestPolicy.originRequestPolicyId);
    dist.addPropertyOverride('DistributionConfig.DefaultCacheBehavior.LambdaFunctionAssociations', [
      {
        EventType: cloudfront.LambdaEdgeEventType.ORIGIN_REQUEST,
        LambdaFunctionARN: lambdaFunc.currentVersion.edgeArn
      }
    ]);

……

在示例代码 “Example 3” 中,我们在“Example 2” 的基础上通过 ”aws-cloudfront.Function” construct 为 CloudFront创建新的 CloudFront Functions,CloudFront Functions代码请参见 “functions/cffunc/cffunc.js”, 并通过 “CDK Escape Hatches” 来实现为关联 EC2源站的默认Behavior(行为)添加新的CloudFront Functions 函数。该部分示例代码如下:

……

/* Example 3: Basing on Example 2, create a CF function and associate it to a specific behavior of CF distribution */
    const cfFunc = new cloudfront.Function(this, 'CFFunction', {
      code: cloudfront.FunctionCode.fromFile({filePath: './functions/cffunc/cffunc.js'})
    })
    
    dist.addPropertyOverride('DistributionConfig.DefaultCacheBehavior.FunctionAssociations', [
      {
        EventType: cloudfront.FunctionEventType.VIEWER_REQUEST,
        FunctionARN: cfFunc.functionArn
      }
]);

……

运行 CDK 代码,部署Flask应用及 CloudFront 服务

部署前准备

修改 ”lib/infra_cdk-stack.ts” 代码中创建 EC2 Instance 部分,将 keyName 对应的值 ‘demo’ 更换为您当前自己的密钥名:

const appInstance = new ec2.Instance(this, 'Instance',{
      vpc: vpc,
      instanceType: ec2.InstanceType.of(ec2.InstanceClass.T3, ec2.InstanceSize.MICRO),
      machineImage: amznLinux,
      keyName:'demo' // You need to modify the value of keyName with your own key-pairs name!
    });

再次确认您当前的系统环境已经满足AWS CDK Prerequisites基本要求,命令行输入以下命令:

cd CloudfrontCdkDemo   # 切换至解压后的示例代码根目录
npm install -g aws-cdk   # 安装 AWS CDK Toolkit,如在 CDK 运行环境准备阶段已经安装过则无需再次安装
npm install  # 按照示例代码中的 ”package.json” 文件列表,安装 CDK 程序运行所需的各种依赖包
npm run build  # Build CDK 应用,将 TypeScript CDK 代码编译为 Node.js 可以直接运行的 JavaScript 代码(可选步骤,CDK 部署应用时会自动执行该命令)
cdk bootstrap  # CDK 部署引导程序,生成CDK部署过程用于存储 Assets 的 S3 存储桶

部署 Flask Web应用程序及资源

命令行输入以下命令(注:在输入该命令前您还可以通过 cdk synth InfraCdkStack命令来预先查看 CDK 所最终生成的用于部署的 CloudFormation 模版):

cdk deploy InfraCdkStack

在弹出的提示中输入 ”y”,继续部署,可以看到 InfraCdkStack 已经开始部署,同时我们在 “AWS CloudFormation” 控制台中也可以看到正在部署的InfraCdkStack,大约几分钟后,所有的应用资源部署即可完成,如下图所示:

在 AWS CloudFormation 控制台选择部署好的 ”InfraStack” 的 ”OutPuts”页签,点击打开其中的 ”BucketConsole” 页面,可以看到 CDK 已经通过 “InfraStack” 为我们创建了一个新的S3存储桶,并将 Flask Web 应用所需的静态文件存放至该存储桶中;在 “OutPuts” 页签,点击打开 ”InstancePublicDNSName” 页面,可以看到新创建好的 EC2 实例已经可以正常运行 Flask Web 应用程序,如下图所示:

为 Flask Web 应用部署 CloudFront 服务

命令行输入以下命令:

cdk deploy CloudfrontCdkStack

命令行弹出提示中输入 ”y”,选择继续部署,等待几分钟,我们就可以通过 CDK 完成应用所需的 CloudFront 服务部署。

在 AWS CloudFormation 控制台选择部署好的 ”CloudfrontCdkStack” 的 ”OutPuts”页签,点击 “CFDistributionDNSName” 页面(即CloudFront为应用交付所提供的默认域名),如果您当前是从中国访问该域名,则您的访问链接会被 CloudFront 所关联的 CloudFront Functions 重定向至亚马逊云科技的中文网站;如果您是从日本访问该域名,则您的访问会由 CloudFront 触发的 Lambda@Edge 返回一个自定义Html页面;如果您是从其他国家访问该域名,则返回正常的Flask Web 应用页面,如下图所示:

Access from ‘CN’


Access from ‘JP’

Access from countries rather than ‘CN’ and ‘JP’

在 AWS CloudFormation 控制台选择部署好的 ”CloudfrontCdkStack” 的 ”OutPuts”页签,点击 “CFDistributionConsole” 页面,进入CloudFront 对应该Distribution(分发)的配置界面,选择 ”Behaviors (行为) ” 页签中的Default Behavior,并选择 ”Edit”,可以看到已经为该行为成功关联了由 CDK 所定义的缓存策略和源请求策略,并绑定了对应的 CloudFront Functions 以及 Lambda@Edge 函数,如下图所示:


至此,我们就完成了 第一个阶段的所有任务:通过 CDK 快速部署了一套 Flask Web 应用程序以及其所需的 CloudFront CDN服务,接下来让我们进入到下一个阶段,通过 CDK Pipeline 实现基于 CloudFront 的持续发布。

实践二: 通过 CDK Pipeline 持续发布 CloudFront CDN 服务

CDK Pipeline 是 AWS CDK 库文件中的一个重要组成模块,您可以通过 CDK Pipeline 集成 AWS CodeCommitAWS CodeBuildAWS CodePipeline 等众多开发类工具服务去持续交付您的 CDK 应用,关于 CDK Pipeline 的详细介绍您可以参考这里

接下来,我们就基于第一个实践内容来实现使用 CDK Pipeline 持续发布 CloudFront CDN 服务(本阶段实践内容不涉及对InfraCdkStack 所包含的 EC2、S3 等资源模版实现持续发布,仅针对 CloudfrontCdkStack 所包含的 CloudFront 资源模版;事实上在绝大多数生产环境中,应用所包含的基础架构资源和为其提供服务的CDN资源往往是通过不同的资源模版封装成不同的 CDK App 进行交付,从而实现二者的解耦带来更大的部署灵活性,这也符合 CDK 构建 APP 和 Stack 的时的最佳实践)。

本次实践环节通过 CDK Pipeline 构建的 CloudFront 持续交付流水线整体架构如下图所示:

架构概述:

  • 编写好的CloudFront (包含 Lambda@Edge 以及 CloudFront Function ) CDK应用代码以及 CDK Pipeline 代码保存在 AWS CodeCommit 代码仓库里。
  • 通过 CDK Pipeline 代码生成 AWS CodePipeline 流水线并集成 AWS CodeCommit(源码保存),AWS CodeBuild(代码构建)等服务。
  • AWS CodeBuild 通过CDK代码构建好相应的 CloudFormation 模版后,调用 CloudFormation 引擎部署至不同的用户环境,如准生产 (Staging) 环境,生产 (Prod) 环境。
  • 在准生产环境和生产环境间引入必要的测试环节和人工批复环节。

CDK Pipeline 环境准备

注:接下来的实践环节我们会一步步创建用于持续发布的Pipeline,并编写相应代码,建议后续实践环节通过 IDE 完成,如 AWS Cloud9 或者VSCode等。

首先,我们需要删除之前通过 ”cdk deploy” 命令手动部署的CloudFront服务资源,命令行执行以下命令:

cdk destroy CloudfrontCdkStack

注:如果您当前是在 ”us-east-1” Region 部署,删除CloudfrontCdkStack时,系统自动帮您删除 LambdaEdgeStack (Lambda@Edge资源),但会提示无法删除Lambda@Edge,请在对应的 CloudFormation 控制台中选择删除 Stack 时保留 Lambda@Edge,等成功删除 CloudfrontCdkStack 后等一段时间即可 手动删除 Lambda@Edge;如果您当前是在其他 ”Region” 部署,则删除CloudFrontCdkStack后,还需要手动通过命令 ”cdk destroy LambdaEdgeStack” 删除Lambda@Edge资源,同时也会出现无法立即删除 Lambda@Edge 的报错信息,解决方案如前所述。

删除完相应资源后,我们还需要再运行一遍新版的 Bootstrap 引导程序,从而为 CDK Pipeline 的正常运行添加更多的部署依赖环境,详见 CDK Pipeline Bootstrap 说明。命令行输入以下命令:

export CDK_NEW_BOOTSTRAP=1 
cdk bootstrap

成功运行完 Bootstrap 引导程序后,我们需要修改 CDK 应用代码中的 ”CloudfrontCdkDemo/cdk.json” 文件,在 “context” 内容项中添加如下标注,并保存:

{
    ……
    "context": {
      ……
      "@aws-cdk/core:newStyleStackSynthesis": true
    }
}

修改完后您的 ”cdk.json” 应该类似于下图:

至此,我们完成了使用 CDK Pipeline 前期的准备工作。

构建您的初始 Pipeline

  • npm 安装 CDK Pipeline 所需用到的功能模块

命令行执行以下安装命令:

npm install @aws-cdk/aws-codecommit @aws-cdk/pipelines
  • 创建 ”pipeline-stack.ts” 文件

在 ”lib/” 目录下创建名为 ”pipeline-stack.ts” 文件,并添加用于创建 “CodeCommit” 代码仓库的 CDK 代码,命令行执行以下命令:

cat << EOF > ./lib/pipeline-stack.ts
import * as cdk from '@aws-cdk/core';
import * as codecommit from '@aws-cdk/aws-codecommit';

export class PipelineStack extends cdk.Stack {
  constructor(scope: cdk.Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props);
    
    const repo = new codecommit.Repository(this, 'CloudFrontCDKRepo', {
      repositoryName: "CloudFrontCDKRepo"
    });

  }
}
EOF

IDE 打开创建好的 ”pipeline-stack.ts” 文件,您的 ”pipeline-stack.ts” 文件代码应该类似于下图:

  • 修改 ”bin/cloufront_cdk_demo.ts”代码

IDE 打开 ”bin/cloufront_cdk_demo.ts”,在 ”import” 代码段添加如下代码:

……

import { PipelineStack } from '../lib/pipeline-stack';

……

为 CDK App添加一个名为 “PipelineStack” 的Stack资源定义,代码如下:

……

new PipelineStack(app, 'PipelineStack', {
  env: { account: process.env.CDK_DEFAULT_ACCOUNT, region: process.env.CDK_DEFAULT_REGION }
});

修改后的 “cloufront_cdk_demo.ts”文件代码应该类似于下图:

  • CDK 创建 CodeCommit Repo

命令行输入:

cdk deploy PipelineStack

待执行完毕后切换至 AWS CodeCommit 图形控制台,可以看到我们已经通过 CDK 成功创建起一个名为 “CloudFrontCDKRepo”的代码仓库,如下图所示:

  • 连接 CodeCommit,并推送代码,部署Pipeline

要连接 AWS CodeCommit,您需要为您当前使用的 AWS 账号用户生成对应的HTTPS Git 访问凭据, 在生成该访问凭据前请先确保您当前的用户账号具备使用 CodeCommit 权限,关于如何使用 CodeCommit Git 凭据并通过 HTTPS 访问 AWS CodeCommit的具体配置请参见该文档

创建好 CodeCommit 访问凭据,并为当前客户端配置好连接后,命令行输入以下命令:

git init
git remote add origin https://git-codecommit.<Region>.amazonaws.com/v1/repos/CloudFrontCDKRepo # 请将 <Region> 替换为您当前 CodeCommit Repo 所在 Region
git add -A
git commit -m "initial commit"
git push --set-upstream origin master

成功推送后,您的 CodeCommit 中的文件应该类似于下图:

  • 为 ”pipeline-stack.ts” 文件添加 “Pipeline” 代码

IDE 打开 ”lib/pipeline-stack.ts” 文件,在 ”import” 代码段添加如下代码:

……

import { CodePipeline, CodePipelineSource, ShellStep } from '@aws-cdk/pipelines';

……

在创建 “CodeCommit Repo” 的代码段后添加如下代码:

……

const pipeline = new CodePipeline(this, 'Pipeline', {
  pipelineName: 'CloudfrontPipeline',
  synth: new ShellStep('Synth', {
    input: CodePipelineSource.codeCommit(repo, 'master'),
    commands: ['export NPM_CONFIG_UNSAFE_PERM=true', 'npm ci', 'npm run build', 'npx cdk synth ']
  })
});

……

修改后的完整代码应该类似于下图所示:

命令行输入以下命令,提交更改后的代码,并创建 Pipeline:

git add -A
git commit -m "add pipeline"
git push
cdk deploy PipelineStack

待部署完毕后,切换至 AWS CodePipeline 控制台,可以看到,我们已经通过 CDK Pipeline 成功部署好对应的 AWS CodePipeline,同时将 CodeCommit 以及 CodeBuild 也与 CodePipeline做好了集成,如下图所示:


至此,您无需再通过执行 “cdk deploy PipelineStack” 去更新您后续的 Pipeline配置,所有对于Pipeline的变更配置都可以通过简单的 git 提交命令触发 Pipeline 进行自我更新

为 Pipeline 添加部署环节

CDK Pipeline 部署 CDK 应用可以通过为 Pipeline 定义 Stage 的子类实现,并利用 addStage() 将部署环节添加在 Pipeline 中。每一个 Stage 可以包含一个或多个要部署的 Stack 应用,如果多个 Stack 间有部署的先后关系,还可以自行定义不同 Stack 的依赖关系,如: stack1.addDependency(stack2);如果多个 Stack 间没有依赖关系,则会同时部署。同时,我们还可以为同一条 Pipeline 针对相同的 Stack 添加多个 Stage,以部署至不同的环境中,比如:测试环境,准生产环境,生产环境等,且每一个部署环境我们都可以单独定义或者使用默认的AWS Account 和 Region,详细示例请参考后文 —— “修改”lib/pipeline-stack.ts s” 代码”小节中的样例代码。

本次实践内容中,我们将会在 Pipeline 的部署 (Stage) 环节中包含名为 “CloudfrontCdkStack” 的 Stack (仅对CloudFront 服务完成自动部署);同时,我们还将在 Pipeline 中添加两个部署(Stage) 环节,用来模拟分别部署到准生产环境和生产环境。在部署至准生产环境和生产环境的过程中,我们还会在 Pipeline 插入上线前测试环节以及人工批复环节,从而模拟一个完整的服务发布流程。

添加 ”lib/pipeline-stage.ts” 代码文件:

命令行执行以下命令:

cat << EOF > ./lib/pipeline-stage.ts
import * as cdk from '@aws-cdk/core';
import { CloudfrontCdkStack } from './cloudfront_cdk-stack';

export interface PipelineStageProps extends cdk.StageProps {
  stage: string
}; //Define a parameter(prop) to pass to CloudfrontCdkStack

export class PipelineStage extends cdk.Stage {
    public readonly serviceUrl: cdk.CfnOutput;
    
    constructor(scope: cdk.Construct, id: string, props: PipelineStageProps) {
      super(scope, id, props);
  
      const CFStack = new CloudfrontCdkStack(this, 'CFStack',{
        environment: props.stage
      });
      
      this.serviceUrl = CFStack.cfUrl;
    }
}
EOF

在这段代码中,我们定义 “PipelineStage” 用来部署 “CloudfrontCdkStack”;同时还为其定义了一个接收参数 “stage: string”,该参数将会在部署时接收来自 PipelineStack 中所定义的部署环境参数 (如 Staging,Prod),并将该部署环境参数传递给其所部署的 “CloudfrontCdkStack” 的 “environment” 参数;除此之外,我们还为其定义了一个只读的接口参数,该参数的值来自于其所部署的 “CloudfrontCdkStack” 的接口参数 “cfUrl”。

修改”lib/cloudfront_cdk-stack.ts” 代码

IDE 打开代码,为其添加定义“environment” 参数代码,该参数将会接收来自 ““PipelineStage”” 中所传递过来的部署环境参数,同时也需要修改 ”constructor” 中对应的参数声明代码;添加只读接口参数cfUrl,该参数将会对外暴露创建好的 CloudFront DNS 域名,为 Pipeline 测试环节中验证 CloudFront对外服务是否正常所用。

……

export interface CloufrontCdkStackProps extends cdk.StackProps {
  environment?: string
}; //Define a parameter(prop) of CloufrontCdkStack for customCachePolicy and customOriginRequestPolicy

export class CloudfrontCdkStack extends cdk.Stack {
  public readonly cfUrl: cdk.CfnOutput;

constructor(scope: cdk.Construct, id: string, props: CloufrontCdkStackProps) {
    super(scope, id, props);

……

修改代码中定义 “cachePolicyName” 以及 “originRequestPolicyName” 的代码,将原有的这两行代码(如下):

……

cachePolicyName: 'customCachePolicy-Lambda',
……

originRequestPolicyName: 'customOriginRequestPolicy-Lambda',

……

替换为如下代码:

……

cachePolicyName: 'customCachePolicy-Lambda-' + props.environment,
……

originRequestPolicyName: 'customOriginRequestPolicy-Lambda-' + props.environment,

……

为接口参数 “cfUrl” 声明赋值,将代码段中最后一部分通过 ”CfnOutput” 暴露 CloudFront 对外域名的代码如下:

……

new cdk.CfnOutput(this, 'CFDistributionDNSName', {
      value: distribution.domainName,
      description: 'The CloudFront distribution for flask app'
});

……

替换为如下代码:

……

this.cfUrl = new cdk.CfnOutput(this, 'CFDistributionDNSName', {
      value: distribution.domainName,
      description: 'The CloudFront distribution for flask app'
});

……

全部修改好后的代码文件请参考这里

修改”lib/pipeline-stack.ts s” 代码

导入部署阶段所需新类,将下述代码

import { CodePipeline, CodePipelineSource, ShellStep } from '@aws-cdk/pipelines';

修改为以下代码:

import { CodePipeline, CodePipelineSource, ShellStep, ManualApprovalStep } from '@aws-cdk/pipelines';

并增加以下导入

import { PipelineStage } from './pipeline-stage';

定义 ”Pipeline” 的代码段下添加如下代码:

const staging = new PipelineStage(this, 'Staging', {
  env: { account: process.env.CDK_DEFAULT_ACCOUNT, region: 'us-east-1' },
  stage: 'Staging' // Parameter to pass to PipelineStage then for CloudfrontCdkStack to define policies
});
const deployStaging = pipeline.addStage(staging);

deployStaging.addPost(new ShellStep('Validate',{
  envFromCfnOutputs: {appUrl:staging.serviceUrl},
  commands: ['curl -Ssf https://$appUrl']
}))

const prod = new PipelineStage(this, 'Prod', {
  env: { account: process.env.CDK_DEFAULT_ACCOUNT, region: 'us-east-1' },
  stage: 'Prod' // Parameter to pass to PipelineStage then for CloudfrontCdkStack to define policies
});
const deployProd = pipeline.addStage(prod);

deployProd.addPre(new ManualApprovalStep('ReviewApproval'));

在上述代码中,我们导入了 Pipeline 会用到的 “ManualApprovalStep” (人工批复功能模块)以及 “PipelineStage” (Stage 功能模块);并为 Pipeline 添加了两个部署阶段,分别为 “Staging” 和 “Prod”;同时在 “Staging” 阶段完成后我们还增加了通过 ”curl” 新生成的 CloudFront 公共域名的方式去验证服务是否部署正常(CloudFront 公共域名通过 CloudfrontCdkStack. cfUrl à PipelineStage.serviceUrl 一层层传递过来),除了使用简单的 ”curl” 方式外,我们还可以增加更为复杂的测试脚本,详见 “Create CDK Pipeline” 中的 “Testing deployment” 部分;除此之外,我们还在向生产环境正式部署前通过 “ManualApprovalStep” 添加了人工批复环节。

全部修改好后的代码文件请参考这里

至此,我们就完成了所有的 Pipeline 代码编写部分 (该部分完整的代码样例请参考这里),请在终端里命令行执行以下命令发布CloudFront 服务:

git add -A

git commit -m "add deployment stage"

git push

待部署完毕后,切换至 AWS CodePipeline 控制台,可以看到新的Pipeline中已经包含了我们新增的准生产环境和生产环境的部署,同时也添加了对应的测试和人工批复环节,两个不同环境的 CloudFront 服务都能够正常部署提供服务。后续对于 CloudFront 服务所有的配置修改后的发布以及其所包含的Lambda@Edge 和 CloudFront Functions的代码修改后的发布,我们都可以采用代码的方式提交至 CodeCommit 中触发 Pipeline实现服务的持续发布。

总结:

Amazon CloudFront 是亚马逊云科技所推出的一项久经考验的 CDN 服务,相较于大家所熟悉的通过界面或命令行方式配置并发布传统 CDN 服务的方式,在这篇博客里我们重点为大家介绍了如果通过 AWS CDK 以更为高效的 IaC 方式快速部署并持续交付您的 CDN 服务,并通过两个具体的实践为大家做了深入阐述。“老骥伏枥,历久弥新”,也希望这篇博客能给大家一些新的启发,换一种方式以全新的思路去重构您的云服务体验。

注:考虑到某些 CDN 使用者可能会购买第三方域名服务商所提供的域名以及使用第三方域名解析服务,所以这篇博客的实践内容并没有涉及到如何为 CloudFront 绑定用户自定义域名以及绑定证书等内容。关于如何利用 CDK CloudFront 设定自定义域名,绑定 DNS 解析服务以及证书等代码,请参考这里

附录: 参考资料

AWS Cloud Development Kit (CDK) 用户手册: https://docs.aws.amazon.com/cdk/latest/guide/home.html

AWS CDK Advanced Workshop: https://cdk-advanced.workshop.aws/

AWS CDK API 参考手册: https://docs.aws.amazon.com/cdk/api/latest/

AWS CDK Examples: https://github.com/aws-samples/aws-cdk-examples

AWS CDK Repo: https://github.com/aws/aws-cdk

本篇作者

郭道明

AWS合作伙伴资深架构师,致力于合作伙伴技术生态构建和业务发展,喜欢尝试一切与众不同的新鲜事物。