Redimensionar imágenes al cargar en S3 usando Lambda y .NET

Amazon Web Services Microsoft .NET AWS Lambda Amazon API Gateway Amazon Simple Storage Service

Imagina que estás creando un sitio web en AWS donde los usuarios pueden subir imágenes (ya sean avatares, portadas de perfil o fotos de gatitos). Estas imágenes deben mostrarse en diferentes tamaños, como miniaturas pequeñas o versiones grandes. Pero si los usuarios tienen que descargar las imágenes de tamaño completo cada vez, ralentizará el sitio, especialmente para aquellos con una conexión deficiente. Un sitio lento significa pérdida de usuarios, ventas e incluso clasificaciones en los motores de búsqueda. Mantengámoslo rápido y hagamos que las imágenes se carguen más rápido. Hay al menos dos opciones:

  • comprimir las imágenes a todos los tamaños necesarios al cargarlas en el sitio:
  • comprimir la imagen correspondiente al tamaño adecuado antes de enviarla al usuario.

En este artículo, nos centraremos en la primera opción.

Arquitectura

Implementaremos una API pública utilizando API Gateway y Lambda para cargar imágenes, que almacenaremos en un bucket de S3. Cada vez que se cargue un archivo en la carpeta images/ de S3, se activará otra función de Lambda, encargada de crear todas las versiones de imagen necesarias y colocarlas en la carpeta resized/. Esto nos permitirá separar los originales de las versiones procesadas. Todas las imágenes serán accesibles a través de URLs públicas generadas por S3.

ℹ️
Por simplicidad, intencionalmente no utilizamos CloudFront para almacenar en caché las imágenes en este ejemplo. Sin embargo, en un proyecto real, sería muy beneficioso hacerlo.

Arquitectura simple de redimensionamiento de imágenes con Lambda en evento de S3
Arquitectura simple de redimensionamiento de imágenes con Lambda en evento de S3

Implementación

Lambda de Procesamiento de Imágenes

Comencemos con la parte más crucial, que es el procesamiento de imágenes. Para esto, utilizaremos la biblioteca SkiaSharp, que es un envoltorio para Skia de Google. Es rápida y multiplataforma, lo que nos permite usarla en AWS Lambda.

Para comenzar, agreguemos los paquetes necesarios a nuestro proyecto. Solo necesitaremos dos paquetes: SkiaSharp y SkiaSharp.NativeAssets.Linux.NoDependencies. El primer paquete contiene la biblioteca en sí, mientras que el segundo incluye archivos nativos para Linux, lo que nos permite usar la biblioteca en AWS Lambda.

dotnet add package SkiaSharp
dotnet add package SkiaSharp.NativeAssets.Linux.NoDependencies

El código de la función Lambda se ve así:

ℹ️
Ten en cuenta que hemos codificado las dimensiones de las imágenes que necesitamos. Puedes hacer esto más flexible, por ejemplo, pasando las dimensiones a través de variables de entorno.
using Amazon.Lambda.Core;
using Amazon.Lambda.S3Events;
using Amazon.S3;
using Amazon.S3.Model;
using SkiaSharp;
using System.Text.Json;
using static Amazon.Lambda.S3Events.S3Event;

// Assembly attribute to enable the Lambda function's JSON input to be converted into a .NET class.
[assembly: LambdaSerializer(typeof(Amazon.Lambda.Serialization.SystemTextJson.DefaultLambdaJsonSerializer))]

namespace ImageResizeLambda;

public class Function
{
    private readonly IAmazonS3 _s3Client;

    // Predefined sizes for images
    private readonly List<(int Width, int Height, string Label)> _wideSizes = [(1280, 720, "L"), (1200, 628, "M"), (854, 480, "S"), (427, 240, "XS")];
    private readonly List<(int Width, int Height, string Label)> _tallSizes = [(720, 1280, "L"), (628, 1200, "M"), (480, 854, "S"), (240, 427, "XS")];
    private readonly List<(int Width, int Height, string Label)> _squareSizes = [(1080, 1080, "SL"), (540, 540, "SM"), (360, 360, "SS"), (180, 180, "SXS")];

    private readonly string _resizedObjectPath;
    private readonly bool _convertToWebp;
    private readonly int _convertQuality;

    public Function() : this(new AmazonS3Client())
    {
    }

