Inek

Implementar serverless cronjobs con AWS lambda

¡Tu mensaje fue recibido! Una vez que sea aprobado, estará visible para los demás visitantes.

Cerrar

23 Apr 2023

¿Quién no ha tenido que configurar cronjobs en algún servidor? En algunos casos, incluso arriesgando la exposición de credenciales en texto plano. Los cronjobs son una herramienta muy útil para programar la ejecución de tareas a intervalos de tiempo determinados. En este post, se analiza una alternativa a los cronjobs tradicionales mediante el uso de funciones lambda de AWS.

Ejecutar un endpoint cada cierto tiempo para generar algún reporte o comprobar la disponibilidad de algún servicio, iniciar una limpieza de archivos en un disco local, o remoto usando un comando SSH, son algunos de los muchos casos en los que usualmente configuraríamos un cronjob. Eventualmente, si tenemos muchos cronjobs, comienza a hacerse difícil el hacer seguimiento y mantenerlos actualizados: ¿en qué servidor configuramos X cronjob?, ¿qué pasa si el servidor donde ejecutamos el cronjob se cae?, ¿cómo nos aseguramos que el cron se ejecute con credenciales protegidas?. Los cronjobs son una excelente opción para tareas recurrentes, sin embargo, a medida que crecen en cantidad, su gestión se dificulta.

En este post vamos a ver cómo podemos, usando funciones lambda de AWS, montar cronjobs serverless. Si bien comparando un cronjob con una implementación en AWS, la primera opción resulta más sencilla, veremos que la segunda opción es más fácil de monitorear y mantener en el mediano y largo plazo.

Ejecutar un endpoint con una api key en intervalos de tiempo

Supongamos que tenemos una aplicación, que todas las noches, a la 1am, necesita iniciar un procedimiento para crear un reporte. A su vez, para evitar que el endpoint sea invocado por cualquier persona que conozca la url, la llamada requiere de una api key. Si invocásemos esta api con un curl desde un cronjob, bien podría ser así:

0 1 * * * curl -X POST https://some.domain/api/reports \
-H X-api-key: some-secret-api-key > /var/log/cron-"`date +\%Y-\%m-\%d__\%H-\%M-\%S`".log

Se observa que el cronjob escribe además un archivo de log con la salida de la ejecución del comando.

No está de más decir, que este cronjob deberá estar en un servidor que esté online, al que también tendremos que mantener actualizado, protegerlo, hacerle copias de seguridad, etc. Este es un punto importante a favor de las funciones lambdas: son serverless. Esto significa que se definen y ejecutan en la nube de AWS, donde no debemos preocuparnos por pagar ni mantener un servidor. Simplemente pagaremos por su ejecución, definida en términos de cuánto dura cada ejecución y la cantidad de ejecuciones. Para más detalles, consulta la página de precios de lambda de AWS.

Versión serverless de nuestro cronjob

Para implementar la generación del reporte de nuestro ejemplo usando una función lambda vamos a necesitar los siguientes recursos:

Para la creación de la mayoría de los recursos en AWS usaré Terraform, que nos permite crear componentes de AWS usando código que podemos almacenar en un repositoriod de código como Github. Si bien podemos crear los componentes directamente en la consola de AWS con unos cuantos clicks, gestionando infraestructura como código (IaC) nos posibilita agrupar código en módulos, reutilizarlo, destruirlo con facilidad, etc.

Api key en parameter store

Si bien el parameter store podemos crearlo con Terraform usando el recurso aws_ssm_parameter, el valor de dicho recurso queda almacenado en el state de Terraform. Es por eso, que es preferible crear el parameter store manualmente, desde la consola o con el CLI de AWS:

~$ aws ssm put-parameter \
  --name /someapi/apikey \
  --value somesecret \
  --type SecureString \
  --region us-east-1 \
  --profile myprofile

Con eso ya tenemos nuestro secreto. Podemos verificarlo en el parameter store. Reemplaza la región us-east-1 con la que sea que estés usando en AWS. Desde el link que indico, también se pueden crear parámetros, en caso de prefieras la consola por sobre el CLI de AWS.

Configurar grupo de registros en Cloudwatch

Para que nuestra función pueda escribir logs, necesitaremos un log group en Cloudwatch:

resource "aws_cloudwatch_log_group" "logs" {
  name = "/aws/lambda/generate-report"
  retention_in_days = 7
}

Un grupo de registros es un componente de AWS que permite agrupar logs. Si tenemos múltiples funciones lambda, los logs de cada unad de ellas estaría en su propio grupo. Encontraremos los grupos de registros, en Cloudwatch:

Log groups

Configurar la regla de evento en Cloudwatch

Una regla de evento es lo que nos permitirá configurar una expresión similar a la de los cronjobs para indicar cuándo se debe ejecutar nuestra función lambda. A continuación definimos un recurso aws_cloudwatch_event_rule que representa la regla de evento y un recurso aws_lambda_permission donde indicamos que nuestra regla de evento tiene permiso para invocar nuestra función lambda.

resource "aws_cloudwatch_event_rule" "rule" {
  name = "generate-report-rule"
  description = "Rule for generate-report"
  schedule_expression = "cron(0/5 * * * ? *)"
}

