亚马逊AWS官方博客

利用 Amazon CloudFront Edge Function 和 Amazon Lambda 对访问图片进行动态压缩

背景

在当今数字化时代,互联网已成为信息传播的主要渠道,而图片作为其中最直观、最具吸引力的内容形式,占据了网络流量的绝大部分。根据最新研究数据显示,图片资源约占网页总下载量的 60-65%,在社交媒体平台上这一比例甚至更高。然而,随着高清图片的普及和用户对视觉体验要求的提升,未经优化的图片正在悄无声息地消耗着宝贵的网络带宽和服务器资源。

在访问图片内容时对图片进行动态压缩,正在成为互联网性能优化不可或缺的关键环节。它不仅能显著减少数据传输量,加快页面加载速度,还能降低服务器负载,减少能源消耗,为企业节省大量带宽成本。在移动互联网时代,由于不同地区网络发展不均衡,对于那些网络条件受限的用户来说,优化后的图片更是提升用户体验的重要保障。

我们可以在亚马逊云上通过多种服务,如 Amazon CloudFront、Amazon CloudFront Edge Function、Amazon Lambda 和 Amazon S3,搭建起一套图片动态压缩的解决方案,具体方案细节请参考该链接。当前的方案通过如下方法实现动态压缩和剪裁:

  • 用户发送 HTTP 请求,请求包含特定转换(例如编码和大小)的图像。这些转换被编码到 URL 中,更准确地说是查询参数。示例 URL 如下所示:https://examples.com/images/cats/mycat.jpg?format=webp&width=200
  • 该请求由附近的 CloudFront 边缘站点处理,会在查看器请求事件中执行 CloudFront Functions 来重写请求 URL。CloudFront Functions 是 CloudFront 的一项功能,允许您使用 JavaScript 编写轻量级函数,以实现大规模、对延迟敏感的 CDN 自定义。在当前的架构中,我们会重写 URL 以验证请求的转换,并通过对转换进行排序并将其转换为小写来规范化 URL,从而提高缓存命中率。当请求自动转换时,该函数还会确定要应用的最佳转换。例如,如果用户使用指令 format=auto 请求最优化的图像格式(JPEG、WebP 或 AVIF),CloudFront Function 将根据请求中的 Accept Header 选择最佳格式。
  • 如果图像不在 CloudFront 缓存中,则请求将被转发到 S3 存储桶,该存储桶专门用于存储转换后的图像。如果请求的图像已转换并存储在 S3 中,则只需在 CloudFront 中提供和缓存即可。

用户需求:更少的代码改造 & 更多的成本节省

以上方案利用 Serverless 的形式,以极低的成本实现了对访问图片的动态压缩。但在实际使用中,用户可能会有如下额外需求:

  • 当前实现压缩的前提是客户端需做一定程度的改造,需支持将请求参数添加到请求 URL 中。针对大部分场景,用户只需对图片进行压缩而不需要剪裁,并且不希望对客户端代码进行变更。
  • 当原始图片进行更新或者删除时,需同样对 S3 中转换后的图片进行删除,并且清除当前的 CloudFront 缓存,以确保用户可以请求到最新版本的压缩图片。
  • 针对同一个 CloudFront 分配,可以同时处理图片和非图片请求。

解决方案

