DEV Community

Cover image for Api rust com pulumi IaC, k8s, gke, dns e CI no gitlab.
Rodrigo Burgos
Rodrigo Burgos

Posted on

Api rust com pulumi IaC, k8s, gke, dns e CI no gitlab.

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))
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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

    })

}
Enter fullscreen mode Exit fullscreen mode

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"]
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)