1. Introdução
Um recurso ocioso continua gerando custo sem entregar valor, por exemplo, o disco de uma instância já excluída, o IP elástico que foi provisionado para associar a alguma instância, mas que não está em uso e ninguém desassociou, o Load Balancer de um ambiente de teste encerrado, etc. Recursos ociosos em excesso geram um alto custo na cloud.
Este artigo mostra como criar 4 funções Lambda em Python, uma para cada tipo de recurso ocioso, que enviam um relatório semanal para um canal do Slack. As funções apenas reportam, sem excluir nada, pois a decisão do que apagar fica a critério do time.

Outros recursos que também podem ser analisados:
- Load Balancers sem targets registrados;
- NAT Gateways sem tráfego;
- Log groups do CloudWatch sem política de retenção;
- Volumes gp2 que podem ser migrados para gp3, que é cerca de 20% mais barato;
- Instâncias RDS paradas ou sem conexões;
- Entre outros.
2. Arquitetura da solução
A solução utiliza o EventBridge Scheduler para disparar cada função no horário definido. Cada função consulta as APIs da AWS apenas com permissões de leitura e, se encontrar recursos ociosos, envia uma mensagem para o webhook do Slack. Manter uma função por recurso oferece algumas vantagens, por exemplo, cada uma pode ter sua própria frequência de execução, uma falha não afeta as demais e fica simples ativar somente as verificações que fazem sentido para cada conta.
Abaixo, tabela com as funções:

OBS: a variável “SLACK_WEBHOOK_URL” será inserida em todas as funções.
3. Implementação
3.1 Criação do canal e webhook no Slack
O webhook é uma URL que recebe mensagens via HTTP POST e as publica em um canal específico.
1.Criar o canal que receberá os alertas, por exemplo “finops-alerts”:
2.Acessar api.slack.com/apps, clicar em “Create New App”, “Blank app”, informar um nome (ex: finops-alerts) e selecionar o workspace:
3.No menu lateral, acessar “Incoming Webhooks” e clicar em “Activate Incoming Webhooks”:

4.Em seguida, clicar em “Add New Webhook”, selecionar o canal “finops-alerts” e autorizar:

5.Copiar a URL gerada, que começa com https://hooks.slack.com/services/xxx:

OBS: a URL completa do webhook não deve ser exposta, pois qualquer pessoa com acesso a ela consegue publicar no canal.
--
3.2 Criação da policy e role IAM das funções Lambda
As 4 funções Lambda compartilham uma única role com permissões somente de leitura. Em contas com requisitos mais rígidos de menor privilégio, é possível criar uma role por função, cada uma apenas com as ações que aquela função usa, mas neste artigo, será utilizada a role “finops-idle-resources-role” para as 4.
1.Para criar a policy, será necessário acessar o console da AWS e no serviço IAM, clicar em “Policies”, “Create policy”, abrir a aba JSON e colar a política abaixo:
{ "Version": "2012-10-17", "Statement": [ { "Sid": "FinOpsIdleResourcesReadOnly", "Effect": "Allow", "Action": [ "ec2:DescribeAddresses", "ec2:DescribeVolumes", "ec2:DescribeSnapshots", "ec2:DescribeImages", "ec2:DescribeInstances" ], "Resource": "*" } ] } Salvar a política com o nome “finops-idle-resources-policy”:
2.Para criar a role, será necessário permanecer no IAM, acessar a aba “Roles”, clicar em “Create role”, selecionar as opções “AWS service”, “Lambda” e prosseguir:
3.Em seguida, selecionar as políticas “AWSLambdaBasicExecutionRole”, “finops-idle-resources-policy” , definir o nome da role (ex: finops-idle-resources-role) e clicar em “Create role”:
--
3.3 Função Lambda 1: IPs elásticos ociosos
O processo é o mesmo para as 4 funções, muda apenas o nome, o código e as variáveis opcionais.
1.Para criar a função, será necessário acessar o serviço Lambda e clicar em “Create Event Function”
2.Em seguida, definir o nome “finops-idle-elastic-ips”, linguagem de programação Python, selecionar a arquitetura ARM64 (custa menos que a que a x86_64), selecionar a role criada anteriormente “finops-idle-resources-role” e clicar em “Create Event Function”:

3.Na aba “Code”, substituir o conteúdo de “lambda_function.py” pelo código da função abaixo e clicar em “Deploy”:
import json import os import urllib.request import boto3 SLACK_WEBHOOK_URL = os.environ["SLACK_WEBHOOK_URL"] IGNORE_TAG = os.environ.get("IGNORE_TAG", "finops-ignore") MAX_ITEMS = 20 ec2 = boto3.client("ec2") def is_ignored(tags): return any(t.get("Key") == IGNORE_TAG and t.get("Value", "").lower() == "true" for t in tags or []) def get_name(tags): return next((t["Value"] for t in tags or [] if t.get("Key") == "Name"), "no name") def find_idle_elastic_ips(): addresses = [a for a in ec2.describe_addresses()["Addresses"] if not is_ignored(a.get("Tags"))] findings = [ f"`{a['PublicIp']}` ({get_name(a.get('Tags'))}): not associated" for a in addresses if "AssociationId" not in a ] instance_ids = [a["InstanceId"] for a in addresses if a.get("InstanceId")] if instance_ids: response = ec2.describe_instances( InstanceIds=instance_ids, Filters=[{"Name": "instance-state-name", "Values": ["stopped"]}], ) stopped = {i["InstanceId"] for r in response["Reservations"] for i in r["Instances"]} findings += [ f"`{a['PublicIp']}` ({get_name(a.get('Tags'))}): attached to stopped instance `{a['InstanceId']}`" for a in addresses if a.get("InstanceId") in stopped ] return findings def notify_slack(title, findings, context): account_id = context.invoked_function_arn.split(":")[4] region = os.environ["AWS_REGION"] lines = "\n".join(f"• {item}" for item in findings[:MAX_ITEMS]) if len(findings) > MAX_ITEMS: lines += f"\n_...and {len(findings) - MAX_ITEMS} more_" payload = { "text": f"{title}: {len(findings)} found", "blocks": [ {"type": "header", "text": {"type": "plain_text", "text": f"{title} ({len(findings)})"}}, {"type": "context", "elements": [{"type": "mrkdwn", "text": f"Account `{account_id}` | Region `{region}`"}]}, {"type": "section", "text": {"type": "mrkdwn", "text": lines[:2900]}}, ], } request = urllib.request.Request( SLACK_WEBHOOK_URL, data=json.dumps(payload).encode("utf-8"), headers={"Content-Type": "application/json"}, ) with urllib.request.urlopen(request, timeout=10) as response: return response.status def lambda_handler(event, context): findings = find_idle_elastic_ips() print(json.dumps({"count": len(findings), "findings": findings})) if findings: notify_slack("Idle Elastic IPs", findings, context) return {"count": len(findings)} 4.Em seguida, clicar em “Configuration”, “General configuration” “Edit” e definir “Timeout” de 2 minutos e “Memory” de 256 MB. O timeout padrão de 3 segundos não é suficiente em contas com muitos recursos:
As configurações foram atualizadas com sucesso:

5.O próximo passo é configurar as variáveis de ambiente. Para isso, será necessário clicar em “Configuration”, “Environment variables”, “Edit” e adicionar “SLACK_WEBHOOK_URL” com a URL gerada no tópico 3.1 (https://hooks.slack.com/services/xxx) e, se necessário, as variáveis opcionais da função:


OBS: em ambientes corporativos, o webhook pode ser guardado em uma ferramenta de gerenciamento de segredos, como o AWS Secrets Manager ou outra solução, em vez de uma variável de ambiente, pois as variáveis ficam visíveis para qualquer pessoa com acesso de leitura à função no console. Essa mudança exige um pequeno ajuste no código e não será abordada aqui.
6.Em seguida, podemos testar. Para garantir que a função encontre algo, será necessário criar um IP elástico (ex: eip-test), em “EC2/ Elastic IPs”, “Allocate Elastic IP address” e deixá-lo sem associação com instâncias:
7.Depois, será necessário clicar na aba “Test”, definir o nome do evento (ex: test-01), substituir o conteúdo do campo “Event JSON” por “{}” e clicar em “Test”. O retorno mostra a quantidade de recursos encontrados (ex: {“count”: 2}), e a mensagem chega ao canal do Slack, se o resultado for maior que zero.
Foram localizados 2 IPs ociosos, sendo um sem associação e o outro associado a uma instância parada:
O resultado também foi disparado no canal “finops-alerts”, no Slack:

OBS: o IP público automático atribuído a uma instância é dinâmico e pode mudar quando a instância é parada e iniciada novamente. Já o IP elástico é fixo e permanece reservado à conta até ser liberado. Quando não está associado a uma instância em execução ou a outro recurso, o IP pode gerar cobranças. Por isso, a função verifica os IPs ociosos, não associados a recursos, identificando possíveis oportunidades de redução de custos.
--
3.4 Função Lambda 2: volumes EBS sem anexo
Um disco no estado “available” não está anexado a nenhuma instância, mas continua sendo cobrado por GB provisionado. Isso acontece quando as instâncias são excluídas sem a opção “Delete on termination”.
Nome da função: finops-unattached-ebs-volumes
import json import os import urllib.request from datetime import datetime, timezone import boto3 SLACK_WEBHOOK_URL = os.environ["SLACK_WEBHOOK_URL"] IGNORE_TAG = os.environ.get("IGNORE_TAG", "finops-ignore") MAX_ITEMS = 20 ec2 = boto3.client("ec2") def is_ignored(tags): return any(t.get("Key") == IGNORE_TAG and t.get("Value", "").lower() == "true" for t in tags or []) def get_name(tags): return next((t["Value"] for t in tags or [] if t.get("Key") == "Name"), "no name") def find_unattached_volumes(): findings = [] now = datetime.now(timezone.utc) pages = ec2.get_paginator("describe_volumes").paginate( Filters=[{"Name": "status"Source:https://dev.to/biancaszimanski/finops-na-aws-automacao-para-identificar-recursos-ociosos-com-alertas-via-slack-59jl Related posts
Three dates that catch a hard-coded time-zone bug in a session clock
Laravel N+1 Query Problem: How to Find and Fix It
LongCat-2.5 at $0.30/$1.20: Thinking Locks Temperature, 1M Context, and Vendor Benches Between Opus 4.8 and 5
Shipped: two packages, a registry listing, and the first seals
AI Agent Memory Needs Garbage Collection, Not Just Storage

























