Redimensionar imágenes en CloudFront + Lambda@Edge

Amazon Web Services Python AWS Lambda Amazon CloudFront Amazon Simple Storage Service

En el artículo anterior, vimos cómo reducir el tamaño de las imágenes durante la carga a S3 utilizando AWS Lambda y .NET. Sin embargo, si estás utilizando CloudFront para distribuir imágenes y deseas tener un enfoque más flexible, puedes usar Lambda@Edge para redimensionar las imágenes sobre la marcha. Esto te permite reducir el tráfico entre CloudFront y tu servidor, así como reducir los tiempos de carga de página para tus usuarios.

Arquitectura

La idea detrás de nuestra solución es utilizar Lambda@Edge en el tipo de evento Origin Request a CloudFront. De esta manera, podemos redimensionar la imagen antes de que se envíe al usuario por primera vez. La imagen luego se almacenará en la caché de CloudFront.

Esta vez utilizaremos Python y la biblioteca Pillow para redimensionar la imagen. Esto se debe a que Lambda@Edge solo permite Python y Node.js como tiempo de ejecución. Más información sobre las limitaciones de Lambda@Edge.

Arquitectura simplificada para redimensionar imágenes usando CloudFront y Lambda@Edge
Arquitectura simplificada para redimensionar imágenes usando CloudFront y Lambda@Edge

Implementación

Lambda@Edge para redimensionar imágenes

Debido a las limitaciones de Lambda@Edge, que mencionamos anteriormente, solo podemos usar Python o Node.js. En este caso, utilizaremos Python. Nuestra función estará vinculada al evento Origin Request. Esto significa que se llamará antes de que CloudFront envíe una solicitud a S3 para obtener la imagen. En este punto, podemos:

  • Verificar si la imagen ya está redimensionada.
  • Redirigir a CloudFront a ella si ya está redimensionada.
  • Redimensionarla primero antes de redirigir si aún no está redimensionada.

Por lo tanto, la imagen se redimensionará solo una vez y luego se guardará en S3 y en la caché de CloudFront. Potencialmente, nuestra función no se llamará con mucha frecuencia. Esto dependerá de la configuración de la caché y la geografía de los usuarios.

import mimetypes
import os
import boto3
from io import BytesIO
from PIL import Image

ALLOWED_RESIZE_EXTENSIONS = ["jpg", "jpeg", "png", "gif", "bmp", "webp"]
SIZE_MAP = {
    'L': {'WIDE': (1280, 720), 'TALL': (720, 1280)},
    'M': {'WIDE': (1200, 628), 'TALL': (628, 1200)},
    'S': {'WIDE': (854, 480), 'TALL': (480, 854)},
    'XS': {'WIDE': (427, 240), 'TALL': (240, 427)},
}
SQUARE_SIZE_MAP = {'SL': (1080, 1080), 'SM': (540, 540), 'SS': (360, 360), 'SXS': (180, 180)}
SIZE_PARAMETER_NAME = "size="
SIZE_MAP.update(SQUARE_SIZE_MAP)
ALLOWED_RESIZE_VALUES = [SIZE_PARAMETER_NAME + size for size in {**SIZE_MAP, **SQUARE_SIZE_MAP}]
CONVERT_TO_WEBP = "to_webp=1"

s3 = boto3.client('s3')

