亚马逊AWS官方博客

基于 AWS Serverless 快速构建 PDF 缩略图生成服务

Serverless的核心价值是“零运维成本” + “零资源浪费”,使能开发者在聚焦业务实现的同时无需过多关注运维与基础设施,并能按需使用和付费。本博客会结合金蝶发票云的真实业务场景,分享从需求设计到开发上线的全过程中,如何在2周内利用AWS serverless来快速构建并上线PDF缩略图生成服务。具体在博客中,我们会先介绍这个服务在AWS上部署的整体架构,接着我们会介绍在实际Serverless开发过程中经常遇到的Lambda冷启动问题是如何进行性能优化的,还有如何进行自动化部署以提高开发效率,最后我们会实际分析一下这个服务在AWS上的实际运行成本。

1. 整体架构设计与实现

金蝶发票云是金蝶子公司,为金蝶ERP提供完整的发票生命周期管理,其中PDF缩略图生成是发票开具业务的一个基础功能模块,用于将用户上传的PDF格式的电子发票转成缩略图并在前端展示。在PDF缩略图生成上,我们使用了开源的Apache PDFBox来进行实现,相关的处理逻辑会在Lambda中用Java语言进行实现。在服务调用上,我们分别实现了异步和同步两种调用模式,以应对两种不同的业务场景。接下来我们先简要介绍下这两种调用模式下各自的架构设计:

1.1 异步调用模式

在这个场景下面,用户会批量上传PDF格式的电子发票,之后根据需要选择相应的缩略图在前端展示。如下是整体的架构图:

 

 

可以看到,我们选择S3来进行电子发票的存储。前端服务(即架构图中的EC2实例)将发票上传至S3,然后利用S3 Event Notification功能,在每个S3对象上传时都触发Lambda来进行处理。Lambda将PDF生成缩略图后,会将图片文件存储至S3,其他服务通过S3即可访问生成好的缩略图。

如下Java示例代码片段展示了如何在Lambda中获得S3上传对象的路径,并使用开源的Apache Pdfbox  java库实现PDF文件的缩略图生成。

public class Handler implements RequestHandler<S3Event, String> 
    private static final Region region = Region.CN_NORTHWEST_1;
    private static final String IMG_TYPE = "png";
    
    public String handleRequest(S3Event s3Event, Context context) {
        S3Client s3 = S3Client.builder().region(region)..credentialsProvider(EnvironmentVariableCredentialsProvider.create()).httpClientBuilder(ApacheHttpClient.builder().maxConnections(1000)).build();
        //读取事件通知中的记录信息列表,获得批量上传的所有pdf文件对应的bucketname和key
        List<S3EventNotification.S3EventNotificationRecord> records = s3Event.getRecords();
        for (S3EventNotification.S3EventNotificationRecord record : records) {
            String bucketName = record.getS3().getBucket().getName();
            String key = record.getS3().getObject().getUrlDecodedKey();
            GetObjectRequest getObjectRequest = GetObjectRequest.builder()
                    .bucket(bucketName)
                    .key(key)
                    .build();
            //在本地文件生成保存的pdf文件的临时目录
            File pdfTmpPath = Pdf2Image.getFileTmpPath(key);
            //从S3下载刚上传的PDF文件到本地缓存
            s3.getObject(getObjectRequest, pdfTmpPath.toPath());
            //使用pdfbox进行缩略图转换
            File[] images = Pdf2Image.pdf2image(pdfTmpPath.getAbsolutePath(), IMG_TYPE);
            for (File srcImage : images) {
                uploadImage(s3, key, srcImage);
            }
        }
        return "success";
    }