    public Function(IAmazonS3 s3Client)
    {
        this._s3Client = s3Client;
        _resizedObjectPath = Environment.GetEnvironmentVariable("RESIZED_OBJECT_PATH") ?? "/resized/";
        _convertToWebp = bool.TryParse(Environment.GetEnvironmentVariable("CONVERT_TO_WEBP"), out bool convert) && convert;
        _convertQuality = int.TryParse(Environment.GetEnvironmentVariable("CONVERT_QUALITY"), out int quality) ? quality : 100;
    }

    public async Task FunctionHandler(S3Event evnt, ILambdaContext context)
    {
        context.Logger.LogLine($"Received S3 event: {JsonSerializer.Serialize(evnt)}");
        var eventRecords = evnt.Records ?? new List<S3EventNotificationRecord>();
        foreach (var s3EventEntity in eventRecords.Select(r => r.S3))
        {
            try
            {
                using var streamResponse = await this._s3Client.GetObjectStreamAsync(s3EventEntity.Bucket.Name, s3EventEntity.Object.Key, null);
                using var memoryStream = new MemoryStream();
                streamResponse.CopyTo(memoryStream);
                memoryStream.Seek(0, SeekOrigin.Begin);
                using var bitmap = SKBitmap.Decode(memoryStream);

                if (bitmap == null)
                {
                    context.Logger.LogError($"Error decoding object {s3EventEntity.Object.Key} from bucket {s3EventEntity.Bucket.Name}.");
                    continue;
                }

                var sizes = bitmap.Height > bitmap.Width ? _tallSizes : _wideSizes;
                foreach (var size in sizes)
                {
                    await ResizeAndPut(s3EventEntity, bitmap, size.Width, size.Height, size.Label, context.Logger);
                }

                foreach (var size in _squareSizes)
                {
                    await ResizeAndPut(s3EventEntity, bitmap, size.Width, size.Height, size.Label, context.Logger);
                }
            }
            catch (Exception e)
            {
                context.Logger.LogError($"Error getting object {s3EventEntity.Object.Key} from bucket {s3EventEntity.Bucket.Name}.");
                context.Logger.LogError(e.Message);
                context.Logger.LogError(e.StackTrace);
                throw;
            }
        }
    }

    private async Task ResizeAndPut(S3Entity s3EventEntity, SKBitmap bitmap, int width, int height, string sizeLabel, ILambdaLogger logger)
    {
        string filePath = Path.GetDirectoryName(s3EventEntity.Object.Key) ?? string.Empty;
        string fileExtension = Path.GetExtension(s3EventEntity.Object.Key);
        string filename = Path.GetFileNameWithoutExtension(s3EventEntity.Object.Key) + (_convertToWebp ? ".webp" : fileExtension);
        string destination = Path.Combine(_resizedObjectPath, filePath, sizeLabel, filename);

        logger.LogLine($"Resizing {s3EventEntity.Object.Key} to {width}x{height} and putting it to {destination}");

        try
        {
            using var resizedBitmap = bitmap.Resize(new SKImageInfo(width, height), SKFilterQuality.High);
            using var image = SKImage.FromBitmap(resizedBitmap);
            using var data = image.Encode(GetEncodedImageFormat(_convertToWebp, fileExtension), _convertQuality);
            using var ms = new MemoryStream();
            data.SaveTo(ms);
            ms.Seek(0, SeekOrigin.Begin);

            var request = new PutObjectRequest
            {
                BucketName = s3EventEntity.Bucket.Name,
                Key = destination,
                InputStream = ms
            };

            await this._s3Client.PutObjectAsync(request);
        }
        catch (Exception e)
        {
            logger.LogError($"Error processing {s3EventEntity.Object.Key}: Destination: {destination}, Width: {width}, Height: {height}");
            logger.LogError(e.Message);
            logger.LogError(e.StackTrace);
        }
    }

    // Helper method to get the image format based on the file extension and the convertToWebp flag
    private static SKEncodedImageFormat GetEncodedImageFormat(bool convertToWebp, string fileExtension) =>
    convertToWebp ? SKEncodedImageFormat.Webp : fileExtension.ToLower() switch
    {
        ".png" => SKEncodedImageFormat.Png,
        ".jpg" or ".jpeg" => SKEncodedImageFormat.Jpeg,
        ".gif" => SKEncodedImageFormat.Gif,
        ".bmp" => SKEncodedImageFormat.Bmp,
        ".wbmp" => SKEncodedImageFormat.Wbmp,
        ".dng" => SKEncodedImageFormat.Dng,
        ".heif" or ".heic" => SKEncodedImageFormat.Heif,
        ".webp" => SKEncodedImageFormat.Webp,
        _ => SKEncodedImageFormat.Png
    };
}

