Cloud Deployment & Auto-scaling

9 min read Module 8 of 10 Topic 24 of 30

What you'll learn

  • Deploy an agent service to Kubernetes with a Deployment and Service
  • Configure Horizontal Pod Autoscaling based on CPU and custom metrics
  • Set up a CI/CD pipeline with GitHub Actions that tests, builds, and deploys
  • Choose between serverless (Cloud Run, Lambda) and Kubernetes for different agent workloads
Building this at your company? For enterprise and company teams taking this to production: book a 30-minute session with our AI engineers for architecture guidance, code review, and a rollout plan for your use case.
Book a Team Session

Kubernetes Deployment for Agent Services

Kubernetes is the standard for production workloads that need auto-scaling, rolling deployments, and self-healing. Here’s a complete deployment configuration.


Kubernetes Manifests

# k8s/deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: agent-service
  namespace: production   # namespaces isolate resources, production is separate from staging
  labels:
    app: agent-service
    version: "1.0.0"
spec:
  replicas: 3   # three pods for high availability; if one crashes, two keep serving
  strategy:
    type: RollingUpdate
    rollingUpdate:
      maxSurge: 1        # allow 1 extra pod during update, briefly run 4 pods to avoid downtime
      maxUnavailable: 0  # never reduce below desired replicas, zero-downtime guarantee
  selector:
    matchLabels:
      app: agent-service   # Deployment manages pods whose labels match this selector
  template:
    metadata:
      labels:
        app: agent-service
    spec:
      serviceAccountName: agent-service-sa   # least-privilege service account, only the permissions this app needs
      
      # Security context for the pod
      securityContext:
        runAsNonRoot: true   # Kubernetes rejects the pod if the container tries to run as root
        runAsUser: 1000      # matches the UID set in the Dockerfile, consistency matters
        fsGroup: 1000        # mounted volumes are owned by this group, useful for shared file access
      
      containers:
        - name: agent-api
          image: registry.example.com/agent-service:1.0.0
          imagePullPolicy: Always   # Always pull ensures the latest digest is used even if the tag didn't change
          ports:
            - containerPort: 8000
          
          # Secrets from Kubernetes Secret: never put secret values directly in the manifest
          env:
            - name: ENVIRONMENT
              value: production
            - name: OPENAI_API_KEY
              valueFrom:
                secretKeyRef:
                  name: llm-api-keys       # name of the Kubernetes Secret object
                  key: openai-api-key      # key within that Secret
            - name: ANTHROPIC_API_KEY
              valueFrom:
                secretKeyRef:
                  name: llm-api-keys
                  key: anthropic-api-key
            - name: DATABASE_URL
              valueFrom:
                secretKeyRef:
                  name: database-credentials
                  key: url
          
          # Resource limits: both requests and limits must be set for HPA to work correctly
          resources:
            requests:
              cpu: "500m"    # 0.5 CPU cores, the amount Kubernetes reserves for this pod on a node
              memory: "512Mi"
            limits:
              cpu: "2000m"   # 2 CPU cores max, prevents one pod from starving other workloads
              memory: "2Gi"  # OOM killer triggers if the container exceeds this
          
          # Health probes
          livenessProbe:
            httpGet:
              path: /health        # calls the lightweight liveness endpoint
              port: 8000
            initialDelaySeconds: 30   # give uvicorn time to start before the first check
            periodSeconds: 10         # check every 10 seconds
            failureThreshold: 3       # restart pod after 3 consecutive failures
          
          readinessProbe:
            httpGet:
              path: /health/ready   # deeper check, verifies dependencies like OpenAI and DB are reachable
              port: 8000
            initialDelaySeconds: 30
            periodSeconds: 5          # check more frequently than liveness to react faster to transient failures
            successThreshold: 1       # one passing check restores the pod to the load balancer
            failureThreshold: 3       # remove from load balancer after 3 failures (but don't restart)
          
          # Graceful shutdown
          lifecycle:
            preStop:
              exec:
                command: ["sleep", "10"]  # drain active connections before SIGTERM is sent to uvicorn
          
          terminationGracePeriodSeconds: 30   # Kubernetes waits 30s after SIGTERM before force-killing
# k8s/service.yaml
apiVersion: v1
kind: Service
metadata:
  name: agent-service
  namespace: production
spec:
  selector:
    app: agent-service   # routes traffic to all pods with this label, automatically updates as pods scale
  ports:
    - protocol: TCP
      port: 80           # external port clients use when calling the Service
      targetPort: 8000   # forwarded to the pod's container port
  type: ClusterIP        # ClusterIP = internal only; use LoadBalancer or Ingress to expose externally

