O Plantonista que nunca tira férias

O Plantonista que nunca tira férias


Véspera de Natal, 23h47. O bucket de produção começa a receber um volume atípico de requisições. O alarme no OCI Monitoring dispara e a notificação chega por email.

Ninguém lê.

O dev sênior está com a família se preparando para a ceia. O time de infra está de folga. O email fica numa alias que apenas três pessoas têm acesso e nenhum dos três está preocupado em acessá-lo. O volume de requisições continua crescendo. O custo de egress sobe. Às 2h da manhã, um usuário IAM que ninguém reconhece é criado na tenancy. Às 7h, o site começa a retornar 5xx.

Quando o time volta no dia 26, há três incidentes na fila sem tratamento. Todos com notificação registrada, mas nenhuma reposta.

No artigo anterior, montamos um pipeline completo de detecção: Monitoring para métricas em tempo real, Events para ações na API da OCI e Notifications como canal de entrega. Tudo funcionando perfeitamente, mas agora o problema é outro porque não importa mais saber que algo está acontecendo: o importante é ter alguém disponível para agir.

 

A peça que faltava

O pipeline de notificações assumo o básico: que alguém vai ler o alerta e tratar a ocorrência. Na prática, essa pessoa precisa estar acordada, disponível, e com acesso ao terminal. Ou seja, uma equipe 24/7 sempre pronta para agir. 

O OCI Function resolve essa lacuna. Ele não espera a ação de ninguém. Quando o alarme dispara e a notificação é publicada no tópico, uma Function é invocada automaticamente e executa a ações de contorno de forma imediata, antes que qualquer humano precise ser acordado.

O que antes era "notificar para reagir" agora é "notificar e agir".

O email continua chegando e o histórico humano continua existindo, mas a grande diferença é que quando o dev sênior abrir seu email no dia seguinte, o bucket já estará bloqueado, o usuário suspeito já foi isolado e a instância já recebeu mais recursos. O email de notificação virou relatório do que foi feito e não um pedido de socorro.

 

O que é OCI Functions

OCI Functions é um serviço serverless baseado no projeto open-source Fn Project. Você escreve a lógica, define os gatilhos e a Oracle cuida do provisionamento, escala e disponibilidade. A cobrança é por invocação e por tempo de execução, sem taxa de idle. Uma Function que não é chamada não gera custo.

Os runtimes disponíveis incluem Python, Java, Node.js, Go e Ruby e o código roda em um container Docker gerenciado pelo serviço.

A autenticação com a OCI é feita via Resource Principals, que é o equivalente do Instance Principals, mas voltado para Functions. Sem credencial em disco, sem token no código.

 

Configuração inicial

Pré-requisitos

# Instalar o fn CLI
curl -LSs https://raw.githubusercontent.com/fnproject/cli/master/install | sh

fn version

 

Contexto do fn CLI

O fn CLI trabalha com contextos para separar os ambientes: local, OCI e outros provedores. Criar um contexto OCI diz ao fn onde fazer deploy, para qual endpoint do Functions enviar as chamadas e em qual registry do OCIR o push das imagens deve ser feito. 

fn create context oci --provider oracle
fn use context oci

fn update context oracle.compartment-id $COMPARTMENT_ID
fn update context api-url https://functions.us-chicago-1.oci.oraclecloud.com
fn update context registry $REGION_KEY.ocir.io/$TENANCY_NAMESPACE/plantao-automatico

# Login no OCI Container Registry
docker login $REGION_KEY.ocir.io \
  -u "$TENANCY_NAMESPACE/$OCI_USERNAME" \
  -p "$AUTH_TOKEN"

O REGION_KEY é o prefixo da região no OCIR: ord para Chicago, gru para São Paulo.

 

Application

No OCI Functions, uma Application agrupa as Functions, define a VCN onde rodam, as variáveis de ambiente compartilhadas e o nível de logging.

oci fn application create \
  --compartment-id $COMPARTMENT_ID \
  --display-name "plantao-automatico" \
  --subnet-ids "[\"$SUBNET_ID\"]"

export APP_ID=$(oci fn application list \
  --compartment-id $COMPARTMENT_ID \
  --display-name "plantao-automatico" \
  --query "data[0].id" --raw-output)

 