基于以上需求,我们对原有方案进行了如下改造:

  • 修改当前 URL Rewrite CloudFront Function。将代码逻辑修改为,在不修改访问 URL 的前提下,根据客户端携带的 Accept Header 来判断客户端能支持哪些压缩格式,如果同时携带 AVIF/WebP,则优先压缩 AVIF。
    function handler(event) {
        var request = event.request;
        var originalImagePath = request.uri;
        
        // Determine the best format based on the Accept header
    var format = 'jpeg';
        if (originalImagePath.includes('.gif')) {
            format = 'webp'; // If the URI contains .gif, set format to webp
        } else if (originalImagePath.includes('.webp')) {
            format = 'webp'; // If the URI contains .webp, set format to webp
        } else if (originalImagePath.includes('.jpg')) {
            format = 'webp'; // If the URI contains .jpg, set format to webp
        }else if (originalImagePath.includes('.png')) {
            format = 'webp'; // If the URI contains .png, set format to webp
        }
        else if (originalImagePath.includes('.svg')) {
            format = 'webp'; // If the URI contains .webp, set format to webp
        }else if (request.headers['accept']) {
            if (request.headers['accept'].value.includes("avif")) {
                format = 'avif';
            } else if (request.headers['accept'].value.includes("webp")) {
                format = 'webp';
            }
        }
    
        // Just add the format parameter and use the original image size.
        request.uri = originalImagePath + '/format=' + format;
        
        // Clear query parameters
        request['querystring'] = {};
        return request;
    }
    
  • 明确图片和非图片请求的处理逻辑。新建行为和原图存放的 S3 源站,只针对 jpeg、jpg、png、gif 后缀的文件触发图片处理逻辑,其他后缀文件直接响应源文件。为此,我们在 CDK 代码中添加了 CloudFront 的行为配置,如下图所示:
  • 更新源桶文件后,需要删除压缩图,并且调用 Cloudfront API 清除指定缓存。CDK 代码中新增了对应的 image-sync。Lambda 实现如下:
    // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
    // SPDX-License-Identifier: MIT-0
    
    import { S3Client, HeadObjectCommand, DeleteObjectCommand } from "@aws-sdk/client-s3";
    import { CloudFrontClient, CreateInvalidationCommand } from "@aws-sdk/client-cloudfront";
    
    const s3Client = new S3Client();
    const cloudfrontClient = new CloudFrontClient();
    
    /**
     * Check if an S3 object exists
     * @param {string} bucket - S3 bucket name
     * @param {string} key - S3 object key
     * @returns {Promise<boolean>} - True if object exists, false otherwise
     */
    async function checkObjectExists(bucket, key) {
      try {
        await s3Client.send(new HeadObjectCommand({ Bucket: bucket, Key: key }));
        return true;
      } catch (error) {
        if (error.name === 'NotFound' || error.$metadata?.httpStatusCode === 404) {
          return false;
        }
        console.error(`Error checking object existence: ${error.message}`);
        throw error;
      }
    }
    
    /**
     * Invalidate CloudFront cache for specified paths
     * @param {string} distributionId - CloudFront distribution ID
     * @param {string[]} paths - Paths to invalidate
     * @returns {Promise<string>} - Invalidation ID
     */
    async function invalidateCloudFrontCache(distributionId, paths) {
      try {
        const response = await cloudfrontClient.send(new CreateInvalidationCommand({
          DistributionId: distributionId,
          InvalidationBatch: {
            Paths: {
              Quantity: paths.length,
              Items: paths
            },
            CallerReference: `image-sync-${Date.now()}`
          }
        }));
        
        console.log(`Invalidation created: ${response.Invalidation.Id}`);
        return response.Invalidation.Id;
      } catch (error) {
        console.error(`Error creating invalidation: ${error.message}`);
        throw error;
      }
    }
    
    export const handler = async (event) => {
      // Get environment variables
      const targetBucket = process.env.TRANSFORMED_IMAGE_BUCKET;
      const distributionId = process.env.CLOUDFRONT_DISTRIBUTION_ID;
      
      if (!targetBucket || !distributionId) {
        console.error('Missing required environment variables');
        return {
          statusCode: 500,
          body: JSON.stringify({ error: 'Missing required environment variables' })
        };
      }
    
      // Process results
      const results = {
        checked: [],
        deleted: [],
        skipped: [],
        invalidated: []
      };
    
      try {
        // Process each record in the S3 event
        for (const record of event.Records) {
          // Get the source file key and decode it
          const sourceKey = decodeURIComponent(record.s3.object.key.replace(/\+/g, ' '));
          console.log(`Processing file: ${sourceKey}`);
    
          // Check if the file is an image (we only care about images)
          const imageExtensions = ['.jpg', '.jpeg', '.png', '.gif'];
          const hasImageExtension = imageExtensions.some(ext => 
            sourceKey.toLowerCase().endsWith(ext)
          );
    
          if (!hasImageExtension) {
            console.log(`Skipping non-image file: ${sourceKey}`);
            continue;
          }
    
          // Define the variants to check and delete
          const variants = [
            `${sourceKey}/format=avif`,
            `${sourceKey}/format=webp`,
            `${sourceKey}/format=jpeg`
          ];
    
          const cloudfrontPaths = [];
    
          // Check and delete each variant
          for (const variantKey of variants) {
            results.checked.push(variantKey);
            console.log(`Checking in target bucket: ${variantKey}`);
    
            try {
              // Check if the variant exists
              const exists = await checkObjectExists(targetBucket, variantKey);
              
              if (exists) {
                // Delete the variant
                await s3Client.send(new DeleteObjectCommand({
                  Bucket: targetBucket,
                  Key: variantKey
                }));
                
                results.deleted.push(variantKey);
                cloudfrontPaths.push(`/${variantKey}`);
                console.log(`Successfully deleted: ${variantKey}`);
              } else {
                results.skipped.push(variantKey);
                console.log(`File does not exist in target bucket, skipping: ${variantKey}`);
              }
            } catch (error) {
              console.error(`Error processing ${variantKey}: ${error.message}`);
            }
          }
    
          // Invalidate CloudFront cache if needed
          if (cloudfrontPaths.length > 0) {
            try {
              await invalidateCloudFrontCache(distributionId, cloudfrontPaths);
              results.invalidated = cloudfrontPaths;
            } catch (error) {
              console.error(`Error invalidating CloudFront cache: ${error.message}`);
            }
          }
        }
    
        return {
          statusCode: 200,
          body: JSON.stringify({
            message: 'Processing completed',
            results
          })
        };
      } catch (error) {
        const errorMessage = `Error in lambda execution: ${error.message}`;
        console.error(errorMessage);
        
        return {
          statusCode: 500,
          body: JSON.stringify({
            error: errorMessage,
            results
          })
        };
      }
    };
    
  • 若您使用的原图存储桶是您自己定义的,需要您手动创建 S3 事件通知如下:

新版本 CDK 代码,您可以从此处进行下载和部署。

方案收益

  • 针对简单的图片压缩需求,客户端代码无需任何改造即可适配。
  • 对于后续图片的更新/删除,CloudFront 缓存清除等工作,该方案也做了相应自动化流程改造。
  • 通过 Serverless 的方式,低成本地实现了动态图片压缩方案,降低用户流量成本。以 AVIF 为例,相比于 JPEG,可减少 30-50% 的文件大小。

结语

本文通过对当前现有 AWS 的图片压缩处理功能进行了进一步功能的增加和改进,希望对于有对应需求的用户可以有所帮助。

本篇作者

孟祥智

亚马逊云科技解决方案架构师

吴华

亚马逊云科技客户技术经理,负责企业级客户技术支持、成本以及架构优化等工作。在加入亚马逊云科技之前曾负责 ByteDance、小米、斗鱼等互联网企业专属高级技术支持工作,有着超过 5 年丰富的客户支持工作经验。