Redimensionar imágenes en CloudFront + Lambda@Edge
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.
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 archivolambda_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 en1
, 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.
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!