IAM: permissões de contenção

Uma Function pode ser invocada sem permissionamento, mas não vai conseguir fazer muita coisa dentro da OCI. O Dynamic Group a identifica pelo tipo de recurso e compartimento. A Policy autoriza as ações que cada cenário vai exigir.

oci iam dynamic-group create \
  --compartment-id $TENANCY_ID \
  --name "functions-plantao" \
  --matching-rule "ALL {resource.type = 'fnfunc', resource.compartment.id = '$COMPARTMENT_ID'}"

oci iam policy create \
  --compartment-id $TENANCY_ID \
  --name "policy-plantao-automatico" \
  --description "Permissoes de contencao para as Functions de resposta automatica" \
  --statements "[
    \"Allow dynamic-group functions-plantao to manage buckets in compartment id $COMPARTMENT_ID\",
    \"Allow dynamic-group functions-plantao to manage instances in compartment id $COMPARTMENT_ID\",
    \"Allow dynamic-group functions-plantao to manage users in tenancy\",
    \"Allow dynamic-group functions-plantao to manage groups in tenancy\",
    \"Allow dynamic-group functions-plantao to read audit-events in tenancy\"
  ]"

 

Analisando Cenários

No artigo Preste Atenção em Suas Notificações, configuramos um pipeline de detecção para três situações reais. Agora voltamos aos mesmos casos com a mesma infraestrutura, mas com um componente novo no final da cadeia: uma Function que age antes que alguém precise abrir o terminal.

 

Cenário 1: bucket com volume atípico de acessos

A situação

Um bucket no Object Storage armazena artefatos de build e assets estáticos usados pela aplicação em produção. No ambiente de testes, o mesmo bucket é acessível para o time. Funciona bem no dia a dia.

Numa tarde de quarta-feira, o volume de requisições ao bucket vai de algumas centenas por hora para dezenas de milhares. Ninguém percebe. A aplicação não quebra. O custo de egress começa a subir. Só no fim do mês alguém estranha a fatura.

 

A contenção automática

A primeira ação recomendada é verificar se o bucket está em modo público e bloqueá-lo antes de qualquer outra investigação. A Function executa exatamente isso, sozinha,  no momento em que o alarme de GetRequests dispara.

A lógica tem uma ressalva antes de agir: a Function verifica o estado atual do bucket antes de tentar modificá-lo. Se já estiver com acesso bloqueado por outro motivo, ela apenas registra  e não faz nada. Isso evita que o log fique poluído com ações redundantes ou que a Function entre em conflito com intervenções manuais em andamento.

O que ela não será capaz de fazer é a análise do trágefo. Para isso, é possível checar os IPs via Audit, verificar se havia URLs pré-autenticadas sem expiração e decidir se o bucket precisava continuar inacessível: tudo isso ainda é trabalho humano. O log da Function é o ponto de partida dessa investigação.

O código

O projeto usa o runtime Python 3.11. Além do SDK do OCI, a única dependência é o fdk, o Fn Development Kit que fornece o objeto response e o contexto de execução da Function.

fn init --runtime python3.11 bloquear-bucket
cd bloquear-bucket

A autenticação usa Resource Principals: o signer é obtido em tempo de execução, sem nenhum arquivo de configuração no container. O nome do bucket e o namespace chegam pelas variáveis de ambiente configuradas no deploy, sem ficar hardcoded no código.

# requirements.txt
fdk>=0.1.59
oci>=2.100.0
# func.py
import io
import json
import oci
import logging
from fdk import response

log = logging.getLogger(__name__)