def lambda_handler(event, context):
    request = event['Records'][0]['cf']['request']
    request_uri: str = request['uri'].lstrip('/')
    request_uri_no_extension: str = os.path.splitext(request_uri)[0]
    request_uri_extension: str = os.path.splitext(request_uri)[1].lstrip('.').lower()
    request_querystring: str = request['querystring']

    ## since environment variables are not supported in Lambda@Edge and we don't want to hardcode values
    ## we are passing them as custom headers in the S3 origin configuration
    ## alternatively, you can use AWS Systems Manager Parameter Store to store these values
    custom_headers = request['origin']['s3']['customHeaders']   
    env_resized_path = custom_headers['x-env-resized-path'][0]['value']
    env_bucket_name = custom_headers['x-env-bucket-name'][0]['value']
    env_quality = int(custom_headers['x-env-quality'][0]['value'])

    print(f"Resized path: {env_resized_path}, Bucket name: {env_bucket_name}, Quality: {env_quality}")

    if request_uri_extension not in ALLOWED_RESIZE_EXTENSIONS:
        print(f"Not allowed extension: {request_uri_extension}. Skipping...")
        return request

    query_params: list[str] = request_querystring.split('&')
    if not any(query in query_params for query in ALLOWED_RESIZE_VALUES):
         print("No allowed size parameter found. Skipping...")
         return request

    size_parameter: str = next((query for query in query_params if query.startswith(SIZE_PARAMETER_NAME)), "").replace(SIZE_PARAMETER_NAME, "")
    convert_to_webp: bool = any(query in query_params for query in [CONVERT_TO_WEBP])
    dest_file_extension = "webp" if convert_to_webp else request_uri_extension
    dest_file_path: str = f"{env_resized_path}/{size_parameter}/{request_uri_no_extension}.{dest_file_extension}"
    dest_image_exists: bool = is_s3_obj_exists(env_bucket_name, dest_file_path)

    if dest_image_exists:
        request['uri'] = '/' + dest_file_path
        print(f"Resized file already exists: {dest_file_path}. Returning...")
        return request

    source_image_exists: bool = is_s3_obj_exists(env_bucket_name, request_uri)
    if not source_image_exists:
        print(f"Source image does not exist: {request_uri}. Skipping...")
        return request

    # download original image from S3 and resize
    source_image_obj = s3.get_object(Bucket=env_bucket_name, Key=request_uri)['Body'].read()
    image: Image.Image = Image.open(BytesIO(source_image_obj))

    # resize image and save it to in-memory file
    image.thumbnail(get_size(image, size_parameter))
    in_mem_file = BytesIO()
    image.save(in_mem_file, format="webp" if convert_to_webp else image.format, quality=env_quality)
    in_mem_file.seek(0)

    # upload resized image to S3
    s3.upload_fileobj(
        in_mem_file,
        env_bucket_name,
        dest_file_path,
        ExtraArgs={
            # set proper content-type instead of default 'binary/octet-stream'
            'ContentType': mimetypes.guess_type(dest_file_path)[0] or f'image/{dest_file_extension}'
        }
    )

    # change request uri to the resized file path and return it to CloudFront
    request['uri'] = '/' + dest_file_path
    return request

def get_size(image, size_parameter):
    if size_parameter in SQUARE_SIZE_MAP:
        return SQUARE_SIZE_MAP[size_parameter]
    elif image.width > image.height:
        return SIZE_MAP[size_parameter]['WIDE']
    else:
        return SIZE_MAP[size_parameter]['TALL']

def is_s3_obj_exists(bucket, key: str) -> bool:
    objs = s3.list_objects_v2(Bucket=bucket, Prefix=key).get('Contents', [])
    return any(obj['Key'] == key for obj in objs)

Parámetros

Dado que Lambda@Edge no admite variables de entorno, los parámetros se pasan como valores de encabezados personalizados como parte de la solicitud desde CloudFront. Se admiten los siguientes parámetros:

  • x-env-bucket-name - el nombre del bucket de S3 donde se almacenan las imágenes originales y redimensionadas.
  • x-env-resized-path - la carpeta donde se almacenan las imágenes redimensionadas.
  • x-env-quality - la calidad de la imagen redimensionada.

Uso

Cuando alguien solicita una imagen desde CloudFront, se activará la función Lambda@Edge. La función verificará si la imagen redimensionada solicitada ya existe. Si no existe, redimensionará la imagen y la almacenará en el bucket de S3. La imagen redimensionada se servirá al usuario.

Solicita una imagen desde CloudFront de la siguiente manera:

https://xxxxxxxxxxx.cloudfront.net/kitty.jpg?size=<SIZE>&to_webp=<1|0>

