Introducción
Si seguiste mi anterior tutorial es posible que te preguntases por qué usar un bucket S3 para almacenar los datos en vez de hacerlo directamente con el volumen EBS de la instancia. La respuesta reside en que S3 nos permite realizar algunas acciones de análisis y/o procesado sobre los ficheros que subimos, en este caso utilizando una Lambda. Vamos a crear una Lambda que se llame con la subida de una imagen y la procese para reducir su tamaño y la vuelva a almacenar en S3.
Para automatizar el proceso de creación de la infraestructura vamos a utilizar Terraform. Antes de empezar el tutorial, tenemos que tener configurada la cuenta de AWS en nuestro ordenador y tener Terraform instalado, de la misma forma que en el tutorial del servidor SFTP enlazado al principio. Además, también debemos crear un directorio de trabajo y añadir el fichero de configuración de AWS con Terraform.
Todo el código del tutorial se puede encontrar en el siguiente repositorio en GitHub: https://github.com/julenminer/aws_terraform_micronaut.
Entorno
El tutorial está escrito usando el siguiente entorno:
- Hardware: Portátil MacBook Pro 14 (2,2 GHz Intel Core i7, 16GB 1600 MHz DDR3, 500GB Flash Storage)
- AWS CLI versión 2
- AWS Lambda
- Amazon S3
- Terraform versión 0.13.5
Configurar el bucket de S3
Una vez tenemos inicializado el proyecto de Terraform (con el comando terraform init) y el fichero setup.tf, es el momento de configurar nuestro bucket de S3. Vamos al directorio de trabajo y creamos un fichero al que vamos a llamar S3_bucket_config.tf (podemos darle otro nombre). En este vamos a crear el bucket de S3 creando el recurso necesario y añadiremos dos objetos (dos carpetas en este caso, una llamada original-images y otra llamada images). El contenido es el siguiente:
resource "aws_s3_bucket" "images_bucket" { bucket = var.bucket_name } resource "aws_s3_bucket_object" "original_images_folder" { bucket = aws_s3_bucket.images_bucket.id key = "original-images/" } resource "aws_s3_bucket_object" "images_folder" { bucket = aws_s3_bucket.images_bucket.id key = "images/" }
Estamos utilizando una variable para el nombre del bucket, por lo que tendremos que ir al fichero variables.tf (creado con la inicialización de Terraform) y añadir la variable:
variable "bucket_name" { default = "<BUCKET_NAME>" }
Tenemos que cambiar <BUCKET_NAME> por el nombre que le queramos dar a nuestro bucket, recordando que tiene que ser un nombre único en todo AWS. Con esto ya tenemos configurada la creación del bucket, que podemos probar con el comando terraform apply (después podemos destruirlo con terraform destroy).
Configurar la Lambda
Para configurar la función Lambda, debemos especificar diferentes recursos: por un lado, el recurso de Lambda en sí, con el código que va a ejecutar al ser llamada y un rol y, por otro lado, el recurso que será el que desencadene la función de la Lambda cuando ocurra un evento concreto.
Como debemos indicar al recurso Lambda el proyecto que va a ejecutar, esto es lo primero que vamos a añadir. En este caso estamos haciendo un programa con Java que coge imágenes de S3, las comprime y las vuelve a subir a S3. Amazon nos ofrece ejemplos de código, así que vamos a usarlos. El ejemplo que vamos a utilizar se encuentra en la siguiente carpeta del repositorio de ejemplos, https://github.com/awsdocs/aws-lambda-developer-guide/tree/master/sample-apps/s3-java. También podemos coger el ejemplo “java-blank” para desarrollar nuestra propia lógica o bien podemos utilizar otros lenguajes de programación de los que soporta Lambda.
Descargamos el proyecto entero en nuestro ordenador y copiamos el ejemplo “s3-java” en nuestro directorio de trabajo, junto a los ficheros de Terraform.
Ahora tenemos que abrir el proyecto Java (se puede utilizar Maven o Gradle, en este caso voy a usar Maven). Una vez lo hayamos abierto y se hayan descargado las dependencias, podemos abrir el único fichero con código, Handler.java (src > main > java > example > Handler.java). Esta clase implementa RequestHandler con S3Event y tiene la función handleRequest. Si analizamos el código, vemos que:
- Obtiene el nombre del bucket y el nombre del objeto a procesar.
- Crea el key de destino (la ruta del objeto).
- Comprueba que la imagen tiene formato JPG o PNG (solo procesa este tipo de imágenes).
- Descarga la imagen de S3.
- Cambia el tamaño de la imagen.
- La sube a S3.
Vemos que este ejemplo hace todo lo que necesitamos, pero debemos cambiar algunos parámetros para ajustarlo a nuestro ejemplo. Concretamente, debemos cambiar la clave de destino, es decir, dónde se va a almacenar la imagen formateada. En el ejemplo de Amazon, si la imagen está en images/image.jpg, se guardaría procesada en resized-images/image.jpg. En nuestro proyecto, queremos coger las imágenes del directorio original-images y almacenar las procesadas en images, así que debemos cambiar el comportamiento. El único cambio que debemos hacer es el siguiente. Debemos cambiar
String dstKey = "resized-" + srcKey;
por
String dstKey = srcKey.replace("original-", "");
De esta forma, quitamos el String “original-” de la ruta original. Además vamos a hacer otro cambio en el código. Vemos que al inicio se establecen dos constantes, MAX_WIDTH y MAX_HEIGHT, a los que vamos a cambiar los valores para que cojan el valor de variables que definamos junto a la Lambda, así podremos cambiar estos valores sin tener que recompilar el proyecto. Para eso, cambiamos la declaración de las constantes por:
private static final float MAX_WIDTH = Float.parseFloat(System.getenv("ENV_MAX_WIDTH")); private static final float MAX_HEIGHT = Float.parseFloat(System.getenv("ENV_MAX_HEIGHT"));
Ahora no debemos olvidar crear estas variables de entorno a la hora de crear la Lambda. El último paso con el proyecto de Java es compilarlo para crear el .jar que vamos a subir. Lo hacemos ejecutando el comando mvn package -DskipTests. Esto hará que se salten los tests, ya que si intentamos pasarlos nos va a salir un error. Esto se debe a que los tests utilizan un bucket de S3 que tendríamos que crear y especificar. Al compilar el proyecto, se debería crear el ejecutable en la carpeta target.
El siguiente paso es configurar el recurso de la Lambda. Para eso, creamos un fichero Lambda_config.tf (volviendo a nuestro directorio de trabajo) y creamos primero la política para la Lambda, con el acceso a S3 y el rol:
resource "aws_iam_role_policy" "policy_for_lambda" { name = "policy_for_lambda" role = aws_iam_role.role_for_lambda.id policy = <<-EOF { "Version": "2012-10-17", "Statement": [ { "Effect": "Allow", "Action": ["s3:ListBucket"], "Resource": ["arn:aws:s3:::${var.bucket_name}"] }, { "Effect": "Allow", "Action": [ "s3:PutObject", "s3:GetObject", "s3:DeleteObject" ], "Resource": ["arn:aws:s3:::${var.bucket_name}/*"] } ] } EOF } resource "aws_iam_role" "role_for_lambda" { name = "role_for_lambda" assume_role_policy = <<EOF { "Version": "2012-10-17", "Statement": [ { "Action": "sts:AssumeRole", "Principal": { "Service": "lambda.amazonaws.com" }, "Effect": "Allow", "Sid": "" } ] } EOF }
Seguido vamos a crear el recurso de la función Lambda en el mismo fichero:
resource "aws_lambda_function" "image_resizer_lambda" { function_name = "image_resizer_lambda" role = aws_iam_role.role_for_lambda.arn handler = "example.Handler::handleRequest" filename = "s3-java/target/s3-java-1.0-SNAPSHOT.jar" timeout = 60 runtime = "java11" memory_size = 1024 environment { variables = { ENV_MAX_WIDTH = "200", ENV_MAX_HEIGHT = "200" } } }
Como vemos, tenemos que darle un nombre a la función, asociar el rol que hemos creado y después definir cómo se va a ejecutar la función al ser llamada. Tenemos que poner el handler (cambia dependiendo del proyecto), el filename con el nombre o ruta del jar que vayamos a subir, el timeout, el runtime (en este caso es java11 pero podría ser otro de los que ofrece Lambda) y las variables de entorno que tenemos que crear. También especificamos el memory_size, para que no tenga problemas al hacer el procesado de la imagen. Por defecto el valor es de 128MB, lo que nos puede dar un error de memoria. Esto también lo podríamos solucionar llamando a una API externa que se encargue de hacer el procesado de la imagen, como por ejemplo llamando a un programa en una instancia de EC2.
Si ahora ejecutamos el comando terraform apply, tendríamos nuestro bucket S3 y la función Lambda, pero sin desencadenador, por lo que nunca se ejecutaría. Para añadir el desencadenador, añadimos los siguientes recursos al fichero Lambda_config.tf:
resource "aws_lambda_permission" "allow_bucket" { statement_id = "AllowExecutionFromS3Bucket" action = "lambda:InvokeFunction" function_name = aws_lambda_function.image_resizer_lambda.arn principal = "s3.amazonaws.com" source_arn = aws_s3_bucket.images_bucket.arn } resource "aws_s3_bucket_notification" "bucket_notification" { bucket = aws_s3_bucket.images_bucket.id lambda_function { lambda_function_arn = aws_lambda_function.image_resizer_lambda.arn events = ["s3:ObjectCreated:*"] filter_prefix = "original-images/" } depends_on = [aws_lambda_permission.allow_bucket] }
Con esto, añadimos primero el permiso para que S3 envíe la notificación a Lambda y después especificamos cómo queremos que ocurra esa notificación. En este caso, se va a llamar cuando se cree un objeto en el bucket de imágenes, únicamente en la ruta original-images. También podríamos añadir un sufijo, añadiendo filter_suffix con, por ejemplo, el valor “.jpg” o “.png”.
Si en este punto ejecutamos el comando terraform apply, ya tendríamos la función con el desencadenador, así que podríamos probar a subir una imagen en la carpeta del bucket y ver cómo aparece con el nuevo tamaño.
Además de esto, adicionalmente podemos añadir un recurso en CloudWatch para ver los logs de nuestra Lambda, algo recomendado por si nuestra función no estuviera funcionando. Lo podemos añadir con lo siguiente:
resource "aws_cloudwatch_log_group" "log_group" { name = "/aws/lambda/${aws_lambda_function.image_resizer_lambda.function_name}" retention_in_days = 1 } resource "aws_iam_policy" "lambda_logging" { name = "lambda_logging" path = "/" description = "IAM policy for logging from a lambda" policy = <<EOF { "Version": "2012-10-17", "Statement": [ { "Action": [ "logs:CreateLogGroup", "logs:CreateLogStream", "logs:PutLogEvents" ], "Resource": "arn:aws:logs:*:*:*", "Effect": "Allow" } ] } EOF } resource "aws_iam_role_policy_attachment" "lambda_logs" { role = aws_iam_role.role_for_lambda.name policy_arn = aws_iam_policy.lambda_logging.arn }
Con esto, si vamos a la consola de CloudWatch, veremos que se crean unos logs cada vez que se ejecuta la Lambda, es decir, cada vez que se procesa una imagen.
Recursos
- Repositorio con todo el código: https://github.com/julenminer/aws_terraform_micronaut
- S3 en Terraform: https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/s3_bucket
- Lambda en Terraform: https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/lambda_function