```

1.2 同步调用模式

在这个场景下面,前端服务提交PDF发票后需要实时获取缩略图进行展示。如下是整体的架构图:

和异步模式相比,我们增加了API Gateway来为前端服务(即架构图中的EC2实例)提供一个RESTful API。前端服务通过这个API直接将PDF文件以二进制文件流的方式提交至Lambda。Lambda获取后将这个文件保存至本地后再进行缩略图生成,再将图片保存至S3,并返回S3的地址给到前端服务。

如下Java示例代码片段展示了在Lambda中处理API Gateway传进来的文件流并生成缩略图:

public class RestAPIHandlerSync implements RequestHandler<APIGatewayProxyRequestEvent, APIGatewayProxyResponseEvent> {
    private static final String ROOT_DIRECTORY = "SYNC/";
    private static final Region region = Region.CN_NORTHWEST_1;
    @Override
    public APIGatewayProxyResponseEvent handleRequest(APIGatewayProxyRequestEvent apiGatewayProxyRequestEvent, Context context) {
        s3 = S3Client.builder().region(region).credentialsProvider(EnvironmentVariableCredentialsProvider.create())
          .httpClientBuilder(ApacheHttpClient.builder().maxConnections(1000)).build();
        APIGatewayProxyResponseEvent response = new APIGatewayProxyResponseEvent();
        InvoicePDFUploadResp resp = new InvoicePDFUploadResp();
        //获取pdf file stream
        String fileStream = apiGatewayProxyRequestEvent.getBody();
        byte[] decodeBase64 = Base64.decodeBase64(fileStream);
        //获取相关的APIGW的相关query parameter,用于标识pdf文件
        Map<String, String> params = apiGatewayProxyRequestEvent.getQueryStringParameters();
        String taxNo = params.getOrDefault("taxNo", "");
        String fileName = params.getOrDefault("fileName", "");
        //将pdf文件流转化为pdf文件,保存在本地,本地目录:SYNC/发票编号/xxx.pdf
        String pdfKey = ROOT_DIRECTORY + Utils.generateKey(taxNo, fileName, true);
        File pdfTmp = Utils.getFileTmpPath(pdfKey);
        FileUtils.writeByteArrayToFile(pdfTmp, decodeBase64);
        //将pdf转化为图片
        File[] images = PDF2Image.pdf2image(pdfTmp.getAbsolutePath(), Constants.IMG_TYPE);
        //上传转化后的缩略图至S3
        List<String> snapshotUrls = new ArrayList<>();
        for (File img : images) {
            String imgKey = pdfKey.substring(0, pdfKey.lastIndexOf('/') + 1) + img.getName();
            Utils.uploadFile(s3, Utils.getProperty(Constants.INVOICE_PDF_SNAPSHOT_STORAGE_BUCKET_KEY), imgKey, img);
            snapshotUrls.add(String.format(Utils.getProperty(Constants.DOWNLOAD_URL_KEY),
                    Utils.getProperty(Constants.INVOICE_PDF_SNAPSHOT_STORAGE_BUCKET_KEY), imgKey));
        }
        //返回缩略图的S3地址
        resp.setSnapshotPreUrls(snapshotUrls);
        return response.withBody(JSON.toJSONString(resp));
    }
}

由于这个API只在AWS VPC内进行访问,因而我们构建了私有REST API。具体过程如下:

首先为API Gateway `execute-api` 创建VPC 终端节点,并放通EC2实例所在的子网

接着我们需要在API Gateway控制台上创建私有API,并设置资源策略。这两步的配置我们会通过SAM来进行自动化部署,详细过程可参考本博客相关章节。

1.3 解决PDFBox中文字体库在Lambda的支持问题

Apache PDFBox需要系统安装中文字体库才能正确转换中文PDF,要不然转换后的图片会出现乱码。在Lambda运行环境中并没有预装中文字体库,因此我们需要额外安装中文字体库。在虚拟机上我们可以比较直接地解决这个问题,即将对应的字体文件放置在系统的字体文件夹下即可。但这种方案在基于无服务器架构的Lambda上并不可行,因为我们无法访问到Lambda底层运行的环境。

经过研究我们看到有两种方案:

  • 在EFS文件系统上安装中文字体库并挂载至Lambda中
  • 将中文字体库打包在Lambda Layer中并加载至Lambda中

这两种方案都可以将中文字体库加载至Lambda中。从便利性考虑我们选择了Lambda Layer这个方案。字体库会通过Lambda Layer部署到 /opt 目录下,然后我们在代码中对PDFBox进行配置,通过org.apache.fontbox.util.autodetect.UnixFontDirFinder.getSearchableDirectories方法里定义多个字体库路径即可。

我们通过SAM来进行Lambda Layer的配置,具体过程请参见本博客相关章节。

2. Lambda 冷启动性能优化

在最开始功能开发完成后,我们在实际测试时发现通常的Lambda调用返回时间在2~3s,这对于业务来说是可以接受的。但偶尔响应时间会达到32s,这对于业务来说是比较差的体验。这是一个典型的Lambda冷启动的性能问题。

因此我们先利用了AWS X-Ray服务对Lambda性能进行深入的分析。AWS X-Ray 可以帮助开发人员分析与调试分布式生产应用程序,例如使用微服务架构构建的应用程序。我们可以利用X-Ray来追踪Lambda函数,以便了解更多关于Lambda执行时间的细节。

首先我们需要在Lambda中启用X-Ray功能:

启用后我们在X-Ray控制台上就可以获取到具体的Lambda运行时间了。在 X-Ray 上我们可以看到Lambda执行时间的细节信息,比如从下图的示例中,我们可以看到Lambda的运行时间会包括Initialization, Invocation 和 Overhead 三部分,我们主要关注 Initalization 和 Invocation 这两部分。

Initialization是Lambda运行时启动的时间,包括启动JVM、加载并初始化Handler类。 Invoke则是Lambda调用Handler方法,也就是Lambda具体业务逻辑处理运行的时间。

回到我们实际的业务场景中,我们在X-Ray可以看到冷启动时Lambda函数运行了31.8秒,其中初始化时间(initialization)花费了372毫秒,剩下绝大部分时间是耗费在Invocation上

为了了解Invocation耗时都是具体在哪些代码上,我们简单在几个主要的步骤上增加了时间戳,通过输出日志观察,我们可以看到PDFBox的实例初始化及S3Client的初始化占用了近20秒的时间。通过AWS在re:Invent上的一个公开分享,我们可以了解到,Lambda在Initialization和Invocation这两个阶段,提供的计算资源是不一样的。 在Initliazation阶段,为了加速Handler类的加载,这时候Lambda会获得额外的CPU资源,而到了Invocation阶段,则会按照实际Lambda配置的CPU资源来运行。因此一个最佳实践是在Initlization阶段就初始化相关的类,这样可以通过Lambda额外提供的计算资源来进行性能加速,示例代码如下:

另外通过查阅官方文档,我们了解到AWS SDK for Java在2.0版本在冷启动问题上进行了优化,同时我们可以通过对S3Client.builder设置特定的配置值,如直接指定区域(CN_NORTHWEST_1)、使用 Environment Variable credentials provider和使用URLConnection的HttpClient等,来额外减少冷启动时间,相关的示例代码如下:

因此按照上面的思路,我们可以把最开始的Lambda代码进行优化,具体如下所示:

优化后我们再进行实际测试,并通过X-Ray查看Lambda运行时间,可以看到冷启动的总体运行时间从31.8秒减少到了12秒,性能提升2.6倍。

具体展开来看,因为我们把相关类的初始化放在了Initialization阶段,因此这个阶段的运行时间由原来的327毫秒增加至5.9秒,但后续的Invocation阶段时间则大幅缩小至6.3秒,整体运行时间也得到了优化。

目前整体运行时间在业务可接受范围内,由于时间限制我们暂时结束了性能优化工作。但从re:Invent分享的视频,我们还可以看到进一步的优化空间,比如AWS SDK 的预热和使用 GraalVM 将 Java 应用程序编译成本地机器码等。除此之外,我们还可以考虑Provisioned Concurrency,即预置并发来解决Lambda冷启动的问题。预置并发可以为Lambda函数进行预热,在接收到请求时Lambda函数可以直接进行处理而无需冷启动。该功能还可以结合Applicaiton Auto Scaling一起来使用,进而自动配置并发数,从而节省预置并发的成本。

 

3. 使用AWS SAM进行自动化部署

在Lambda控制台上虽然可以直接进行Lambda函数的创建和编辑,但如果需要进行依赖库、本地调试和自动化部署等,则建议通过AWS Serverless Application Model(以下简称SAM) 来实现。此外,Serverless应用程序常常不只包括Lambda, 如我们这次实际场景中还包括API Gateway和S3等资源,通过SAM可以定义一个模板,在这个模板里面可以用简洁的语法来描述Lambda函数, API、权限、配置和构成无服务器应用程序的事件等。另外SAM还提供一个命令行工具(SAM CLI),通过SAM CLI我们能够在本地验证 AWS SAM 模板文件是否有语法错误、调用 Lambda 和API Gateway的本地运行功能、单步调试 Lambda 、打包和部署无服务器应用程序到AWS云端等功能。

3.1 SAM模板定义

在这里我们使用SAM CLI 在本地创建一个工程并进行开发,整体的目录结构如下图所示:

其中 template.yaml 文件是SAM的模板文件,这里我们列出几个关键资源的模板定义,完整的模版文件请参见Github仓库中的template.yaml

  • 打包中文字体库的Lambda Layer配置
PDF2ImageFontLayer: #创建Lambda依赖的Layer资源
       Type: AWS::Serverless::LayerVersion
       Properties:
         LayerName: fonts-layer
         ContentUri: ./fonts-layer.zip
         CompatibleRuntimes:
           - java8.al2
         Description: Fonts Layer
  • 异步调用模式的Lambda函数配置
Pdf2imageFunction:
       Type: AWS::Serverless::Function # 创建Lambda函数资源
       Properties:
         FunctionName: pdf2image-java
         Runtime: java8.al2  #运行时定义
         MemorySize: 1024    #Lambda内存分配
         CodeUri: .
         Handler: com.kixxxee.pwy.pdf2image.handler.S3HandlerASync::handleRequest 
         Layers:
           - Ref: PDF2ImageFontLayer #字体库资源引用
         Policies:
           - AmazonS3FullAccess  #对外部资源的访问权限策略
         Events:
           S3Event:  #Lambda触发事件
             Type: S3
             Properties:
               Bucket:
                 Ref: InvoicePdfStorage #原始pdf文件上传后的存储桶
               Events: s3:ObjectCreated:*    #触发事件
               Filter:
                 S3Key:
                   Rules:
                     - Name: prefix          #仅对指定前缀&后缀的文件触发Lambda
                       Value: ASYNC/
                     - Name: suffix
                       Value: .pdf
  • 同步调用模式的API Gateway和Lambda函数配置
Pdf2imageSyncAPI:
       Type: AWS::Serverless::Api  #创建APIGW资源
       Properties:
         Name: pdf2image
         StageName: Prod
         BinaryMediaTypes:
           - '*/*'
         EndpointConfiguration: PRIVATE  #Private API
         Auth:
           ResourcePolicy:  #允许基于指定VPC的流量
             CustomStatements: 
               - Effect: Deny
                 Principal: '*'
                 Action: execute-api:Invoke
                 Resource: "execute-api:/Prod/POST/pdf2image"
                 Condition:
                   StringNotEquals:
                     aws:sourceVpce: vpce-04baf2a8e131d4701
   
               - Effect: Allow
                 Principal: '*'
                 Action: execute-api:Invoke
                 Resource: "execute-api:/Prod/POST/pdf2image"
 
   Pdf2imageSyncFunction:
       Type: AWS::Serverless::Function  # 创建Lambda函数资源
       Properties:
         FunctionName: pdf2image-java-sync 
         Runtime: java8.al2
         MemorySize: 1024
         CodeUri: .
         Handler: com.kxxxee.pwy.pdf2image.handler.RestAPIHandlerSync::handleRequest
         Layers:
           - Ref: PDF2ImageFontLayer
         Policies:
           - AmazonS3FullAccess
         Events:
           ApiEvent:  #由API GW触发的Lambda
             Type: Api
             Properties:
               Path: /pdf2image
               Method: POST
               RestApiId:
                 Ref: Pdf2imageSyncAPI #引用API GW资源定义

3.2 部署至AWS云端

在工程的根目录下(即 template.yaml文件所在目录)中执行如下SAM CLI 命令完成至 AWS 云端的部署:

此外我们可以使用如 sam local start-api 和 sam local invoke 等命令来在本地模拟API Gateway和Lambda的运行,方便在本地进行开发调试,有兴趣的朋友可以自行尝试。

4. Serverless成本估算与优化

本次项目中,无服务器化带来的一个独特优势是运营成本的大幅降低。开发者不但能够聚焦业务实现、无需关注运维与基础设施,而且我们只需要按照业务调用次数、执行代码时长和资源付费,真正避免资源的浪费,极大降低业务的整体成本。

4.1 成本估算

我们通过Cloudwatch可以实际看到API Gateway和Lambda的请求次数,并通过账单快速浏览serverless服务的实际计费。中国宁夏区域为APIGateway提供了12个月免费套餐,覆盖每月1百万次的调用;Lambda则提供永久免费套餐,覆盖每月1百万次调用和3.2百万秒的使用时长。具体可以参考官网上关于免费套餐的介绍

当前场景的工作负载全部在免费套餐中覆盖,不需要为serverless的使用付费。

4.2 成本优化

通过上面的估算我们可以看到Serverless的成本其实是非常低了。从Lambda的计费模型我们可以看到,除了调用次数外,还有运行时长和占用资源这两个维度。一般来说,Lambda配置的资源越高,资源成本也就越高,但通常运行时间也会相应减少,运行时长成本就减少了。因此在优化Lambda成本时,我们可以通过配置不同的资源观察实际运行时间,并计算出对应资源下的整体成本,进而可以知道成本最优时的配置是如何的。

目前AWS国内区域Lambda资源配置最小支持内存为128MB,最大为3008MB。考虑到PDF缩略图生成是较耗资源的,我们在本项目开发中就先初定了1024MB这个配置。我们可以通过选择不同的配置,如128MB、512MB或是更大的2048MB等配置,统计相应的耗时并计算成本。

这个测试工作相对比较繁琐,我们可以考虑使用 Lambda Power Tuning 工具来进行自动化的测试。这个工具会自动选择不同的Lambda配置并进行测试,同时输出相应的成本,我们可以根据工具生成的图表来评估合理的Lambda配置。

比如说如下这个图我们可以看到内存配置从128MB提高到1536MB时,运行时长从35秒减少到小于3秒,成本甚至还降低了14%

因此我们在后续的Serverless应用改造过程中,会使用该工具来进行性能和成本优化。

5. 小结

从上面的分享我们可以看到,AWS Serverless可以帮助开发者聚焦在业务实现,无需耗费精力关注底层服务器、网络等基础架运维,因而可以缩短业务上线时间,帮助企业更快实现业务创新。同时,基于AWS Serverless构建的应用,我们只需要为实际使用的资源付费,整体成本可以得到优化。

在AWS上进行Serverless开发和部署时,开发者还可以利用丰富的配置工具,比如使用AWS SAM进行本地打包、调试和自动化部署等,使用X-Ray来进行分布式追踪,深入分析Lambda的运行时间等等。通过这些辅助的工具可以极大提高开发者的开发和调试效率,进而更好地在生产环境去落地Serverless架构。

 

参考资料:

  1. 博客: Using Amazon EFS for AWS Lambda in your serverless applications
  2. 官方文档:Optimizing cold start performance for AWS Lambda
  3. re:Invent视频分享: Best Practices for AWS Lambda and Java (SVS403-R1)
  4. 官方文档:Managing concurrency for a Lambda function

 

本篇作者

杨文威

金蝶发票云开发工程师。一位热爱运动的程序员。终生运动了,才有更好的状态去终生学习。

倪惠青

AWS 解决方案架构师,负责基于AWS云计算方案架构的咨询和设计,在国内推广AWS云平台技术和各种解决方案。在加入AWS 之前曾在Oracle,Microsoft工作多年,负责企业公有云方案咨询和架构设计,在基础架构及大数据方面有丰富经验。

林俊

AWS高级解决方案架构师,主要负责现代应用程序的方案咨询与架构设计,致力于无服务器 (Serverless) 技术的推广与应用