def handler(ctx, data: io.BytesIO = None):
    try:
        config_vars = dict(ctx.Config())
        bucket_name = config_vars.get('BUCKET_NAME')
        namespace   = config_vars.get('NAMESPACE')

        signer = oci.auth.signers.get_resource_principals_signer()
        object_storage = oci.object_storage.ObjectStorageClient(
            config={}, signer=signer
        )

        bucket = object_storage.get_bucket(namespace, bucket_name).data
        if bucket.public_access_type == 'NoPublicAccess':
            log.info("Bucket %s ja esta bloqueado", bucket_name)
            return response.Response(
                ctx,
                response_data=json.dumps({'status': 'ja bloqueado'}),
                status_code=200
            )

        object_storage.update_bucket(
            namespace_name=namespace,
            bucket_name=bucket_name,
            update_bucket_details=oci.object_storage.models.UpdateBucketDetails(
                public_access_type='NoPublicAccess'
            )
        )

        log.info("Bucket %s bloqueado por volume anomalo", bucket_name)
        return response.Response(
            ctx,
            response_data=json.dumps({
                'status': 'bucket bloqueado',
                'bucket': bucket_name
            }),
            status_code=200
        )

    except Exception as ex:
        log.error("Erro: %s", str(ex))
        return response.Response(
            ctx,
            response_data=json.dumps({'erro': str(ex)}),
            status_code=500
        )

 

Deploy

Antes do deploy, é preciso confirmar que o login no OCIR foi feito na etapa de configuração. O fn deploy faz o build e o push da imagem automaticamente, e vai falhar sem mensagem clara se a autenticação não estiver ativa.

fn deploy --app plantao-automatico

export FUNCTION_ID_BUCKET=$(oci fn function list \
  --application-id $APP_ID \
  --display-name "bloquear-bucket" \
  --query "data[0].id" --raw-output)

oci fn function update \
  --function-id $FUNCTION_ID_BUCKET \
  --config "{\"BUCKET_NAME\": \"nome-do-bucket\", \"NAMESPACE\": \"$TENANCY_NAMESPACE\"}"

Quando o alarme disparar

O bucket fica inacessível publicamente em menos de dois segundos. O log registra o nome do bucket e o estado anterior. O email de notificação chega em paralelo: o contexto do alarme e o registro feito pela Function já são suficientes para começar a investigação no dia seguinte.

 

 

Cenário 2: usuário criado num horário suspeito

A situação

São 2h47 de um sábado. Um usuário novo é criado na tenancy. Nenhuma mudança estava planejada para o final de semana. Você não tem como saber se foi um colega que precisou de acesso de emergência, um erro de automação, ou algo pior.

A contenção automática

Com o OCI Function, investigar e agir não tem uma ordem fixa. Enquanto a notificação ainda está esperando alguém acordar para ser lida, a Function já age: lê o payload do evento CreateUser, extrai o OCID do usuário recém-criado e remove todas as associações de grupo em sequência.

Um usuário sem grupo não consegue fazer virtualmente nada na OCI. Não consegue acessar recursos, não consegue criar nada ou não consegue escalar o próprio acesso. Ou seja, contenção é imediata, sem depender de ninguém abrir o console.

A Function não deleta o usuário e por um bom motivo: Deletar destrói evidências ou pode interromper um processo legítimo. Se a investigação confirmar que foi uma automação interna rodando fora do horário esperado, basta readicionar o usuários aos grupos corretos. Agora, se a ameaça for confirmada, o usuário é pemanentemente deletado com o histórico intacto para auditoria. O log da Function, com OCID, timestamp e lista de grupos removidos será o ponto de partida dessa decisão.

O código

O ponto de entrada é o campo resourceId dentro de data no payload do evento IAM. É nele que a OCI registra o OCID do recurso afetado pela ação, neste caso o usuário recém-criado. A Function verifica se esse campo existe antes de qualquer operação: se o payload chegar malformado, ela registra um aviso e retorna 200 para não travar o processamento do tópico.

cd ..
fn init --runtime python3.11 isolar-usuario
cd isolar-usuario

O loop percorre todas as associações de grupo do usuário e remove todas elas, registrando cada remoção. Se o usuário tiver sido adicionado a múltiplos grupos antes do evento chegar ao tópico, todos são removidos na mesma execução.

# requirements.txt
fdk>=0.1.59
oci>=2.100.0
# func.py
import io
import json
import oci
import logging
from fdk import response

log = logging.getLogger(__name__)

