quarta-feira, 4 de novembro de 2026 Edição nº 228
Buscar
Avaliação · Infraestrutura · 20 min de leitura

Ciclo de Vida de Tasks no Google AX: Hands-On no Amazon EKS

Por Fabio Douek

Ir para seção
Explica (TLDR) como se eu fosse...
O que é isso?

Imagina que cada robô ajudante ganha seu próprio quartinho para trabalhar. Quando ele tira uma soneca, só consegue guardar as coisas de uma gaveta especial. Quando acorda, a gaveta continua cheia, mas todo o resto do quarto foi arrumado e ele começa a brincadeira de novo.

A soneca e o acordar são rápidos, menos de um segundo. Se você bater na porta, um robô dormindo acorda sozinho. E a gaveta é copiada para um armário seguro alguns minutos depois de cada soneca, então ela sobrevive mesmo se o quarto for levado embora.

O AX roda tasks de agentes em sandboxes isoladas, em infraestrutura que o operador controla. Os fatos relevantes para uma análise de risco são o que um suspend preserva (somente a pasta de workspace), onde isso fica armazenado (no node por cinco minutos, depois no bucket S3 do operador) e o que se perde quando uma máquina falha.

Uma task em execução em um worker que falhou perde tudo desde o último snapshot, e alterar o pool de workers faz falhar todas as tasks em execução. Planeje retenção, durabilidade e janelas de mudança com base nisso, e não na descrição de marketing do projeto sobre checkpointing.

Examinei cada estágio da vida de uma task sob condições controladas: criação, primeira inicialização, pausa e retomada, despertar por request, armazenamento e falha. Cada achado é reportado com tempos medidos, e os ciclos de suspend foram repetidos dentro de cada teste.

Observações principais: o suspend preserva a pasta de workspace e nada mais, o resume leva menos de um segundo, e um snapshot não é durável durante os cinco minutos após um suspend. Uma falha de worker encerra as tasks em execução. O prognóstico é bom para workloads que mantêm seu estado em disco.

É fácil ouvir "suspend e resume" e imaginar um agente pausando no meio de um pensamento e retomando exatamente de onde parou. Essa imagem é reconfortante, e não é o que o AX faz hoje, o que pode ser perturbador se você planejou em torno dela.

O que ele oferece é mais estável do que parece: os arquivos sobrevivem, o reinício é rápido e as regras são previsíveis quando você as conhece. O trabalho é ajustar as expectativas cedo, para que ninguém descubra a diferença em produção.

Imagine uma banda que consegue parar entre as músicas e voltar a tocar em menos de um segundo. O setlist e as partituras na estante sobrevivem a todas as pausas; o que estava no meio de uma frase não sobrevive, então cada reinício é uma nova contagem desde o começo da partitura.

O roadie arquiva as partituras no cofre cinco minutos depois de cada pausa. Bata na porta da sala de ensaio e a banda começa a tocar sozinha. Só não troque o palco enquanto eles estão no meio de uma música.

Os números de destaque são bons: tasks retomam em menos de um segundo, acordam por request em cerca de um décimo de segundo pelo router do Substrate, e dois nodes pequenos comportam trinta sandboxes ociosas quando você as dimensiona.

O enquadramento honesto é "reinícios rápidos com arquivos duráveis", e não "pause e retome a mente do seu agente". Esse posicionamento funciona para times cujos agentes já mantêm estado em disco, e define a expectativa certa para todos os outros.

GitHub Veja o código-fonte aqui: google-ax-lifecycle

Visão Geral

Uma task do Google AX é um comando que roda em uma sandbox gVisor, e ela passa a maior parte da vida sem estar rodando. A proposta do AX é que agentes ficam ociosos por longos períodos enquanto esperam um modelo ou um humano, então uma task é barata de criar, barata de suspender e rápida de trazer de volta. Este post, parte 2 de 4 da série, mede cada etapa dessa vida no cluster da parte 1, a instalação: dois nodes m7i.xlarge no Amazon EKS 1.37, AX a496fc1, com o Agent Substrate d3f2363 (v0.6.0) por baixo.

A primeira coisa a entender é o que suspend significa no AX. O Substrate consegue tirar snapshot da memória de uma sandbox, mas o AX pede a ele um snapshot somente de volumes de um único diretório, /workspace. O documento do runner do AX diz isso com todas as letras: uma task retomada vê “the same files but a new process tree”. Todo o resto deste post decorre disso.