resource "aws_lambda_permission" "allow_cloudwatch_to_trigger_lambda" {
    action = "lambda:InvokeFunction"
    function_name = "generate-report"
    principal = "events.amazonaws.com"
    source_arn = aws_cloudwatch_event_rule.rule.arn
}

La expresión cron(0/5 * * * ? *) se intrepreta de la siguiente manera:

Para leer más sobre las expresiones de las reglas de eventos, consulta la documentación de AWS.

Preparar permisos de ejecución para la función lambda

Como nuestro requerimiento es que nuestra función lambda utilice una api key que hemos guardado en el parameter store, necesitamos asignar un permiso a la función para permitirle dicho acceso. Además, vamos a escribir logs, por lo que la función también va a requerir permisos para interactuar con Cloudwatch. Para permitir la interacción entre nuestra función y el paramter store y Cloudwatch, vamos a definir una política IAM:

resource "aws_iam_policy" "policy_for_lambda" {
  name = "generate-report-policy"

  policy = jsonencode({
    "Version": "2012-10-17", 
    "Statement": [
      {
        "Effect": "Allow",
        "Action": [
          "logs:CreateLogStream",
          "logs:PutLogEvents"
        ],
        "Resource": [
          "${aws_cloudwatch_log_group.logs.arn}:*"
        ]
      },
      {
        "Effect": "Allow",
        "Action": "ssm:GetParameter",
        "Resource": [
          "arn:aws:ssm:us-east-1:${data.aws_caller_identity.current.account_id}:parameter/someapi/apikey"
        ]
      }
    ]
  })
}

La política IAM se vincula a nuestra función a través de un rol IAM:

resource "aws_iam_role" "role_for_lambda" {
  name = "generate-report-role"

  assume_role_policy = jsonencode({
    "Version": "2012-10-17",
    "Statement": [
      {
        "Effect": "Allow",
        "Principal": {
          "Service": "lambda.amazonaws.com"
        },
        "Action": "sts:AssumeRole"
      }
    ]
  })
}

resource "aws_iam_role_policy_attachment" "attachment" {
  role       = aws_iam_role.role_for_lambda.name
  policy_arn = aws_iam_policy.policy_for_lambda.arn
}

Cuando definamos la función le asignaremos el rol que hemos creado en esta sección.

Función lambda

Ya tenemos el parameter store con la api key. Hemos creado el log group y la regla de evento en Cloudwatch. También tenemos preparados los permisos para que nuestra función lambda pueda interactuar con estos recursos. Lo que nos queda es crear la función que ejecuta un endpoint y escribe el resultado en el log. Vamos a crear un archivo llamado generate-report.js con el siguiente código:

const AWS = require("aws-sdk");
const ssm = new AWS.SSM();
const https = require('https');

exports.handler = async function(event, context, callback) {
  // Obtenemos la api key del parameter store
  const apiKey = await ssm.getParameter({
    Name: '/someapi/apikey',
    WithDecryption: true,
  }).promise();

  const options = {
    host: 'some.domain',
    port: 443,
    path: '/api/reports',
    method: 'POST',
    headers: {
      'X-api-key': apiKey.Parameter.Value
    }
  };

  // Efectuamos la llamada y escribimos la respuesta en el log
  return new Promise((resolve, reject) => {
    const req = https.request(options, (res) => {
      let dataString = '';

      res.on('data', chunk => {
        dataString += chunk;
      });

      res.on('end', () => {
        console.log(dataString);
        resolve(res.statusCode);
      });
    }).on("error", (e) => {
      reject(new Error(e));
    });

    req.end();
  });
};

Para poner este código en AWS, usaremos los recursos de Terraform archive_file para leer nuestro archivo y aws_lambda_function para la creación de la función lambda:

data "archive_file" "generate_report" {
  type = "zip"
  source_file = "generate-report.js"
  output_path = "generate-report.zip"
}

resource "aws_lambda_function" "generate_report" {
  filename = data.archive_file.generate_report.output_path
  function_name = "generate-report"
  role = aws_iam_role.role_for_lambda.arn
  handler = "generate-report.handler"
  source_code_hash = data.archive_file.generate_report.output_base64sha256
  runtime = "nodejs14.x"
  timeout = 25
}

En la consola de AWS deberíamos ver la función lambda ya creada. En unos 5 minutos ya deberíamos empezar a ver logs escritos en el grupo de registros:

Function logs

Código fuente

He dejado el código de este post en un repositorio de Github. He modificado el código para modularizarlo, ya que planeo utilizarlo en otros posts, con diferentes funciones lambda, reaprovechando la creación de elementos comunes (grupo de registros, permisos, etc).

Limpieza de recursos

Por último, y no menos importante: debido a que AWS nos cobra cada vez que se ejecuta nuestra función, si has implementado la función lambda del post solo para practicar, te recomiendo que la elimines, para que no quede ejecutándose ad eternum. Si has utilizado el código del repositorio, bastará con que ejecutes terraform destroy.

Si creaste la api key en el parameter store como indiqué al principio del post, para eliminarlo, tendrás que ejecutar:

~$ aws ssm delete-parameter \
  --name /someapi/apikey \
  --region us-east-1 \
  --profile myprofile

Con eso, ya queda nuestra cuenta AWS tal como estaba antes de empezar el post.

¿Qué te pareció el post?

No hay comentarios.