Dónde:

  • size - el tamaño de la imagen redimensionada. Los valores permitidos están predefinidos y codificados para mayor simplicidad. Puedes cambiarlos en el archivo lambda_function.py. Los valores permitidos son:

    • XS, S, M, L para imágenes ’largas’ y ‘altas’
    • SXS, SS, SM, SL para imágenes ‘cuadradas’
  • to_webp - si se establece en 1, la imagen se convertirá al formato WebP. De lo contrario, el formato de la imagen será el mismo que el de la imagen original.

CDK

Prerrequisitos

  • Tener instalado y configurado AWS CDK.
  • Tener instalado y ejecutándose Docker. Esto es necesario para compilar correctamente la función Lambda.

Stack

Para implementar nuestra solución, utilizaremos AWS CDK. Describiremos un Stack que creará un bucket de S3, una distribución de CloudFront y una función Lambda@Edge con todos los roles, desencadenadores y permisos necesarios.

ℹ️
CDK’s S3Origin no admite la configuración recomendada de OriginAccessControl, pero utiliza la identidad de acceso de origen heredada en su lugar. Para superar esta limitación, utilizaremos un método alternativo extendiendo el constructo S3Origin y sobrescribiendo el método RenderS3OriginConfig.
public class ImageResizeEdgeCdkStack : Stack
{
    internal ImageResizeEdgeCdkStack(Construct scope, string id, IStackProps props = null) : base(scope, id, props)
    {
        var bucketName = new CfnParameter(this, "BucketName", new CfnParameterProps
        {
            Type = "String",
            Description = "The name of the S3 bucket to store images",
            Default = "fastfoodcoding-imageprocessing"
        });

        var destinationFolder = new CfnParameter(this, "DestinationFolder", new CfnParameterProps
        {
            Type = "String",
            Description = "The name of the folder in the S3 bucket to store resized images",
            Default = "resize"
        });

        var quality = new CfnParameter(this, "Quality", new CfnParameterProps
        {
            Type = "Number",
            Description = "The quality of the resized images",
            Default = 80
        });

        // define a public S3 bucket to store images
        var s3Bucket = new Bucket(this, "ImageBucket", new BucketProps
        {
            BucketName = bucketName.ValueAsString,
            RemovalPolicy = RemovalPolicy.DESTROY,
            BlockPublicAccess = BlockPublicAccess.BLOCK_ALL,
        });

        // allow the lambda function to read and write objects to the bucket
        var imageResizeLambdaRole = new Role(this, "ImageResizeLambdaRole", new RoleProps
        {
            AssumedBy = new ServicePrincipal("lambda.amazonaws.com"),
            ManagedPolicies =
            [
                ManagedPolicy.FromAwsManagedPolicyName("service-role/AWSLambdaBasicExecutionRole")
            ],
            InlinePolicies = new Dictionary<string, PolicyDocument>
            {
                ["S3Policy"] = new PolicyDocument(new PolicyDocumentProps
                {
                    Statements =
                    [
                        new PolicyStatement(new PolicyStatementProps
                        {
                            Actions = ["s3:GetObject", "s3:PutObject", "s3:Listbucket"],
                            Resources = [s3Bucket.ArnForObjects("*"), s3Bucket.BucketArn]
                        })
                    ]
                })
            }
        });

        // define a lambda function to resize images
        var imageResizeLambda = new Amazon.CDK.AWS.Lambda.Function(this, "ImageResizeLambda", new Amazon.CDK.AWS.Lambda.FunctionProps
        {
            Runtime = Runtime.PYTHON_3_11,
            Handler = "lambda_function.lambda_handler",
            // build our function with Docker since Pillow has platform-specific dependencies. In our case, we're building for linux/amd64
            Code = Code.FromDockerBuild("../src", new DockerBuildAssetOptions { Platform = "linux/amd64" }),
            Architecture = Architecture.X86_64,
            MemorySize = 512,
            Timeout = Duration.Seconds(30),
            Role = imageResizeLambdaRole
        });

        // define L1 construct to attach the OriginAccessControl to the CloudFront Distribution
        var cfnOriginAccessControl = new CfnOriginAccessControl(this, "OriginAccessControl", new CfnOriginAccessControlProps
        {
            OriginAccessControlConfig = new OriginAccessControlConfigProperty
            {
                Name = "ImageResize-OriginAccessControl",
                OriginAccessControlOriginType = "s3",
                SigningBehavior = "always",
                SigningProtocol = "sigv4"
            }
        });

        // define CloudFront distribution to serve images from the bucket
        var cfnDistribution = new Distribution(this, "ImageBucketDistribution", new DistributionProps
        {
            DefaultBehavior = new BehaviorOptions
            {
                Origin = new S3OacOrigin(s3Bucket, new S3OriginProps
                {
                    OriginAccessIdentity = null,
                    ConnectionAttempts = 3,
                    ConnectionTimeout = Duration.Seconds(10),
                    // since Lambda@Edge doesn't support Environment variables, we're passing those as custom headers
                    // please note, that this make sense only for the OriginRequest event type and not for the ViewerRequest
                    CustomHeaders = new Dictionary<string, string>
                    {
                        ["X-Env-Resized-Path"] = destinationFolder.ValueAsString,
                        ["X-Env-Bucket-Name"] = bucketName.ValueAsString,
                        ["X-Env-Quality"] = quality.ValueAsString
                    }
                }),
                CachePolicy = new CachePolicy(this, "ImageBucketCachePolicy", new CachePolicyProps
                {
                    QueryStringBehavior = CacheQueryStringBehavior.AllowList("size", "to_webp"),
                    DefaultTtl = Duration.Days(1),
                    MaxTtl = Duration.Days(365),
                    MinTtl = Duration.Seconds(1),
                    EnableAcceptEncodingGzip = true
                }),
                ViewerProtocolPolicy = ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
                EdgeLambdas = new[]
                {
                    new EdgeLambda
                    {
                        EventType = LambdaEdgeEventType.ORIGIN_REQUEST,
                        FunctionVersion = imageResizeLambda.CurrentVersion
                    }
                }
            }
        });

        s3Bucket.AddToResourcePolicy(new PolicyStatement(new PolicyStatementProps
        {
            Actions = ["s3:GetObject"],
            Principals = [new ServicePrincipal("cloudfront.amazonaws.com")],
            Effect = Effect.ALLOW,
            Resources = [s3Bucket.ArnForObjects("*")],
            Conditions = new Dictionary<string, object>
            {
                ["StringEquals"] = new Dictionary<string, object>
                {
                    ["AWS:SourceArn"] = $"arn:aws:cloudfront::{this.Account}:distribution/{cfnDistribution.DistributionId}"
                }
            }
        }));

        // workaround using the L1 construct to attach the OriginAccessControl to the CloudFront Distribution
        var l1CfnDistribution = cfnDistribution.Node.DefaultChild as CfnDistribution;
        l1CfnDistribution.AddPropertyOverride("DistributionConfig.Origins.0.OriginAccessControlId", cfnOriginAccessControl.AttrId);
    }
}

public class S3OacOrigin : OriginBase
{
    public S3OacOrigin(IBucket bucket, IOriginProps props = null) : base(bucket.BucketRegionalDomainName, props) { }

    // workaround to avoid the "OriginAccessIdentity" property to be rendered in the CloudFormation template
    protected override IS3OriginConfigProperty RenderS3OriginConfig()
    {
        return new S3OriginConfigProperty
        {
            OriginAccessIdentity = ""
        };
    }
}

Cómo desplegar

cdk deploy --parameters BucketName=fastfoodcoding-edge --parameters DestinationFolder=resized --parameters Quality=80

Conclusión

En este artículo, hemos aprendido a redimensionar imágenes sobre la marcha utilizando CloudFront y Lambda@Edge. Este enfoque nos permite reducir el tráfico entre CloudFront y nuestro servidor, así como disminuir el tiempo de carga de la página para nuestros usuarios. También hemos enfrentado algunas limitaciones de Lambda@Edge y CDK, pero hemos logrado superarlas. Siéntete libre de experimentar con el código y adaptarlo a tus necesidades. ¡Feliz codificación!