São nove scripts Python, um por teste, todos acionando a CLI ax real. Todos os tempos vêm das minhas execuções em 2026-10-30, medidos a partir do client, então cada um inclui o round trip da CLI pelo seu port-forward: o que um usuário do AX realmente espera.

O ciclo de vida em um relance

ax apply Suspended não ocupa capacidade de worker Running sandbox gVisor em um worker ax resume · request pelo router ax suspend (mantém só /workspace) Failed o pod do worker sumiu worker morre ax resume: inicia do zero snapshot no node /var/lib/ate, gravado no suspend snapshot no S3 upload após 5 min cópia do node removida após 30 min
O ciclo de vida de uma task do AX conforme medido aqui. ax delete remove uma task de qualquer estado.

Configuração

Os scripts precisam do cluster e da CLI ax do post de instalação, Python 3.11 e nada mais: sem API keys, sem serviços externos. Toda task roda sleep infinity ou um comando shell curto, e todas ficam em um atespace chamado lifecycle, então a limpeza nunca toca em mais nada.

cd my2centsai-blog-samples/google-ax-lifecycle
python3 01_create.py      # and so on, through 09_cleanup.py

O common.py encapsula a CLI. Dois helpers fazem a maior parte do trabalho: um monta o manifest de uma task, o outro roda um trecho de shell dentro de uma task via ax ssh:

def task_manifest(name, command, cpu=None, memory=None):
    ...  # apiVersion: ax.io/v1alpha1, kind: Task, spec.debug: true (needed for ax ssh)

def sh(name, script):
    """Run a shell snippet inside the task over `ax ssh` and return its output."""
    p = ax("ssh", name, "--", "sh", "-c", script, check=False, quiet=True)
    return (p.stdout + p.stderr).rstrip()

Testes

Teste 1: Criação

for i in range(1, 6):
    timed(f"ax apply lc-create-{i}", create, f"lc-create-{i}")
ax("get", "tasks")
  [   1883 ms] ax apply  lc-create-1
  [    684 ms] ax apply  lc-create-2
  [    682 ms] ax apply  lc-create-3
  [    681 ms] ax apply  lc-create-4
  [    694 ms] ax apply  lc-create-5
  create: n=5 median 684 ms, min 681, max 1883

NAME          ATESPACE    PHASE       ACTOR         WORKER-IP   AGE
lc-create-5   lifecycle   Suspended   lc-create-5   <none>      0s
...

Uma task criada fica Suspended, não em execução, e não ocupa nenhum worker: os dois pods de worker não mudam. O primeiro ax apply de uma sessão demora mais (1,9 s) porque a CLI abre seu túnel para o ax-server; depois disso, uma criação custa cerca de 0,7 s.

Teste 2: O primeiro resume, e o tamanho de uma sandbox

for i in range(1, 6):
    timed(f"ax resume lc-create-{i}", ax, "resume", "task", f"lc-create-{i}")
print(sh("lc-create-1", "uname -r; echo nproc $(nproc); grep MemTotal /proc/meminfo; df -h /workspace"))
  [   2780 ms] ax resume lc-create-1
             -> Running, ax-workers-7d9cc594c5-4hs5z on ip-10-40-33-18.ec2.internal
  [   2804 ms] ax resume lc-create-2
             -> Running, ax-workers-7d9cc594c5-7rv4w on ip-10-40-28-182.ec2.internal
  ...
  first resume: n=5 median 2780 ms, min 2772, max 2804

== inside a sandbox with AX's default size (1 CPU, 512 MiB)
4.19.0-gvisor
nproc 2
MemTotal:         524288 kB

== a task with limits: cpu 2, memory 2Gi
  [   2820 ms] ax resume lc-limits
MemTotal:        2097152 kB

Um primeiro resume leva cerca de 2,8 s: o Substrate inicia a task a partir do golden snapshot do seu template e o AX espera a readiness probe do runner. As tasks alternam entre os dois pods de worker. Uma task sem limits recebe o padrão do AX de 1 CPU e 512 MiB; spec.resources.limits muda isso, e o /proc/meminfo da sandbox segue o limite de memória exatamente. O nproc reporta 2 nos dois casos, então não dimensione thread pools com base nele dentro do AX.

