Kubernetes Operators em Zig: CRDs, Watch, Reconcile e Deploy
Kubernetes Operators automatizam a gestão de aplicações complexas: recebem um estado desejado em um recurso customizado, observam o estado real do cluster e executam ações corretivas até os dois convergirem. O caminho mais comum é escrever operators em Go com controller-runtime, mas a ideia central não depende da linguagem. Um operator é, essencialmente, um processo Linux que fala HTTP com a API do Kubernetes.
Zig entra nessa conversa quando o operador precisa ser pequeno, previsível e barato de executar: binário estático, startup rápido, controle explícito de memória e uma imagem de container que pode ficar na casa de poucos megabytes. O custo é que você abre mão do ecossistema maduro de controller-runtime e precisa implementar mais peças: client HTTP, watch, cache local, retries, RBAC, serialização JSON e idempotência.
Este guia mostra uma arquitetura realista para um Kubernetes Operator em Zig, com foco em viabilidade de produção, não apenas em um snippet bonito.
Quando faz sentido escrever um operator em Zig?
Use Zig quando pelo menos uma destas condições for verdadeira:
- o operator roda em muitos clusters pequenos e o footprint de memória importa;
- o reconciler é simples, com poucos recursos e regras bem controladas;
- você quer distribuir um único binário estático sem runtime;
- o operator faz trabalho de baixo nível, rede, arquivos, sidecars ou integração com binários nativos;
- a equipe já usa Zig em outros componentes e aceita manter infraestrutura própria.
Evite Zig quando o operator precisa de dezenas de integrações Kubernetes prontas, webhooks complexos, leader election sofisticado, cache compartilhado, finalizers avançados e evolução rápida do schema. Nesse caso, Go com controller-runtime continua sendo a escolha pragmática.
Arquitetura básica de um operator
Um operator mínimo em Zig precisa de quatro blocos:
- Client Kubernetes — lê token, CA e namespace do service account montado no pod.
- Watch loop — abre streams contra a API do Kubernetes para receber eventos de CRDs, Pods, ConfigMaps ou Deployments.
- Fila de reconcile — deduplica eventos por chave
namespace/namee controla retry/backoff. - Reconciler idempotente — compara estado desejado e real, aplica mudanças e grava status.
┌─────────────────────────────────────────────┐
│ Kubernetes API │
│ /apis/example.com/v1/widgets?watch=true │
└───────────────────┬─────────────────────────┘
│ ADDED / MODIFIED / DELETED
▼
┌─────────────────────────────────────────────┐
│ Watch loop em Zig │
│ - parse JSON incremental │
│ - reconecta em timeout │
│ - envia namespace/name para a fila │
└───────────────────┬─────────────────────────┘
▼
┌─────────────────────────────────────────────┐
│ Work queue │
│ - dedupe por chave │
│ - retry com backoff │
│ - limite de concorrência │
└───────────────────┬─────────────────────────┘
▼
┌─────────────────────────────────────────────┐
│ Reconcile loop │
│ - lê CRD │
│ - lê estado real │
│ - cria/atualiza recursos │
│ - atualiza .status │
└─────────────────────────────────────────────┘
CRD de exemplo
Imagine um CRD Widget que declara quantas réplicas de um pequeno serviço devem existir:
apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
name: widgets.example.com
spec:
group: example.com
scope: Namespaced
names:
plural: widgets
singular: widget
kind: Widget
versions:
- name: v1
served: true
storage: true
schema:
openAPIV3Schema:
type: object
properties:
spec:
type: object
properties:
image:
type: string
replicas:
type: integer
minimum: 1
maximum: 10
status:
type: object
properties:
readyReplicas:
type: integer
phase:
type: string
subresources:
status: {}
O operator deve assistir Widget, criar ou atualizar um Deployment, observar quantos pods ficaram prontos e gravar o resumo em status.
Client HTTP para a API do Kubernetes
Dentro de um pod, o Kubernetes monta credenciais em /var/run/secrets/kubernetes.io/serviceaccount/. Você precisa ler token, CA e namespace. O exemplo abaixo é propositalmente enxuto; em produção, encapsule headers, TLS, status codes e tratamento de erros.
const std = @import("std");
const ServiceAccount = struct {
token: []const u8,
namespace: []const u8,
pub fn load(allocator: std.mem.Allocator) !ServiceAccount {
const dir = "/var/run/secrets/kubernetes.io/serviceaccount";
const token = try std.fs.cwd().readFileAlloc(
allocator,
dir ++ "/token",
64 * 1024,
);
const namespace = try std.fs.cwd().readFileAlloc(
allocator,
dir ++ "/namespace",
4096,
);
return .{
.token = std.mem.trim(u8, token, "\n\r \t"),
.namespace = std.mem.trim(u8, namespace, "\n\r \t"),
};
}
};
const K8sClient = struct {
allocator: std.mem.Allocator,
api_server: []const u8 = "https://kubernetes.default.svc",
service_account: ServiceAccount,
pub fn get(self: *K8sClient, path: []const u8) ![]u8 {
const url = try std.fmt.allocPrint(self.allocator, "{s}{s}", .{
self.api_server,
path,
});
defer self.allocator.free(url);
var client = std.http.Client{ .allocator = self.allocator };
defer client.deinit();
const auth = try std.fmt.allocPrint(
self.allocator,
"Bearer {s}",
.{self.service_account.token},
);
defer self.allocator.free(auth);
const uri = try std.Uri.parse(url);
var header_buf: [16 * 1024]u8 = undefined;
var req = try client.open(.GET, uri, .{
.server_header_buffer = &header_buf,
.extra_headers = &.{
.{ .name = "Authorization", .value = auth },
.{ .name = "Accept", .value = "application/json" },
},
});
defer req.deinit();
try req.send();
try req.wait();
if (req.response.status != .ok) {
return error.KubernetesRequestFailed;
}
return try req.reader().readAllAlloc(self.allocator, 4 * 1024 * 1024);
}
};
Para POST, PATCH e PUT, adicione Content-Type: application/merge-patch+json ou application/apply-patch+yaml, dependendo da estratégia. Para objetos Kubernetes, PATCH com server-side apply costuma ser mais robusto do que montar PUT completo.
Watch de CRDs
O watch da API retorna uma sequência de eventos JSON, normalmente separados por quebras de linha. Cada evento tem um type e um object.
{"type":"ADDED","object":{"metadata":{"namespace":"default","name":"demo"}}}
{"type":"MODIFIED","object":{"metadata":{"namespace":"default","name":"demo"}}}
Em Zig, a versão inicial pode ler chunks e processar linhas completas. A versão de produção deve respeitar resourceVersion, tratar 410 Gone, reconectar com backoff e aplicar timeout para não ficar presa em conexões mortas.
fn watchWidgets(client: *K8sClient, queue: *WorkQueue) !void {
var resource_version: []const u8 = "";
while (true) {
const path = try std.fmt.allocPrint(
client.allocator,
"/apis/example.com/v1/widgets?watch=true&resourceVersion={s}",
.{resource_version},
);
defer client.allocator.free(path);
const body = client.get(path) catch |err| {
std.log.warn("watch failed: {}, reconnecting", .{err});
std.time.sleep(2 * std.time.ns_per_s);
continue;
};
defer client.allocator.free(body);
var lines = std.mem.splitScalar(u8, body, '\n');
while (lines.next()) |line| {
if (line.len == 0) continue;
const key = parseWatchEventForKey(line) catch continue;
try queue.pushDedup(key);
resource_version = key.resource_version;
}
}
}
O exemplo acima simplifica a vida útil de resource_version; não copie a alocação literalmente. Em produção, salve uma cópia própria da string e libere a anterior.
Reconciliation loop idempotente
Um reconciler bom pode rodar cem vezes e terminar no mesmo estado. Ele não presume que um evento significa mudança real. Ele lê o mundo, calcula o delta e aplica somente o necessário.
const ReconcileResult = union(enum) {
done,
requeue,
requeue_after_ns: u64,
};
fn reconcile(client: *K8sClient, namespace: []const u8, name: []const u8) ReconcileResult {
const widget = loadWidget(client, namespace, name) catch |err| switch (err) {
error.NotFound => return .done,
else => return .requeue,
};
defer widget.deinit();
ensureDeployment(client, widget) catch return .requeue;
ensureService(client, widget) catch return .requeue;
const ready = countReadyPods(client, widget) catch return .{ .requeue_after_ns = 30 * std.time.ns_per_s };
updateStatus(client, widget, ready) catch return .requeue;
return .done;
}
Regras práticas:
- nunca dependa apenas do evento recebido; sempre releia o recurso atual;
- trate
NotFoundcomo sucesso quando o recurso já foi removido; - use nomes determinísticos para objetos filhos;
- grave
ownerReferencespara garbage collection; - atualize
statusseparadamente despec; - use backoff para erros transitórios e limite tentativas ruins.
RBAC mínimo
Não rode o operator com permissões de cluster-admin. Comece com um Role por namespace e expanda apenas quando necessário:
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: widget-operator
rules:
- apiGroups: ["example.com"]
resources: ["widgets"]
verbs: ["get", "list", "watch"]
- apiGroups: ["example.com"]
resources: ["widgets/status"]
verbs: ["patch", "update"]
- apiGroups: ["apps"]
resources: ["deployments"]
verbs: ["get", "list", "watch", "create", "patch", "update"]
- apiGroups: [""]
resources: ["services", "pods"]
verbs: ["get", "list", "watch", "create", "patch", "update"]
Se o operator precisa gerenciar recursos em vários namespaces, use ClusterRole e ClusterRoleBinding, mas documente o motivo. Operators são automação privilegiada; permissões amplas viram incidente quando há bug no reconcile.
Deploy com imagem pequena
O fluxo natural é compilar um binário Linux estático e copiar para uma imagem scratch ou distroless:
FROM alpine:3.20 AS build
RUN apk add --no-cache zig ca-certificates
WORKDIR /src
COPY build.zig build.zig.zon ./
COPY src ./src
RUN zig build -Doptimize=ReleaseSafe -Dtarget=x86_64-linux-musl
FROM scratch
COPY --from=build /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
COPY --from=build /src/zig-out/bin/widget-operator /widget-operator
USER 65532:65532
ENTRYPOINT ["/widget-operator"]
Mesmo em scratch, mantenha certificados CA se o operator fala com endpoints HTTPS externos. Para falar só com https://kubernetes.default.svc, você pode usar o CA do service account montado pelo cluster.
Observabilidade do operator
Operators silenciosos são difíceis de operar. Inclua desde o começo:
- logs estruturados com
namespace,name,resourceVersion, resultado e duração; - métricas de reconcile por status: sucesso, retry, erro permanente;
- tamanho e latência da fila;
- contador de reconexões do watch;
- readiness probe que falha se o watch principal morreu;
- health check simples para saber se o processo continua vivo.
Veja também o guia de observabilidade em Zig e o guia de HTTP server em produção para padrões de logs, health checks e métricas Prometheus.
Segurança e finalizers
Se o CRD cria recursos externos — buckets, filas, bancos, DNS — implemente finalizers. O finalizer impede que o objeto desapareça antes de o operator limpar o recurso externo.
Checklist de segurança:
- validar campos do CRD no schema OpenAPI;
- limitar tamanho de strings e listas;
- não interpolar
specem comandos shell; - usar RBAC mínimo;
- separar reconcilers por escopo quando permissões forem muito diferentes;
- não gravar tokens, headers ou segredos em logs;
- usar configuração segura e segredos para credenciais externas.
Limites práticos em 2026
Zig ainda não tem um equivalente amplamente adotado ao controller-runtime. Isso significa que você provavelmente vai escrever ou manter:
- tipos para objetos Kubernetes que usa;
- serialização e validação de JSON;
- watch com
resourceVersion; - retry/backoff;
- queue interna;
- patch server-side apply;
- leader election, se rodar mais de uma réplica;
- testes de integração contra
kindouk3d.
Para um operator simples, esse custo é aceitável. Para uma plataforma inteira, talvez não. Uma estratégia comum é escrever o plano de controle principal em Go e usar Zig para componentes auxiliares de alta performance, agentes de nó, sidecars ou ferramentas CLI.
Checklist antes de ir para produção
- CRD tem schema OpenAPI e limites de campo.
- Reconciler é idempotente e trata
NotFoundcorretamente. - Objetos filhos têm
ownerReferences. - Status é atualizado separadamente de spec.
- Watch reconecta com backoff e
resourceVersion. - RBAC usa menor privilégio possível.
- Logs não expõem tokens ou segredos.
- Imagem roda como usuário não-root.
- Health/readiness probes existem.
- Testes cobrem create, update, delete e erro transitório.
FAQ rápido
Dá para escrever um Kubernetes Operator em Zig?
Sim. Um operator só precisa falar com a API do Kubernetes, assistir eventos e reconciliar estado. Zig consegue fazer isso via HTTP, JSON e binário estático. O desafio é a falta de um framework maduro equivalente ao controller-runtime.
Zig substitui Go para operators?
Na maioria dos times, não. Go continua melhor para operators complexos por causa do ecossistema Kubernetes. Zig faz sentido para operators pequenos, agentes leves, sidecars e casos em que footprint e distribuição estática importam muito.
Preciso de cluster-admin?
Quase nunca. Comece com Role por namespace e permissões específicas para o CRD e os recursos filhos. Use ClusterRole apenas se o operator realmente precisa observar ou criar recursos em múltiplos namespaces.
Como testar um operator em Zig?
Use testes unitários para lógica de diff e reconciliação, testes de contrato para JSON/patches e testes de integração com kind ou k3d. O teste mais importante é garantir que rodar o reconciler repetidas vezes não cria mudanças infinitas.
Conclusão
Kubernetes Operators em Zig são viáveis quando o domínio é pequeno, o footprint importa e você aceita construir parte da infraestrutura que Go entrega pronta. A arquitetura recomendada é simples: client HTTP seguro, watch resiliente, fila com dedupe, reconcile idempotente, RBAC mínimo e observabilidade desde o primeiro deploy.
Para a maioria dos operators complexos, Go segue como padrão. Para operators leves, agentes e automações especializadas, Zig pode entregar binários muito pequenos, consumo previsível e uma superfície operacional enxuta.
Conteúdo Relacionado
- Zig Cloud Native — Zig na nuvem
- Zig e Docker — Containers pequenos e reproduzíveis
- Microserviços com Zig — Quando vale a pena usar Zig em serviços
- HTTP Server em Produção — Proxy, logs, limites e health checks
- Observabilidade em Zig — Monitoramento de serviços e operators
- Configuração Segura em Zig — Env vars, segredos e runtime