Nuestra función está configurada a través de las siguientes variables de entorno:

  • RESIZED_OBJECT_PATH - la ruta donde se almacenarán las imágenes procesadas.
  • CONVERT_TO_WEBP - una bandera que indica si se deben convertir las imágenes al formato (WebP)[https://developers.google.com/speed/webp/] (un formato moderno progresivo que acelera aún más la carga).
  • CONVERT_QUALITY - calidad de conversión de 0 a 100.

API Gateway

Para permitir a los usuarios cargar imágenes en nuestro sitio web, necesitamos crear una API pública. Para el backend de API Gateway, utilizaremos una función Lambda que almacenará los archivos cargados en S3. Comencemos creando la función basada en .NET Minimal API:

⚠️
Agregamos deliberadamente DisableAntiforgery a nuestro endpoint para evitar problemas de falsificación de solicitudes. No hagas esto en producción. Además, agregamos UseSwagger y UseSwaggerUI para poder probar nuestra API a través de Swagger.
using Amazon.S3;
using Amazon.S3.Model;

var builder = WebApplication.CreateBuilder(args);

// Add AWS Lambda support. When application is run in Lambda Kestrel is swapped out as the web server with Amazon.Lambda.AspNetCoreServer. This
// package will act as the webserver translating request and responses between the Lambda event source and ASP.NET Core.
builder.Services.AddAWSLambdaHosting(LambdaEventSource.HttpApi);
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();

var app = builder.Build();

app.UseHttpsRedirection();

// Enable middleware to serve generated Swagger as a JSON endpoint. 
// Don't do this in production, as it's a security risk.
app.UseSwagger();
app.UseSwaggerUI();

var s3Client = new AmazonS3Client();
var bucketName = Environment.GetEnvironmentVariable("BUCKET_NAME") ?? "fastfoodcoding-imageprocessing";
var uploadPath = Environment.GetEnvironmentVariable("UPLOAD_PATH") ?? "images/";

var allowedExtensions = new[] { ".jpg", ".jpeg", ".png", ".gif", ".bmp", ".webp", ".dng", ".heif", ".heic", ".wbmp" };

// upload file endpoint (images)
app.MapPost("/upload", async (IFormFile file) =>
{
    if (file == null || file.Length == 0)
    {
        return Results.BadRequest("File is empty");
    }

    // only allow certain file extensions
    if (!allowedExtensions.Contains(Path.GetExtension(file.FileName).ToLower(), StringComparer.OrdinalIgnoreCase))
    {
        return Results.BadRequest("Invalid file extension");
    }

    // don't allow files larger than 25MB
    if (file.Length > 25 * 1024 * 1024)
    {
        return Results.BadRequest("File is too large");
    }

    using var inputStream = file.OpenReadStream();
    using var memoryStream = new MemoryStream();
    inputStream.CopyTo(memoryStream);
    memoryStream.Seek(0, SeekOrigin.Begin);

    var putRequest = new PutObjectRequest
    {
        BucketName = bucketName,
        Key = Path.Combine(uploadPath, file.FileName),
        InputStream = memoryStream
    };

    var response = await s3Client.PutObjectAsync(putRequest);
    return Results.StatusCode((int)response.HttpStatusCode);
})
.DisableAntiforgery(); // Disable antiforgery for this endpoint. Don't do this in production.

app.Run();

Esta función se configura a través de las siguientes variables de entorno:

  • BUCKET_NAME - el nombre del bucket S3 donde se almacenarán los archivos subidos.
  • UPLOAD_PATH - la ruta para almacenar los archivos.

CDK

Para desplegar nuestra solución, utilizaremos AWS CDK. Vamos a crear un Stack que contendrá nuestras funciones Lambda, el bucket S3 y el API Gateway.

using Amazon.CDK;
using Amazon.CDK.AWS.Apigatewayv2;
using Amazon.CDK.AWS.IAM;
using Amazon.CDK.AWS.Lambda;
using Amazon.CDK.AWS.S3;
using Amazon.CDK.AWS.S3.Notifications;
using Amazon.CDK.AwsApigatewayv2Integrations;
using Constructs;
using System.Collections.Generic;

namespace FastFoodCoding.ImageProcessing.Cdk
{
    public class ImageProcessingCdkStack : Stack
    {
        internal ImageProcessingCdkStack(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 sourceFolder = new CfnParameter(this, "SourceFolder", new CfnParameterProps
            {
                Type = "String",
                Description = "The name of the folder in the S3 bucket to store images",
                Default = "images"
            });

            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 = "r"
            });

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

            // allow anyone to read objects from the bucket
            s3Bucket.AddToResourcePolicy(new PolicyStatement(new PolicyStatementProps
            {
                Actions = ["s3:GetObject"],
                Resources = [s3Bucket.ArnForObjects("*")],
                Principals = [new StarPrincipal()]
            }));

            // 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"],
                                Resources = [s3Bucket.ArnForObjects("*")]
                            })
                        ]
                    })
                }
            });

            // define a lambda function to resize images
            var imageResizeLambda = new Function(this, "ImageResizeLambda", new FunctionProps
            {
                Runtime = Runtime.DOTNET_8,
                Handler = "ImageResizeLambda::ImageResizeLambda.Function::FunctionHandler",
                Code = Code.FromAsset("../src/ImageResizeLambda/bin/Release/net8.0/linux-arm64/publish"),
                Architecture = Architecture.ARM_64,
                MemorySize = 512,
                Timeout = Duration.Minutes(2),
                Role = imageResizeLambdaRole,
                Environment = new Dictionary<string, string>
                {
                    ["RESIZED_OBJECT_PATH"] = destinationFolder.ValueAsString
                }
            });

            // add an event notification to the bucket to trigger the lambda function when an image is uploaded.
            // prefix is used to filter the event notifications to only trigger when an image is uploaded to the source folder
            s3Bucket.AddEventNotification(EventType.OBJECT_CREATED, new LambdaDestination(imageResizeLambda), new NotificationKeyFilter
            {
                Prefix = sourceFolder.ValueAsString + "/"
            });

            // allow the API lambda function to write objects to the bucket
            var imageUploadApiLambdaRole = new Role(this, "ImageUploadApiLambdaRole", 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:PutObject"],
                                Resources = [s3Bucket.ArnForObjects("*")]
                            })
                        ]
                    })
                }
            });

            // define a lambda function to upload images
            var imageUploadApiLambda = new Function(this, "ImageUploadApiLambda", new FunctionProps
            {
                Runtime = Runtime.DOTNET_8,
                Handler = "ImageUploadApi",
                Code = Code.FromAsset("../src/ImageUploadApi/bin/Release/net8.0/linux-arm64/publish"),
                Architecture = Architecture.ARM_64,
                MemorySize = 512,
                Timeout = Duration.Minutes(2),
                Role = imageUploadApiLambdaRole,
                Environment = new Dictionary<string, string>
                {
                    ["BUCKET_NAME"] = bucketName.ValueAsString,
                    ["UPLOAD_PATH"] = sourceFolder.ValueAsString
                }
            });

            // define an API Gateway (HTTP) to upload images
            _ = new HttpApi(this, "ImageUploadApi", new HttpApiProps
            {
                DefaultIntegration = new HttpLambdaIntegration(
                    "ImageUploadApiIntegration",
                    imageUploadApiLambda,
                    new HttpLambdaIntegrationProps
                    {
                        PayloadFormatVersion = PayloadFormatVersion.VERSION_2_0
                    }),
                ApiName = "ImageUploadApi",
                CorsPreflight = new CorsPreflightOptions
                {
                    AllowOrigins = new[] { "*" },
                    AllowMethods = new[] { CorsHttpMethod.ANY },
                    AllowHeaders = new[] { "*" }
                },
            });
        }
    }
}
ℹ️
Por favor, ten en cuenta que elegimos arm64 (procesador AWS Graviton2) para nuestras Lambdas de AWS porque son más económicas y rápidas en comparación con x86-64. Más detalles disponibles aquí.

Cómo desplegar

Puedes desplegar esta solución utilizando los siguientes comandos:

cd src
dotnet publish -c Release -r linux-arm64

cd ../cdk/src
cdk deploy --parameters BucketName=MyUniqueBucketName --parameters SourceFolder=images --parameters DestinationFolder=resized