---
# k8s/ingress.yaml
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: agent-service
  namespace: production
  annotations:
    nginx.ingress.kubernetes.io/proxy-read-timeout: "3600"  # for long SSE connections, prevent nginx from closing the stream
    nginx.ingress.kubernetes.io/proxy-buffering: "off"       # disable buffering for SSE, tokens stream to the client immediately
    cert-manager.io/cluster-issuer: "letsencrypt-prod"       # cert-manager auto-provisions and renews TLS certificates
spec:
  ingressClassName: nginx
  tls:
    - hosts: [api.yourdomain.com]
      secretName: agent-service-tls   # cert-manager writes the TLS cert into this Secret
  rules:
    - host: api.yourdomain.com
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: agent-service
                port:
                  number: 80

Horizontal Pod Autoscaling

# k8s/hpa.yaml
apiVersion: autoscaling/v2   # v2 supports multiple metrics; v1 only supported CPU
kind: HorizontalPodAutoscaler
metadata:
  name: agent-service-hpa
  namespace: production
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: agent-service   # HPA controls the replica count of this Deployment
  minReplicas: 2   # always at least 2 for HA, one pod can restart without downtime
  maxReplicas: 20  # cap at 20 to control costs, prevent runaway scaling from a traffic spike
  
  metrics:
    # Scale on CPU usage: CPU-bound when LLM responses are being post-processed or streamed
    - type: Resource
      resource:
        name: cpu
        target:
          type: Utilization
          averageUtilization: 70  # scale up when avg CPU > 70% across all pods
    
    # Scale on memory: add memory metrics for memory-bound workloads like large context windows
    - type: Resource
      resource:
        name: memory
        target:
          type: Utilization
          averageUtilization: 80  # scale up when average memory usage exceeds 80%
  
  behavior:
    scaleUp:
      stabilizationWindowSeconds: 60    # wait 60s before scaling up again, prevents flapping
      policies:
        - type: Pods
          value: 4                       # add at most 4 pods at once, gradual scale-up avoids node pressure
          periodSeconds: 60
    scaleDown:
      stabilizationWindowSeconds: 300   # wait 5 min before scaling down, agents may have long-running tasks
      policies:
        - type: Percent
          value: 25                      # remove at most 25% of pods at once, cautious scale-down
          periodSeconds: 60

Kubernetes Secrets Management

# Create secrets (never store in source control)
# --from-literal reads values from shell variables: secrets are never written to disk
kubectl create secret generic llm-api-keys \
  --from-literal=openai-api-key="$OPENAI_API_KEY" \
  --from-literal=anthropic-api-key="$ANTHROPIC_API_KEY" \
  -n production

# For production: use External Secrets Operator with AWS Secrets Manager
# It syncs secrets from AWS Secrets Manager to Kubernetes secrets automatically
# This way secrets are managed centrally and rotated without redeploying pods

CI/CD Pipeline with GitHub Actions

# .github/workflows/deploy.yml
name: Build and Deploy Agent Service

on:
  push:
    branches: [main]       # deploy automatically on every merge to main
  pull_request:
    branches: [main]       # run tests on every PR but don't deploy