Teste 3: Suspend e resume, cinco vezes

O comando da task adiciona uma linha a /workspace/boots.log toda vez que inicia, o que mostra o que um resume realmente faz:

BOOT = ["sh", "-c", 'echo "boot $(date +%T) pid $$" >> /workspace/boots.log; exec sleep infinity']
create(NAME, BOOT)
sh(NAME, "dd if=/dev/urandom of=/workspace/blob bs=1M count=50; sha256sum /workspace/blob > /workspace/blob.sha; "
         "echo 0 > /workspace/counter; echo /tmp-was-here > /tmp/marker")
for c in range(5):
    timed("ax suspend", ax, "suspend", "task", NAME)
    timed("ax resume", ax, "resume", "task", NAME)
    sh(NAME, "increment /workspace/counter; sha256sum -c /workspace/blob.sha; ls /tmp/marker")
== cycle 1
  [    820 ms] ax suspend
  [    817 ms] ax resume
counter=1
blob: /workspace/blob: OK
tmp: ls: cannot access '/tmp/marker': No such file or directory
...
== results
  suspend: n=5 median 820 ms, min 773, max 861
  resume : n=5 median 817 ms, min 790, max 821
boot 03:43:23 pid 9
boot 03:43:30 pid 9
boot 03:43:35 pid 9
boot 03:43:44 pid 10
boot 03:43:50 pid 9
boot 03:43:55 pid 9
O quêAo longo de 5 ciclos
Suspendmediana de 820 ms
Resumemediana de 817 ms
/workspace (arquivo de 50 MB, contador)Intacto todas as vezes; o contador chegou a 5
/tmpSome após cada resume
spec.commandRodou de novo a cada resume: 6 boots para 1 criação e 5 resumes

Suspend e resume ficam ambos bem abaixo de um segundo, e o workspace volta intacto byte a byte. Mas cada resume é um boot: o comando começa de novo do início, como um processo novo. Um agente que queira continuar de onde parou precisa gravar seu estado em /workspace e lê-lo de volta ao iniciar.

Teste 4: Wake on request

O documento de networking do AX diz que todo request para uma task passa pelo router do Substrate, que “resumes it first if it was suspended”. Ele também diz para fazer port-forward da porta 80 do router e dar curl nela. Nesta instalação não existe porta 80:

NAME            TYPE        CLUSTER-IP      EXTERNAL-IP   PORT(S)            AGE
atenet-router   ClusterIP   172.20.180.61   <none>        443/TCP,8444/TCP   66m

O router serve a 443 com mutual TLS e só admite os SPIFFE IDs passados ao instalador, aqui o do ax-server. Então o teste roda o curl a partir de um pod que roda como a service account ax-server e solicita o mesmo certificado de pod:

spec:
  serviceAccountName: ax-server
  volumes:
  - name: podidentity            # this pod's client certificate (SPIFFE ID of ax-server)
    projected:
      sources:
      - podCertificate:
          signerName: podidentity.podcert.ate.dev/identity
          keyType: ECDSAP256
          credentialBundlePath: credential-bundle.pem
CURL = ("curl -s -o /dev/null -w 'HTTP %{http_code} in %{time_total}s' "
        "--cacert /run/servicedns-ca/trust-bundle.pem "
        "--cert /run/podidentity/credential-bundle.pem --key /run/podidentity/credential-bundle.pem "
        "-H 'ate-target-actor: lifecycle/lc-wake' https://atenet-router.ate-system.svc/readyz")
== lc-wake: create, resume, suspend
{'phase': 'Suspended', 'worker': None, 'ready': 'Suspended'}

== request 1 to the suspended task, through the router
HTTP 200 in 0.100859s
{'phase': 'Running', 'worker': '10.40.55.108', 'ready': 'TaskRunning'}

== request 2 to the suspended task, through the router
HTTP 200 in 0.103489s

== without the ate-target-actor header, or without a client certificate
HTTP 404 in 0.006015s
no client cert: HTTP 000, curl exit 55

Um request para uma task suspensa voltou 200, e o AX então reportou a task como Running. O curl, rodando dentro do cluster, mediu cerca de 100 ms para o request inteiro, wake incluído. Esse é o único número deste post que não foi medido a partir do meu laptop: a partir do client, a mesma chamada levou cerca de um segundo, quase tudo em kubectl exec. Sem o header, o router responde 404; sem um certificado de client, ele derruba a conexão.

