O projetinho de hoje é construir uma api rust, com deploy no gke usando pulumi como iac. Nossa primeira abordagem será criar a nossa IaC usando pulumi.
IaC
Primeiro recurso é o ApiDeployment, não é o serviço propriamente dito mas possui algumas especificações do pod como recurso de máquina, portas (do serviço, observabilidade) e variáveis de ambiente:
func ApiDeployment(ctx *pulumi.Context, provider *kubernetes.Provider, apiLabels pulumi.StringMap, secret *corev1.Secret, image pulumi.StringInput) error {
_, err := appsv1.NewDeployment(ctx, "api", &appsv1.DeploymentArgs{
Metadata: &metav1.ObjectMetaArgs{
Name: pulumi.String("api"),
},
Spec: &appsv1.DeploymentSpecArgs{
Replicas: pulumi.Int(1),
Selector: &metav1.LabelSelectorArgs{
MatchLabels: apiLabels,
},
Template: &corev1.PodTemplateSpecArgs{
Metadata: &metav1.ObjectMetaArgs{
Labels: apiLabels,
},
Spec: &corev1.PodSpecArgs{
Containers: corev1.ContainerArray{
&corev1.ContainerArgs{
Name: pulumi.String("api"),
Image: image,
Ports: corev1.ContainerPortArray{
&corev1.ContainerPortArgs{ContainerPort: pulumi.Int(8080)},
&corev1.ContainerPortArgs{ContainerPort: pulumi.Int(3001), Name: pulumi.String("metrics")},
},
Resources: &corev1.ResourceRequirementsArgs{
Requests: pulumi.StringMap{"cpu": pulumi.String("100m")},
Limits: pulumi.StringMap{"cpu": pulumi.String("500m")},
},
Env: corev1.EnvVarArray{
&corev1.EnvVarArgs{
Name: pulumi.String("TEST"),
ValueFrom: &corev1.EnvVarSourceArgs{
SecretKeyRef: &corev1.SecretKeySelectorArgs{
Name: secret.Metadata.Name(),
Key: pulumi.String("TEST"),
},
},
},
},
},
},
},
},
},
}, pulumi.Provider(provider))
Proximo recurso é o serviço propriamente dito, mais focado no load balancer (atenção pra congruência das portas expostas).
func ApiLb(ctx *pulumi.Context, provider *kubernetes.Provider, apiLabels pulumi.StringMap) (*corev1.Service, error) {
apiService, err := corev1.NewService(ctx, "api-lb", &corev1.ServiceArgs{
Metadata: &metav1.ObjectMetaArgs{
Name: pulumi.String("api-lb"),
Labels: apiLabels,
Annotations: pulumi.StringMap{
"cloud.google.com/neg": pulumi.String(`{"ingress": true}`),
"cloud.google.com/backend-config": pulumi.String(`{"default":"api-backendconfig"}`),
"pulumi.com/skipAwait": pulumi.String("true"),
},
},
Spec: &corev1.ServiceSpecArgs{
Selector: apiLabels,
Ports: corev1.ServicePortArray{
&corev1.ServicePortArgs{
Port: pulumi.Int(80),
TargetPort: pulumi.Int(8080),
},
},
},
}, pulumi.Provider(provider))
if err != nil {
return nil, err
}
return apiService, nil
}
Este recurso diz respeito ao ingress, a classe do ingress, o gerenciador de certificado, nome do ip estático público e o o domínio são gerenciados aqui:
func ApiIngress(
ctx *pulumi.Context,
provider *kubernetes.Provider,
apiService *corev1.Service,
cert *apiextensions.CustomResource,
ip *compute.GlobalAddress, // <— MUDEI: recebe o recurso inteiro
) (*networkingv1.Ingress, error) {
ingress, err := networkingv1.NewIngress(ctx, "ingress", &networkingv1.IngressArgs{
Metadata: &metav1.ObjectMetaArgs{
Name: pulumi.String("ingress"),
Annotations: pulumi.StringMap{
"networking.gke.io/managed-certificates": pulumi.String("managed-cert"),
"kubernetes.io/ingress.class": pulumi.String("gce"),
"pulumi.com/skipAwait": pulumi.String("true"),
// <- Aqui vai o NOME do GlobalAddress:
"kubernetes.io/ingress.global-static-ip-name": ip.Name,
},
},
Spec: &networkingv1.IngressSpecArgs{
IngressClassName: pulumi.StringPtr("gce"),
Rules: networkingv1.IngressRuleArray{
&networkingv1.IngressRuleArgs{
Host: pulumi.String("subdomio.dominio.com"),
Http: &networkingv1.HTTPIngressRuleValueArgs{
Paths: networkingv1.HTTPIngressPathArray{
&networkingv1.HTTPIngressPathArgs{
Path: pulumi.String("/"),
PathType: pulumi.String("Prefix"),
Backend: &networkingv1.IngressBackendArgs{
Service: &networkingv1.IngressServiceBackendArgs{
Name: pulumi.String("api-lb"),
Port: &networkingv1.ServiceBackendPortArgs{
Number: pulumi.Int(80),
},
},
},
},
},
},
},
},
},
}, pulumi.Provider(provider), pulumi.DependsOn([]pulumi.Resource{apiService, cert, ip}))
if err != nil {
return nil, err
}
return ingress, nil
}
Já este recurso é uma outra camada de gerenciamento do certificado, especificações do gcp.
func ManagedCert(ctx *pulumi.Context, provider *kubernetes.Provider) (*apiextensions.CustomResource, error) {
cert, err := apiextensions.NewCustomResource(ctx, "managed-cert", &apiextensions.CustomResourceArgs{
ApiVersion: pulumi.String("networking.gke.io/v1"),
Kind: pulumi.String("ManagedCertificate"),
Metadata: metav1.ObjectMetaArgs{
Name: pulumi.String("managed-cert"),
Namespace: pulumi.String("default"),
},
OtherFields: kubernetes.UntypedArgs{
"spec": pulumi.Map{
"domains": pulumi.StringArray{
pulumi.String("subdominio.dominio.com"),
},
},
},
}, pulumi.Provider(provider))
if err != nil {
return nil, err
}
return cert, nil
}
Terceira camada de gerenciamento de recurso, especificação do gcp:
func NewRecordSet(ctx *pulumi.Context, managedZone *dns.ManagedZone, addr pulumi.StringInput) error {
_, err := dns.NewRecordSet(ctx, "nomeclatura-arbitraria", &dns.RecordSetArgs{
ManagedZone: managedZone.Name,
Name: pulumi.String("subdominio.dominio.com."),
Type: pulumi.String("A"),
Ttl: pulumi.Int(300),
Rrdatas: pulumi.StringArray{addr},
})
return err
}
Quarta camada de gerenciamento de dns
func ManagedZone(ctx *pulumi.Context) (*dns.ManagedZone, error) {
managedZone, err := dns.NewManagedZone(ctx, "nomeclatura-arbitraria-para-zona", &dns.ManagedZoneArgs{
DnsName: pulumi.String("subdominio.dominio.com."),
Description: pulumi.String("Managed zone for GKE ingress"),
})
if err != nil {
return nil, err
}
return managedZone, nil
}
Declaração de IP público
func IngressIP(ctx *pulumi.Context) (*compute.GlobalAddress, error) {
ip, err := compute.NewGlobalAddress(ctx, "nomeclatura-arbitraria-referenciando-ip", &compute.GlobalAddressArgs{
AddressType: pulumi.String("EXTERNAL"),
IpVersion: pulumi.String("IPV4"),
Name: pulumi.String("nomeclatura-arbitraria-referenciando-ip"),
})
if err != nil {
return nil, err
}
return ip, nil
}
Nossa main function com todas as declarações:
func main() {
pulumi.Run(func(ctx *pulumi.Context) error {
cfg := config.New(ctx, "gcp")
region := cfg.Require("region")
image := config.Require(ctx, "apiImage")
cluster, err := infra.Cluster(ctx, region)
if err != nil {
return err
}
node, err := infra.Node(region, ctx, cluster)
if err != nil {
return err
}
kubeconfig := utils.Kubeconfig(cluster)
provider, err := infra.Provider(ctx, node, &kubeconfig)
if err != nil {
return err
}
apiLabels := service.ApiLabels()
secret, err := utils.CreateAppSecret(ctx, provider)
err = service.ApiDeployment(ctx, provider, apiLabels, secret, pulumi.String(image))
if err != nil {
return err
}
apiLb, err := service.ApiLb(ctx, provider, apiLabels)
if err != nil {
return err
}
// Cria o ManagedCertificate e o armazena para ser usado como dependência.
managedCert, err := service.ManagedCert(ctx, provider)
if err != nil {
return err
}
// O Ingress agora recebe o ManagedCert como argumento,
// garantindo a ordem de criação correta.
// _, err = service.ApiIngress(ctx, provider, apiLb, managedCert)
// if err != nil {
// return err
// }
//
IP, err := utils.IngressIP(ctx)
if err != nil {
return err
}
_, err = service.ApiIngress(ctx, provider, apiLb, managedCert, IP)
if err != nil {
return err
}
err = utils.BackendConfig(ctx, provider)
if err != nil {
return err
}
// ingressIP := service.IngressIP(apiIngress)
// ingressIP.ApplyT(func(ip string) error {
// ctx.Log.Info("Ingress IP: "+ip, nil)
// return nil
// })
managedZone, err := service.ManagedZone(ctx)
if err != nil {
return err
}
err = service.NewRecordSet(ctx, managedZone, IP.Address)
if err != nil {
return err
}
return nil
})
}
Dockerfile
Não vou me aprofundar muito na api, aqui está o dockerfile performático com gerenciamento de cache:
# ===== Base (Rust + deps de build) =====
ARG RUST_VERSION=1.83-bookworm
FROM rust:${RUST_VERSION} AS rust-base
RUN apt-get update && apt-get install -y --no-install-recommends \
pkg-config libssl-dev ca-certificates && \
rm -rf /var/lib/apt/lists/*
WORKDIR /app
# ===== Planner (gera a recipe do cargo-chef) =====
FROM rust-base AS planner
RUN cargo install cargo-chef --locked
COPY Cargo.* ./
COPY src ./src
RUN cargo chef prepare --recipe-path recipe.json
# ===== Cache de deps (cozinha as dependências) =====
FROM rust-base AS cacher
RUN cargo install cargo-chef --locked
COPY --from=planner /app/recipe.json /app/recipe.json
RUN cargo chef cook --release --recipe-path /app/recipe.json
# ===== Build final do binário =====
FROM rust-base AS builder
# Opcional: garante lockfile se não estiver versionado
COPY . .
RUN [ -f Cargo.lock ] || cargo generate-lockfile
# Reaproveita cache de deps via layers do chef
RUN cargo build --release
# ===== Runtime (slim, com OpenSSL 3) =====
FROM debian:bookworm-slim
RUN apt-get update && apt-get install -y --no-install-recommends \
libssl3 ca-certificates && \
apt-get clean && rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY --from=builder /app/target/release/test_api /app/test_api
ENV PORT=8080
EXPOSE 8080
CMD ["/app/test_api"]
Gitlab CI/CD
Mandando a imagem pro artifact registry do gcp
push_test_api:
stage: push_test_api
tags: [temp]
environment: { name: sandbox }
rules:
- if: '$CI_COMMIT_BRANCH == "sandbox" && $CI_PIPELINE_SOURCE == "push"'
changes: ["test_api/**/*"]
when: always
- when: never
before_script:
- *before_gcp
script: |
set -euo pipefail
IMAGE_TAG="${CI_COMMIT_SHORT_SHA}"
REPO="us-central1-docker.pkg.dev/projeto/registry"
IMAGE_NAME="test-api"
IMAGE_URI="${REPO}/${IMAGE_NAME}:${IMAGE_TAG}"
docker build -t "${IMAGE_URI}" -f test_api/Dockerfile test_api
docker push "${IMAGE_URI}"
# Pin por digest (opcional, recomendado)
DIGEST=$(gcloud artifacts docker images describe "${IMAGE_URI}" --format="value(image_summary.digest)")
echo "IMAGE_URI=${IMAGE_URI}" > image_env.txt
echo "IMAGE_DIGEST=${DIGEST}" >> image_env.txt
artifacts:
reports:
dotenv: image_env.txt
after_script:
- rm -f gcloud-key.json || true
Atualizando o recurso através do pulumi
deploy_api_pulumi:
stage: deploy_api_pulumi
needs: ["push_test_api"]
tags: [temp]
environment: { name: sandbox }
rules:
- if: '$CI_COMMIT_BRANCH == "branch_name" && $CI_PIPELINE_SOURCE == "push"'
changes: ["test_api/**/*"]
when: always
- when: never
before_script:
- *before_gcp
# Pulumi + Go
- curl -fsSL https://get.pulumi.com | sh
- export PATH="$HOME/.pulumi/bin:$PATH"
- mkdir -p "$CI_PROJECT_DIR/.tmp/go"
- curl -sSL "https://go.dev/dl/go1.23.0.linux-amd64.tar.gz" | tar -xz -C "$CI_PROJECT_DIR/.tmp/go" --strip-components=1
- export PATH="$CI_PROJECT_DIR/.tmp/go/bin:$PATH"
# GKE auth plugin + kubectl
- gcloud components install gke-gcloud-auth-plugin kubectl --quiet || true
- export USE_GKE_GCLOUD_AUTH_PLUGIN=True
- kubectl version --client=true
# kubeconfig
- gcloud container clusters get-credentials seu-cluster --region us-east1 --project seu-projeto
script: |
set -euo pipefail
cd iac_secret_manager
# usa o backend do Pulumi.yaml (gs://seu-bucket-gerenciamento-state)
pulumi login
export PULUMI_CONFIG_PASSPHRASE=""
pulumi stack select dev
# aponta a imagem imutável produzida no job anterior
pulumi config set apiImage "${IMAGE_URI}"
# ---- (opcional) injete configs/segredos que sua app usa ----
# Exemplos: (defina as envs no GitLab CI e marque-as como masked/protected)
# pulumi config set --secret TEST "$TEST"
"$AUTH_TOKEN_KEY_STAGING"
# ------------------------------------------------------------
pulumi up --yes
kubectl rollout status deploy/api -n default --timeout=120s
after_script:
- rm -f gcloud-key.json || true
Garanta um SA (service account) com permissões necessárias para essa operação. Crie o bucket no gs para gerenciar o estado, se o gitlab for self hosted, prepare uma VM para computar essas operações (com bastante espaço pra armazenar o cache ou com algum mecanismo de limpeza periódico) e você terá uma CI/CD segura e performática para seu projeto :)
Top comments (0)