env:
  REGISTRY: ghcr.io                                    # GitHub Container Registry, free for public repos
  IMAGE_NAME: ${{ github.repository }}/agent-service

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: astral-sh/setup-uv@v3
        with:
          python-version: "3.12"
      
      - name: Install dependencies
        run: uv sync --extra dev   # --extra dev includes test and lint packages
      
      - name: Lint
        run: uv run ruff check src/ tests/   # fail fast on lint errors before running slower tests
      
      - name: Unit tests
        run: uv run pytest tests/unit/ -v --asyncio-mode=auto
        env:
          OPENAI_API_KEY: "test-key"  # mocked in tests, no real API calls during CI

  build:
    needs: test   # build only runs if tests pass, prevents pushing a broken image
    runs-on: ubuntu-latest
    outputs:
      image_tag: ${{ steps.meta.outputs.tags }}   # pass the image tag to the deploy job
    
    steps:
      - uses: actions/checkout@v4
      
      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3   # Buildx enables multi-platform builds and layer caching
      
      - name: Log in to Container Registry
        uses: docker/login-action@v3
        with:
          registry: ${{ env.REGISTRY }}
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}   # GITHUB_TOKEN is auto-generated, no manual secret needed
      
      - name: Extract metadata
        id: meta
        uses: docker/metadata-action@v5
        with:
          images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
          tags: |
            type=sha, prefix={{branch}}-   # e.g. main-abc1234, unique per commit
            type=ref, event=branch         # e.g. main, always points to the latest build of a branch
      
      - name: Build and push
        uses: docker/build-push-action@v5
        with:
          context: .
          push: ${{ github.event_name != 'pull_request' }}   # push only on merge to main, not on PRs
          tags: ${{ steps.meta.outputs.tags }}
          cache-from: type=gha          # read layer cache from GitHub Actions cache
          cache-to: type=gha, mode=max   # write all layers to cache, speeds up subsequent builds significantly

  deploy:
    needs: build
    runs-on: ubuntu-latest
    if: github.ref == 'refs/heads/main'   # only deploy when merging to main, not feature branches
    environment: production               # requires manual approval if branch protection is configured
    
    steps:
      - uses: actions/checkout@v4
      
      - name: Configure kubectl
        uses: azure/k8s-set-context@v3
        with:
          kubeconfig: ${{ secrets.KUBECONFIG }}   # base64-encoded kubeconfig stored as a GitHub secret
      
      - name: Deploy to Kubernetes
        run: |
          # Update image tag in deployment: kubectl patches the running Deployment in-place
          kubectl set image deployment/agent-service \
            agent-api=${{ needs.build.outputs.image_tag }} \
            -n production
          
          # Wait for rollout: exits non-zero if the rollout fails within 5 minutes
          kubectl rollout status deployment/agent-service -n production --timeout=5m
      
      - name: Run smoke tests
        run: |
          ENDPOINT="https://api.yourdomain.com"
          # curl -w "%{http_code}" captures the HTTP status code: fail the pipeline if not 200
          STATUS=$(curl -s -o /dev/null -w "%{http_code}" "$ENDPOINT/health")
          [ "$STATUS" = "200" ] || (echo "Health check failed: $STATUS" && exit 1)

Serverless Alternative: Google Cloud Run

For simpler deployments or very bursty workloads:

# cloudbuild.yaml
steps:
  # Step 1: build the Docker image and tag it with the commit SHA for traceability
  - name: 'gcr.io/cloud-builders/docker'
    args: ['build', '-t', 'us-central1-docker.pkg.dev/$PROJECT_ID/agents/agent-service:$COMMIT_SHA', '.']
  
  # Step 2: push the image to Artifact Registry before deploying
  - name: 'gcr.io/cloud-builders/docker'
    args: ['push', 'us-central1-docker.pkg.dev/$PROJECT_ID/agents/agent-service:$COMMIT_SHA']
  
  # Step 3: deploy to Cloud Run: managed serverless, no nodes to configure
  - name: 'gcr.io/google.com/cloudsdktool/cloud-sdk'
    args:
      - run
      - deploy
      - agent-service
      - --image=us-central1-docker.pkg.dev/$PROJECT_ID/agents/agent-service:$COMMIT_SHA
      - --region=us-central1
      - --platform=managed
      - --min-instances=1        # keep warm, prevents cold starts for the first request after inactivity
      - --max-instances=100      # Cloud Run scales to 100 instances automatically under load
      - --memory=2Gi
      - --cpu=2
      - --timeout=3600           # for long agent runs, override the default 300s Cloud Run timeout
      - --concurrency=10         # 10 concurrent requests per instance before a new instance is spun up
      - --set-secrets=OPENAI_API_KEY=openai-api-key:latest   # inject secret from Secret Manager at runtime

Cloud Run scales to zero automatically, bills per request-second, and handles all infrastructure. The tradeoff: cold start latency (typically 2-5 seconds for Python) and limited configuration versus Kubernetes.

The right choice: Cloud Run for startups and bursty workloads. Kubernetes for teams with existing cluster expertise, fine-grained control requirements, or multi-service architectures.

Knowledge Check

3 questions to test your understanding

1 An agent service has unpredictable load, some hours have 10 requests, others have 500. Which deployment approach handles this best?

2 Why is a rolling deployment strategy preferred over recreating all pods simultaneously for an agent service?

3 What should a Kubernetes readiness probe test for an agent service, and how does it differ from a liveness probe?

Discussion

Questions and notes from learners on this topic

Loading discussion…

Go further with expert guidance

Ready to build production AI?
Talk to our R&D team.

These courses give you the foundation. Our embedded AI teams take you from prototype to production in 30–90 days, with your team, your codebase, your goals. Book a free strategy call to see how we can accelerate your AI initiative.

30 minutes · No obligation · Expert AI engineers, not sales reps

AI Architecture Review

Audit your current stack and identify high-impact improvements

Project Review

Get expert feedback on your AI implementation and codebase

Team Mentoring

Upskill your engineers with hands-on AI coaching sessions

AI Strategy

Define your AI roadmap, prioritization, and implementation plan