Duas coisas para tirar daqui. O wake on request funciona e é rápido, mas nada suspende tasks ociosas automaticamente ainda; isso está no roadmap do AX, então hoje você mesmo chama ax suspend. E a fronteira de acesso para o endpoint de rede de toda task é “conseguir criar pods como ax-server em ax-system”: quem tem isso consegue alcançar qualquer task.

Teste 5: Quando um snapshot chega ao S3

O suspend do Substrate é assíncrono por padrão. O checkpoint é gravado no node, o atelet faz upload dele para o object storage em background após --snapshot-upload-delay (5 minutos por padrão) e descarta a cópia do node após --snapshot-detach-delay (30 minutos). O 05_snapshot_s3.py acompanha isso acontecer pelo próprio registro do actor no Substrate, usando a CLI do Substrate, kubectl-ate, já que o AX não expõe nada disso:

def snap():
    snaps = actor(NAME).get("status", {}).get("snapshots") or [{}]   # kubectl ate get actor -o json
    s = snaps[-1]
    return {"durable": s.get("durableSnapshot", {}).get("status"),   # PENDING -> COMPLETED
            "uri": s.get("durableSnapshot", {}).get("object", {}).get("snapshotUri"),
            "locality": s.get("locality") or "none (detached)"}       # the node holding a local copy

A task gravou 100 MB de dados aleatórios e 100 MB de zeros em /workspace, e depois foi suspensa:

== suspend, then watch the actor's snapshot status every 30 s
  [    839 ms] ax suspend
  +    2s  durable copy: PENDING    local copy on: ip-10-40-33-18.ec2.internal
  +  324s  durable copy: COMPLETED  local copy on: ip-10-40-33-18.ec2.internal
  + 1801s  durable copy: COMPLETED  local copy on: none (detached)

== the snapshot in S3
     104.9 MB  7aca69ec-.../fs/pages.img.zstd
       0.0 MB  7aca69ec-.../manifest.json
     104.9 MB  in S3 for 200 MB of files (100 MB random, 100 MB zeros), zstd-compressed

== resume, now that the only copy is in S3
  [   1623 ms] ax resume
/workspace/random: OK
/workspace/zeros: OK
Tempo após o suspendOnde o snapshot está
0 a 5 minutosSomente no disco do node
5,4 minutosNo node e no S3 (COMPLETED)
30 minutosSomente no S3

Duas consequências. Nos primeiros cinco minutos após um suspend, o workspace existe em um único node; o guia de API do Substrate (no commit fixado) diz que um actor suspenso “loses its local snapshot when its node is deleted before that snapshot finishes uploading”. E, depois que a cópia do node some, um resume baixa o snapshot: 1,6 s aqui contra 0,8 s a partir do node, para 105 MB. A compressão compensa: os 100 MB de zeros custaram quase nada para armazenar.

Teste 6: Um workspace de 2 GB

sh(NAME, "for i in 1 2 3 4; do dd if=/dev/urandom of=/workspace/part$i bs=1M count=512; done; "
         "sha256sum /workspace/part* > /workspace/sums")
for c in (1, 2, 3):
    timed("ax suspend", ax, "suspend", "task", NAME)
    timed("ax resume", ax, "resume", "task", NAME)
    sh(NAME, "sha256sum -c --quiet /workspace/sums && echo 'all 4 parts intact'")
== cycle 1
  [  13122 ms] ax suspend
  [    875 ms] ax resume
all 4 parts intact
...
== results
  suspend: n=3 median 15497 ms, min 13122, max 16254
  resume : n=3 median 875 ms, min 865, max 922

O tempo de suspend cresce com o workspace: cerca de 15 s para 2 GB contra 0,8 s para 50 MB. O resume não cresce, fica abaixo de um segundo, porque o node ainda tem a cópia. Todos os bytes voltaram nos três ciclos. Não consegui reproduzir a #453, em que um workspace grande foi perdido enquanto o AX reportava sucesso; esse relato envolve um deadline de 15 segundos na CLI, e o build do AX que eu rodei permite 5 minutos para chamadas de ciclo de vida, então o gatilho provavelmente não está presente aqui.