def handler(ctx, data: io.BytesIO = None):
    try:
        body = json.loads(data.getvalue())

        # O payload do evento IAM traz o OCID do usuário criado
        user_id = (
            body.get('data', {})
                .get('resourceId')
        )

        if not user_id:
            log.warning("OCID do usuario nao encontrado no payload")
            return response.Response(
                ctx,
                response_data=json.dumps({'aviso': 'usuario nao identificado'}),
                status_code=200
            )

        signer = oci.auth.signers.get_resource_principals_signer()
        identity = oci.identity.IdentityClient(config={}, signer=signer)

        groups = identity.list_user_group_memberships(
            compartment_id=dict(ctx.Config()).get('TENANCY_ID'),
            user_id=user_id
        ).data

        removidos = []
        for membership in groups:
            identity.remove_user_from_group(membership.id)
            removidos.append(membership.group_id)
            log.info("Usuario %s removido do grupo %s", user_id, membership.group_id)

        log.info("Usuario %s isolado. Grupos removidos: %d", user_id, len(removidos))
        return response.Response(
            ctx,
            response_data=json.dumps({
                'status': 'usuario isolado',
                'user_id': user_id,
                'grupos_removidos': len(removidos)
            }),
            status_code=200
        )

    except Exception as ex:
        log.error("Erro: %s", str(ex))
        return response.Response(
            ctx,
            response_data=json.dumps({'erro': str(ex)}),
            status_code=500
        )

 

Deploy

fn deploy --app plantao-automatico

export FUNCTION_ID_USUARIO=$(oci fn function list \
  --application-id $APP_ID \
  --display-name "isolar-usuario" \
  --query "data[0].id" --raw-output)

oci fn function update \
  --function-id $FUNCTION_ID_USUARIO \
  --config "{\"TENANCY_ID\": \"$TENANCY_ID\"}"

Quando o alarme disparar

O log registra cada grupo removido com o OCID do usuário e o timestamp da operação. A investigação começa a partir desses registros: quantos grupos foram removidos, quais eram e em quanto tempo a contenção aconteceu após a criação do usuário.

 

 

Cenário 3: erros 5xx seguidos de avalanche de tráfego

A situação

Um site foi para produção há duas semanas. Tráfego estável, nenhum problema. Na manhã de uma terça-feira, erros HTTP 5xx começam a subir. Minutos depois, o volume de conexões ativas no Load Balancer triplica. A interpretação natural é: ataque. Mas a situação tem outra leitura possível, e a resposta errada para cada uma pode ser catastrófica.

 

A contenção automática

Diagnosticar se a causa foi um ataque ou conteúdo viral ainda exige análise humana, mas o site não precisa esperar esse diagnóstico para ter mais recursos disponíveis. A Function escala a instância quando o alarme de tráfego dispara, mantém o site ativo e ganha tempo para que os analistas iniciem a investigação sem pressão.

Antes de agir, a Function faz uma última checagem: se a instância já estiver no shape necessário, seja por uma intervenção anterior ou talvez porque o alarme disparou duas vezes, ela apenas registra e retorna sem fazer nada.

Um detalhe que precisa estar definido antes de configurar: tudo isso só vai funcionar com Flex shapes. Shapes fixos não dão suporte a  atualização de OCPUs e memória sem reinicialização, o que tornaria a operação inviável num incidente ativo. Então antes de qualquer coisa, confirme qual shape está em uso antes de implementar essa solução.

O código

Os valores-alvo de OCPUs e memória estão definidos como constantes no topo do arquivo. Para adaptar a Function a outro contexto, basta alterar OCPUS_ALVO e MEMORIA_ALVO antes do deploy.

cd ..
fn init --runtime python3.11 escalar-instancia
cd escalar-instancia

O log registra os valores anteriores e os novos a cada execução, o que torna o histórico de escala auditável: é possível ver quantas vezes a Function foi invocada, em quais horários e quais valores de OCPUs e memória foram atribuídos em cada momento.

# requirements.txt
fdk>=0.1.59
oci>=2.100.0
# func.py
import io
import json
import oci
import logging
from fdk import response

log = logging.getLogger(__name__)

OCPUS_ALVO   = 4
MEMORIA_ALVO = 32