Teste 7: Quantas tasks cabem

O Substrate reserva os limits de CPU e memória de cada task contra a capacidade do seu worker. O 07_density.py cria duas ondas em um cluster de resto ocioso e faz resume delas dez de cada vez:

wave("lc-default", 12)                                # AX's default: 1 CPU, 512 MiB
wave("lc-small", 40, cpu="250m", memory="256Mi")
NODE                           CPU     MEMORY
ip-10-40-28-182.ec2.internal   3920m   15065436Ki
ip-10-40-33-18.ec2.internal    3920m   15065440Ki

== wave 1: 12 tasks at AX's default size (1 CPU, 512 MiB)
  resume (10 at a time): n=6 median 2862 ms, min 2844, max 2932
  running: 6, refused: 6
  refused with: no worker has room for the actor
    3 on ax-workers-7d9cc594c5-jkwf9 on ip-10-40-33-18.ec2.internal
    3 on ax-workers-7d9cc594c5-nvfpm on ip-10-40-28-182.ec2.internal

== wave 2: 40 tasks at cpu 250m, memory 256Mi
  resume (10 at a time): n=30 median 2951 ms, min 2880, max 3085
  running: 30, refused: 10
   15 on ax-workers-7d9cc594c5-jkwf9 on ip-10-40-33-18.ec2.internal
   15 on ax-workers-7d9cc594c5-nvfpm on ip-10-40-28-182.ec2.internal

== what 30 running sandboxes use (kubelet stats summary)
  ip-10-40-28-182.ec2.internal: node working set 1.6 GiB
      ax-workers-7d9cc594c5-nvfpm: 0.24 GiB for its sandboxes
Tamanho da taskRodando ao mesmo tempoPor workerCPU reservada por worker
Padrão do AX (1 CPU, 512 MiB)633,0 de 3,92
250m, 256 MiB30153,75 de 3,92

O teto é a CPU, e é a CPU alocável do node: 3,92 em uma m7i.xlarge. Uma quarta task padrão (4 CPU) ou uma décima sexta pequena (4,0 CPU) não cabe, então o Substrate a recusa com no worker has room for the actor. O guia de API do Substrate diz que um worker que não declara capacidade é “unconstrained”. Na prática, ele é dimensionado pelo node: o Substrate lê os limits de um worker pela downward API do Kubernetes, que reporta a CPU alocável do node quando nenhum limit está definido. A memória não é a restrição de forma alguma: 15 sandboxes ociosas usaram 0,24 GiB de um node de 15 GiB.

Então o tamanho padrão é caro para agentes ociosos. Seis tasks enchem dois nodes de quatro cores enquanto ficam paradas em sleep. Declarar limits menores em cada task é a alavanca.

Teste 8: Quando as coisas terminam mal

O 08_failures.py cobre três formas de a vida de uma task terminar mal.

O comando termina. A task continua Running: o runner do AX é o PID 1 e continua servindo ax ssh e os endpoints de metadata depois que o comando acaba, como o contrato do runner do AX diz que deve. O AX não lê o exit code de volta, então nada em ax get task diz que o trabalho terminou.

== the command exits with code 3
{'phase': 'Running', 'worker': '10.40.38.184', 'ready': 'TaskRunning'}
  PID COMMAND
    1 /usr/local/bin/ax-task-runner

Um pod de worker morre. O script coloca uma task em execução e uma task suspensa no mesmo worker, grava um arquivo que a task em execução nunca inclui em snapshot e apaga o pod:

sh("lc-victim-running", "echo never-snapshotted > /workspace/unsaved")
kubectl("-n", "ax-system", "delete", "pod", pod, "--wait=true")
  running task:   {'phase': 'Failed', 'worker': None, 'ready': 'Failed'}
  suspended task: {'phase': 'Suspended', 'worker': None, 'ready': 'Suspended'}

== can either come back?
  [    774 ms] resume the suspended one
still-here
  [    786 ms] resume the failed one
  failed task now: {'phase': 'Running', 'worker': '10.40.30.123', 'ready': 'TaskRunning'}
 05:11:41 up 0 min
cat: /workspace/unsaved: No such file or directory

A task suspensa nem percebeu: seu snapshot estava no disco do node, não no pod, e ela retomou com seu arquivo. A task em execução foi para Failed. O resume dela funcionou, mas ela nunca tinha sido suspensa, então não tinha snapshot próprio: iniciou do zero a partir do golden snapshot do seu template, e o arquivo que ela gravou se perdeu.

O WorkerPool muda. Qualquer mudança no template do pool faz rollout dos pods de worker. Aqui eu declarei capacidade de CPU e memória nele:

  running before: ['lc-exit', 'lc-victim-running', 'lc-victim-suspended']
workerpool.ate.dev/ax-workers patched
  [   2986 ms] worker rollout
  lc-exit: {'phase': 'Failed', ...}
  lc-victim-running: {'phase': 'Failed', ...}
  lc-victim-suspended: {'phase': 'Failed', ...}

Todas as tasks em execução falharam (o rollout em si levou 3 segundos). Não existe drain: suspenda suas tasks antes de mexer no pool, ou trate uma mudança no pool como uma indisponibilidade.

Resumo dos resultados

TesteResultado
Criação0,7 s; a task começa Suspended e não ocupa worker
Primeiro resume2,8 s (boot a partir do golden snapshot do template)
Suspend / resume, workspace de 50 MB0,82 s / 0,82 s de mediana em 5 ciclos
O que sobrevive ao suspendSomente /workspace; /tmp e processos não; o comando roda de novo a cada resume
Wake on request pelo router200 em cerca de 100 ms (medido dentro do cluster), task de volta a Running
Durabilidade do snapshotSomente no node nos primeiros 5 minutos; no S3 após 5,4; cópia do node descartada aos 30
Resume a partir do S3 (105 MB armazenados)1,6 s
Workspace de 2 GBSuspend 15 s, resume 0,9 s, todos os bytes intactos
Densidade em 2 × m7i.xlarge6 tasks padrão, ou 30 com 250m de CPU; a CPU é o limite
Comando terminaA task continua Running; exit code não é reportado
Pod de worker morreTask em execução vai para Failed (o resume a inicia do zero); task suspensa não é afetada
Mudança no WorkerPoolTodas as tasks em execução vão para Failed

Limpeza

python3 09_cleanup.py

Ele apaga todas as tasks do atespace lifecycle; os snapshots delas no S3 vão junto. O cluster em si continua de pé para a parte 3, e é derrubado com o 99_teardown.sh da parte 1.

Veredito

O ciclo de vida do AX é rápido e previsível quando você para de esperar que ele pause um processo. Suspend e resume ficam ambos abaixo de um segundo para um workspace normal, um request pelo router do Substrate acorda uma task em cerca de um décimo de segundo (medido dentro do cluster), e o workspace voltou byte a byte em todos os testes, incluindo 2 GB ao longo de três ciclos. São bons números para um cluster de dois nodes.

Com o que eu me planejaria:

  • Suspend é um reinício com os seus arquivos. Só /workspace sobrevive, e o comando roda de novo do início. Projete os agentes para persistir seu estado ali e retomar a partir dele.
  • Um snapshot recente vive em um único node por cinco minutos. Perca o node nessa janela e o trabalho desde o último snapshot durável se perde. Se isso importa, o Substrate tem um modo de suspend síncrono; o AX não o expõe.
  • Dimensione suas tasks. O padrão do AX de 1 CPU por task enche um node de quatro cores com três tasks ociosas. Declarar resources.limits multiplicou isso por cinco aqui.
  • Os pods de worker ainda não são cattle. Um worker morrendo faz falhar suas tasks em execução, e mudar o pool faz falhar todas elas.
  • Parte disso você só enxerga com as ferramentas do Substrate. Durabilidade de snapshot, o mTLS do router e o estado dos actors exigiram kubectl-ate ou um pod com a identidade do ax-server; a CLI e a documentação do AX não cobrem isso, e o documento de networking descreve uma porta do router que não existe nesta instalação.

Para agentes que mantêm seu estado de trabalho em disco e passam a maior parte do tempo esperando, o modelo do AX se encaixa: barato de manter suspenso, rápido de trazer de volta e acordado pelo request que precisa dele. Para agentes que precisam retomar no meio de um pensamento, ele não faz isso hoje. A parte 3 cobre a outra metade de rodar agentes de verdade: entregar API keys a eles sem colocar as keys na sandbox. A parte 4 descobre até onde dois nodes aguentam.

Comments