def handler(ctx, data: io.BytesIO = None):
    try:
        config_vars = dict(ctx.Config())
        instance_id = config_vars.get('INSTANCE_ID')

        signer  = oci.auth.signers.get_resource_principals_signer()
        compute = oci.core.ComputeClient(config={}, signer=signer)

        instance     = compute.get_instance(instance_id).data
        shape_config = instance.shape_config

        if shape_config.ocpus >= OCPUS_ALVO:
            log.info("Instancia %s ja esta no shape alvo", instance_id)
            return response.Response(
                ctx,
                response_data=json.dumps({'status': 'ja escalado'}),
                status_code=200
            )

        compute.update_instance(
            instance_id=instance_id,
            update_instance_details=oci.core.models.UpdateInstanceDetails(
                shape_config=oci.core.models.UpdateInstanceShapeConfigDetails(
                    ocpus=OCPUS_ALVO,
                    memory_in_gbs=MEMORIA_ALVO
                )
            )
        )

        log.info(
            "Instancia %s escalada: %.0f OCPUs -> %d, %.0f GB -> %d GB",
            instance_id, shape_config.ocpus, OCPUS_ALVO,
            shape_config.memory_in_gbs, MEMORIA_ALVO
        )

        return response.Response(
            ctx,
            response_data=json.dumps({
                'status': 'instancia escalada',
                'ocpus': OCPUS_ALVO,
                'memoria_gb': MEMORIA_ALVO
            }),
            status_code=200
        )

    except Exception as ex:
        log.error("Erro: %s", str(ex))
        return response.Response(
            ctx,
            response_data=json.dumps({'erro': str(ex)}),
            status_code=500
        )

 

Deploy

fn deploy --app plantao-automatico

export FUNCTION_ID_INSTANCIA=$(oci fn function list \
  --application-id $APP_ID \
  --display-name "escalar-instancia" \
  --query "data[0].id" --raw-output)

oci fn function update \
  --function-id $FUNCTION_ID_INSTANCIA \
  --config "{\"INSTANCE_ID\": \"$INSTANCE_ID\"}"

Quando o alarme disparar

O log registra os valores anteriores e os novos de OCPUs e memória, com o OCID da instância e o timestamp. Se o site continuar com 5xx após receber mais recursos, o motivo provavelmente não era capacidade, o que já ajuda a direcionar a investigação.

 

Conectando as três Functions ao pipeline

Cada Function entra como subscriber do mesmo tópico de notificações, ou seja, os responsáveis continuam sendo notificados. As Functions agem em paralelo, cada uma no seu cenário, sem depender uma da outra.

# Function do bucket
oci ons subscription create \
  --compartment-id $COMPARTMENT_ID \
  --topic-id $TOPIC_ID \
  --protocol ORACLE_FUNCTIONS \
  --subscription-endpoint $FUNCTION_ID_BUCKET

# Function do usuário suspeito
oci ons subscription create \
  --compartment-id $COMPARTMENT_ID \
  --topic-id $TOPIC_ID \
  --protocol ORACLE_FUNCTIONS \
  --subscription-endpoint $FUNCTION_ID_USUARIO

# Function de escala
oci ons subscription create \
  --compartment-id $COMPARTMENT_ID \
  --topic-id $TOPIC_ID \
  --protocol ORACLE_FUNCTIONS \
  --subscription-endpoint $FUNCTION_ID_INSTANCIA

Em nosso caso, como as três Functions estão no mesmo tópico, cada uma é invocada por qualquer notificação que chegar, não apenas pela cenário que foi descrito. O código de cada Function age sobre um recurso específico configurado nas variáveis de ambiente, então qualquer invocação extra não surte qualquer efeito. Mas para operações com um volume alto de alerta, a abordagem mais eficiente é criar um tópico para cada um dos cenários.

 

Véspera de Natal, 23h47

O alarme disparou, a notificação chegou quando a ceia de Natal estava para começar, mas não tem problema.

O bucket foi bloqueado em dois segundos. O usuário criado às 2h47 foi removido de todos os grupos antes que pudesse fazer qualquer coisa. Quando o tráfego triplicou de madrugada, a instância já estava com mais recursos.

O dev sênior abriu o email no dia 26 e leu os três registros de todas as ações, devidamente organizadas por timestamp. Mas sem o OCI Functions, ele provavelmente estaria lendo pedidos de socorro.

Uma notificação sem resposta é so ruído, mas com as Functions no pipeline, ela vira gatilho de uma contenção que não depende de ninguém estar acordado - ou trabalhando.

Leia mais

← Todos os artigos
editar