Chapter 48

Build and Deploy

Build and Deploy

In 2016, Uber's engineering team faced a deployment nightmare. They had hundreds of microservices, each with complex runtime dependencies—Python needed a specific interpreter version, Node.js needed node_modules, Java needed the JVM and specific JAR files. When a new machine joined the cluster, the deployment process had to install and configure all these dependencies, taking minutes and frequently failing due to version mismatches.

They started rewriting critical services in Go. The first transformation was immediate: deploying a new service became "copy a binary to the server, then run it." No dependency installation, no runtime configuration, no "it works on my machine" problems. Go service deployment time compressed from several minutes to a few seconds.

This is the core of Go's deployment philosophy: compilation equals packaging. Go's static-linking compiler compiles all dependencies (including the standard library) into a single standalone executable. That file runs directly on the target platform with no external runtime environment whatsoever.

Level 1 · What You Need to Know

Go's Deployment Advantage: Single Static Binary

Understanding Go's deployment advantage requires understanding the deployment challenges other languages face:

Python deployment complexity: Requires installing a specific Python interpreter version (2.x vs 3.x), installing all third-party packages with pip or poetry, resolving conflicts between the system Python and virtual environments, and ensuring all package versions match the development environment. requirements.txt is often imprecise; pip install in production can install different sub-dependency versions.

Node.js deployment complexity: The node_modules directory typically contains tens of thousands of files totaling hundreds of megabytes. Every deployment requires re-running npm install; network failures cause deployment failures. package-lock.json partially mitigates but doesn't fully solve version locking.

Java deployment complexity: Requires installing the JVM (with matching version), various JAR dependencies, and classpath configuration. Traditional JAR files don't include all dependencies; you need additional tooling (like Maven's fat JAR) to create standalone deployable artifacts.

Go's deployment reality:

# Compile
GOOS=linux GOARCH=amd64 go build -o myapp .

# Deploy: copy this single file to the server
scp myapp user@server:/usr/local/bin/

# Run
ssh user@server "myapp --port 8080"

That's it. The server doesn't need any Go runtime installed, no dependency managers, not even internet connectivity (it runs perfectly offline).

Why Go makes DevOps simpler:

  1. Smaller Docker images: A Go application's Docker image can be built from scratch (empty image) or alpine (5MB), with final images typically 10-30MB. By comparison, Python application images are usually 200-500MB, Node.js 100-300MB.
  2. Faster deployments: A single binary transfers quickly, starts fast (typically milliseconds), and new instances are ready during scale-out.
  3. High environment determinism: The same binary behaves identically in development, testing, and production environments (assuming identical external dependencies like databases and configuration).
  4. Simple rollbacks: Keep the previous binary, and rollback means replacing the file and restarting.

Build System Overview

Go's build system is driven by the go build command and supports several advanced features:


Level 2 · Principles and Mechanisms

The Design Philosophy Behind Multi-Stage Dockerfiles

Multi-stage Docker builds solve a fundamental tension: building requires a complete toolchain (Go compiler, dependencies), but running only requires the compiled binary.

Before multi-stage builds, there were two poor options:

  1. Include the compilation toolchain in the final image: massive images (the Go compiler alone is ~300MB) with a large attack surface
  2. Compile in CI and COPY into a minimal image: complex workflow, difficult to reproduce the build environment

Multi-stage builds combine the best of both:

# === Stage 1: Build ===
# Uses the full Go compilation environment; this stage's image doesn't ship
FROM golang:1.22-alpine AS builder

# Install build dependencies (gcc needed for CGO)
RUN apk add --no-cache git ca-certificates tzdata

WORKDIR /app

# Copy go.mod and go.sum first to leverage Docker layer caching.
# As long as dependencies don't change, this step hits the cache.
COPY go.mod go.sum ./
RUN go mod download

# Copy source code
COPY . .

# Build arguments
ARG VERSION=dev
ARG GIT_COMMIT=unknown
ARG BUILD_TIME=unknown

# Build the binary:
# CGO_ENABLED=0  ensures fully static linking (no system libc dependency)
# -trimpath      removes local path info (security and reproducibility)
# -ldflags       injects version metadata
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \
    go build \
    -trimpath \
    -ldflags="-w -s \
              -X main.Version=${VERSION} \
              -X main.GitCommit=${GIT_COMMIT} \
              -X main.BuildTime=${BUILD_TIME}" \
    -o /app/myapp \
    ./cmd/server

# === Stage 2: Final Image ===
# scratch is Docker's empty image with no filesystem layers at all
FROM scratch

# Copy necessary files from the build stage.
# ca-certificates: required for HTTPS requests
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
# Timezone data: required by time.LoadLocation()
COPY --from=builder /usr/share/zoneinfo /usr/share/zoneinfo
# Copy only the binary
COPY --from=builder /app/myapp /myapp

# Run as non-root user (scratch has no /etc/passwd, use numeric UID)
USER 65534:65534

EXPOSE 8080

ENTRYPOINT ["/myapp"]

Why -w -s?

-w disables DWARF debug information; -s strips the symbol table. Neither affects program execution, but they reduce binary size by 20-30%. In production you'll want these for pprof profiling, but typically in dedicated profiling builds.

The Mechanics of Cross-Compilation

Go's cross-compilation is so simple because of its toolchain design: the Go compiler itself does not depend on the target platform's toolchain (when CGO_ENABLED=0).

GOOS and GOARCH are environment variables passed to the Go toolchain:

# Common combinations
GOOS=linux   GOARCH=amd64  # x86_64 Linux (most common)
GOOS=linux   GOARCH=arm64  # ARM64 Linux (AWS Graviton, Raspberry Pi 4)
GOOS=darwin  GOARCH=amd64  # Intel Mac
GOOS=darwin  GOARCH=arm64  # Apple Silicon Mac
GOOS=windows GOARCH=amd64  # 64-bit Windows
GOOS=linux   GOARCH=386    # 32-bit Linux (embedded systems)

# Build all platforms in one pass (common in CI)
for GOOS in linux darwin windows; do
    for GOARCH in amd64 arm64; do
        output="myapp-${GOOS}-${GOARCH}"
        [ "$GOOS" = "windows" ] && output="${output}.exe"
        GOOS=$GOOS GOARCH=$GOARCH go build -o "$output" .
    done
done

When CGO_ENABLED=1 (when calling C code), cross-compilation becomes complex: you need the target platform's C cross-compilation toolchain (e.g., aarch64-linux-gnu-gcc). This is why you should prefer pure Go implementations over C dependencies wherever possible.

Build Tags

Build tags are Go's conditional compilation mechanism, allowing different source files to be included based on conditions (operating system, architecture, custom tags):

//go:build linux
// +build linux  // Old syntax (pre-Go 1.17), still supported

package syscallutil

// This file only compiles on Linux
//go:build !production

package config

// Development and test configuration; excluded from production builds
func DefaultConfig() Config {
    return Config{
        Debug:    true,
        LogLevel: "debug",
    }
}
# Build with the production tag
go build -tags production ./...

# Use specific tags in tests
go test -tags integration ./...

Build tags are also used for platform-specific code: files named file_linux.go, file_darwin.go, file_windows.go are automatically recognized by the Go toolchain as platform-specific, with no explicit tag required.

//go:embed for Embedding Static Assets

The go:embed directive (introduced in Go 1.16) allows files or directory contents to be compiled into the binary, completely eliminating the dependency on "static files must be deployed alongside the binary":

package main

import (
    "embed"
    "io/fs"
    "net/http"
    "html/template"
)

// Embed a single file
//go:embed version.txt
var versionFile string

// Embed an entire directory (including subdirectories)
//go:embed static
var staticFiles embed.FS

// Embed HTML templates
//go:embed templates/*.html
var templates embed.FS

func main() {
    // Serve embedded files as an HTTP file server
    staticFS, _ := fs.Sub(staticFiles, "static")
    http.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.FS(staticFS))))

    // Use embedded templates
    tmpl := template.Must(template.ParseFS(templates, "templates/*.html"))
    _ = tmpl
}

How embedded files work: go build reads the specified files at compile time and encodes their content as byte data in the binary's data segment. Accessing these files at runtime is accessing in-memory data—extremely fast and requiring no filesystem access.

ldflags for Version Injection

Being able to query version information from a running program is a basic requirement for any released software. ldflags provides a mechanism to inject variable values at build time, eliminating the need to hard-code them in source:

// main.go
package main

import (
    "fmt"
    "os"
)

// These variables are injected at build time by ldflags.
// Default values are for development environments.
var (
    Version   = "dev"
    GitCommit = "unknown"
    BuildTime = "unknown"
)

func main() {
    if len(os.Args) > 1 && os.Args[1] == "version" {
        fmt.Printf("Version:    %s\n", Version)
        fmt.Printf("Git Commit: %s\n", GitCommit)
        fmt.Printf("Build Time: %s\n", BuildTime)
        os.Exit(0)
    }
    // ... normal startup logic
}
# Inject at build time
go build \
  -ldflags="-X main.Version=v1.2.3 \
            -X main.GitCommit=$(git rev-parse --short HEAD) \
            -X main.BuildTime=$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
  -o myapp .

# Run
./myapp version
# Output:
# Version:    v1.2.3
# Git Commit: a1b2c3d
# Build Time: 2024-01-15T08:30:00Z

Level 3 · Code Practice

Complete GitHub Actions CI/CD Pipeline

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

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

env:
  GO_VERSION: "1.22"
  REGISTRY: ghcr.io
  IMAGE_NAME: ${{ github.repository }}

jobs:
  # ── Code Quality ───────────────────────────────────────────
  lint:
    name: Lint
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-go@v5
        with:
          go-version: ${{ env.GO_VERSION }}

      - name: Run go vet
        run: go vet ./...

      - name: Run golangci-lint
        uses: golangci/golangci-lint-action@v4
        with:
          version: latest
          args: --timeout=5m

  # ── Tests ──────────────────────────────────────────────────
  test:
    name: Test
    runs-on: ubuntu-latest
    services:
      postgres:
        image: postgres:16-alpine
        env:
          POSTGRES_DB: testdb
          POSTGRES_USER: testuser
          POSTGRES_PASSWORD: testpass
        options: >-
          --health-cmd pg_isready
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5
        ports:
          - 5432:5432

    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-go@v5
        with:
          go-version: ${{ env.GO_VERSION }}
          cache: true

      - name: Run tests with race detector
        run: go test -race -coverprofile=coverage.out -covermode=atomic ./...
        env:
          TEST_DB_URL: postgres://testuser:testpass@localhost:5432/testdb?sslmode=disable

      - name: Upload coverage to Codecov
        uses: codecov/codecov-action@v4
        with:
          file: ./coverage.out

  # ── Docker Build ────────────────────────────────────────────
  build:
    name: Build and Push Docker Image
    runs-on: ubuntu-latest
    needs: [lint, test]
    if: github.ref == 'refs/heads/main'
    permissions:
      contents: read
      packages: write

    outputs:
      image-digest: ${{ steps.build.outputs.digest }}

    steps:
      - uses: actions/checkout@v4

      - name: Log in to Container Registry
        uses: docker/login-action@v3
        with:
          registry: ${{ env.REGISTRY }}
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Extract metadata
        id: meta
        uses: docker/metadata-action@v5
        with:
          images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
          tags: |
            type=sha,prefix=sha-
            type=raw,value=latest,enable={{is_default_branch}}

      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3

      - name: Build and push
        id: build
        uses: docker/build-push-action@v5
        with:
          context: .
          push: true
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}
          # Enable build cache to dramatically speed up subsequent builds
          cache-from: type=gha
          cache-to: type=gha,mode=max
          build-args: |
            VERSION=${{ github.ref_name }}
            GIT_COMMIT=${{ github.sha }}
            BUILD_TIME=${{ github.event.head_commit.timestamp }}

  # ── Deploy to Server ────────────────────────────────────────
  deploy:
    name: Deploy to Production
    runs-on: ubuntu-latest
    needs: [build]
    environment: production

    steps:
      - name: Deploy via SSH
        uses: appleboy/ssh-action@v1
        with:
          host: ${{ secrets.SERVER_HOST }}
          username: ${{ secrets.SERVER_USER }}
          key: ${{ secrets.SERVER_SSH_KEY }}
          script: |
            docker pull ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
            
            docker service update \
              --image ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest \
              --update-parallelism 1 \
              --update-delay 10s \
              myapp_service

systemd Service File

For traditional deployments without Docker, systemd provides stable process management:

# /etc/systemd/system/myapp.service
[Unit]
Description=MyApp Go Server
Documentation=https://github.com/myorg/myapp
After=network-online.target
Wants=network-online.target

[Service]
Type=exec
User=myapp
Group=myapp
WorkingDirectory=/opt/myapp

# Load environment variables from file (keeps secrets out of unit file)
EnvironmentFile=/etc/myapp/config.env

ExecStart=/opt/myapp/myapp --config /etc/myapp/config.yaml

# Zero-downtime reload (Go app must implement graceful shutdown)
ExecReload=/bin/kill -HUP $MAINPID

# Restart policy
Restart=on-failure
RestartSec=5s
StartLimitInterval=60s
StartLimitBurst=3

# Security hardening
NoNewPrivileges=yes
PrivateTmp=yes
ProtectSystem=strict
ReadWritePaths=/var/lib/myapp /var/log/myapp
ProtectHome=yes

# Resource limits
LimitNOFILE=65536
LimitNPROC=4096

# Graceful shutdown timeout
TimeoutStopSec=30s
KillMode=mixed
KillSignal=SIGTERM

[Install]
WantedBy=multi-user.target
#!/bin/bash
set -e

# 1. Upload new version
scp myapp-linux-amd64 myapp@server:/opt/myapp/myapp.new

# 2. Swap binaries atomically
ssh myapp@server "
  cp /opt/myapp/myapp /opt/myapp/myapp.old  # Keep old version for rollback
  mv /opt/myapp/myapp.new /opt/myapp/myapp
  chmod +x /opt/myapp/myapp
"

# 3. Restart service (systemd handles graceful shutdown)
ssh root@server "systemctl restart myapp"

# 4. Verify service health
sleep 2
ssh root@server "systemctl is-active myapp" || {
    echo "Deploy failed, rolling back..."
    ssh root@server "
        mv /opt/myapp/myapp.old /opt/myapp/myapp
        systemctl restart myapp
    "
    exit 1
}

echo "Deploy succeeded!"

Graceful Shutdown in Go

When systemd sends SIGTERM, the Go application must handle it correctly: finish in-flight requests, reject new ones, release resources:

package main

import (
    "context"
    "fmt"
    "net/http"
    "os"
    "os/signal"
    "syscall"
    "time"

    "go.uber.org/zap"
)

func main() {
    logger, _ := zap.NewProduction()
    defer logger.Sync()

    srv := &http.Server{
        Addr:    ":8080",
        Handler: setupRoutes(),
        // Set timeouts to prevent slow-client attacks
        ReadTimeout:    10 * time.Second,
        WriteTimeout:   30 * time.Second,
        IdleTimeout:    120 * time.Second,
        MaxHeaderBytes: 1 << 20, // 1MB
    }

    // Start server in a goroutine
    serverErr := make(chan error, 1)
    go func() {
        logger.Info("starting server", zap.String("addr", srv.Addr))
        if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
            serverErr <- err
        }
    }()

    // Wait for signals
    quit := make(chan os.Signal, 1)
    // SIGTERM: sent by systemd/Kubernetes for normal termination
    // SIGINT:  Ctrl+C
    // SIGHUP:  traditionally used for config reload
    signal.Notify(quit, syscall.SIGTERM, syscall.SIGINT, syscall.SIGHUP)

    select {
    case err := <-serverErr:
        logger.Fatal("server failed to start", zap.Error(err))
    case sig := <-quit:
        logger.Info("received signal, shutting down", zap.String("signal", sig.String()))
    }

    // Graceful shutdown: give in-flight requests up to 30 seconds to complete
    ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
    defer cancel()

    if err := srv.Shutdown(ctx); err != nil {
        logger.Error("graceful shutdown failed", zap.Error(err))
        os.Exit(1)
    }

    logger.Info("server stopped gracefully")
}

Nginx Reverse Proxy Configuration

# /etc/nginx/sites-available/myapp
upstream myapp {
    # Load balance across multiple instances
    server 127.0.0.1:8080;
    server 127.0.0.1:8081;
    
    # Maintain persistent connections to upstream
    keepalive 32;
}

server {
    listen 80;
    server_name example.com www.example.com;
    return 301 https://$server_name$request_uri;
}

server {
    listen 443 ssl http2;
    server_name example.com www.example.com;

    ssl_certificate     /etc/letsencrypt/live/example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
    ssl_session_timeout 1d;
    ssl_session_cache   shared:SSL:10m;
    ssl_protocols       TLSv1.2 TLSv1.3;
    ssl_prefer_server_ciphers off;

    add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;

    client_max_body_size 10m;

    location / {
        proxy_pass http://myapp;
        
        # Essential headers: let the Go app know the real client IP and protocol
        proxy_set_header Host              $host;
        proxy_set_header X-Real-IP         $remote_addr;
        proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        
        # WebSocket support (if needed)
        proxy_http_version 1.1;
        proxy_set_header Upgrade    $http_upgrade;
        proxy_set_header Connection "upgrade";
        
        # Timeouts
        proxy_connect_timeout 5s;
        proxy_send_timeout    60s;
        proxy_read_timeout    60s;
    }

    # Don't log health check requests
    location /health {
        proxy_pass http://myapp;
        access_log off;
    }

    # Serve static files directly from nginx (if not using go:embed)
    location /static/ {
        alias /opt/myapp/static/;
        expires 30d;
        add_header Cache-Control "public, immutable";
    }
}

Level 4 · Advanced and Edge Cases

GoReleaser: Multi-Platform Release Automation

GoReleaser is the most mature release tool in the Go ecosystem, automating multi-platform binary builds, checksum generation, GitHub Release creation, Homebrew tap publishing, and more:

# .goreleaser.yaml
version: 2

before:
  hooks:
    - go mod tidy
    - go generate ./...

builds:
  - id: myapp
    main: ./cmd/server
    binary: myapp
    
    env:
      - CGO_ENABLED=0
    
    goos:
      - linux
      - darwin
      - windows
    goarch:
      - amd64
      - arm64
    
    ignore:
      - goos: windows
        goarch: arm64
    
    ldflags:
      - -s -w
      - -X main.Version={{.Version}}
      - -X main.GitCommit={{.FullCommit}}
      - -X main.BuildTime={{.Date}}

archives:
  - format: tar.gz
    name_template: "{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}"
    format_overrides:
      - goos: windows
        format: zip

checksum:
  name_template: "checksums.txt"
  algorithm: sha256

changelog:
  sort: asc
  filters:
    exclude:
      - "^docs:"
      - "^test:"
      - "^chore:"

dockers:
  - image_templates:
      - "ghcr.io/myorg/myapp:{{ .Tag }}-amd64"
    use: buildx
    build_flag_templates:
      - "--platform=linux/amd64"
    
  - image_templates:
      - "ghcr.io/myorg/myapp:{{ .Tag }}-arm64"
    use: buildx
    build_flag_templates:
      - "--platform=linux/arm64"

docker_manifests:
  - name_template: "ghcr.io/myorg/myapp:{{ .Tag }}"
    image_templates:
      - "ghcr.io/myorg/myapp:{{ .Tag }}-amd64"
      - "ghcr.io/myorg/myapp:{{ .Tag }}-arm64"
# Test locally (no actual publishing)
goreleaser release --snapshot --clean

# Official release (requires GITHUB_TOKEN)
goreleaser release --clean

Kubernetes Deployment

For scenarios requiring container orchestration, here is a complete Kubernetes deployment configuration:

# deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: myapp
  namespace: production
spec:
  replicas: 3
  selector:
    matchLabels:
      app: myapp
  strategy:
    type: RollingUpdate
    rollingUpdate:
      maxSurge: 1         # At most 1 extra Pod above desired count
      maxUnavailable: 0   # Always maintain the desired number of available Pods
  template:
    metadata:
      labels:
        app: myapp
    spec:
      containers:
        - name: myapp
          image: ghcr.io/myorg/myapp:v1.2.3
          ports:
            - containerPort: 8080
          
          env:
            - name: DATABASE_URL
              valueFrom:
                secretKeyRef:
                  name: myapp-secrets
                  key: database-url
            - name: JWT_SECRET
              valueFrom:
                secretKeyRef:
                  name: myapp-secrets
                  key: jwt-secret
          
          # Liveness probe: fails → restart the Pod
          livenessProbe:
            httpGet:
              path: /health/live
              port: 8080
            initialDelaySeconds: 10
            periodSeconds: 30
            failureThreshold: 3
          
          # Readiness probe: not ready → no traffic
          readinessProbe:
            httpGet:
              path: /health/ready
              port: 8080
            initialDelaySeconds: 5
            periodSeconds: 10
            failureThreshold: 3
          
          # Resource limits (Go apps have predictable memory usage)
          resources:
            requests:
              memory: "64Mi"
              cpu: "100m"
            limits:
              memory: "256Mi"
              cpu: "500m"
      
      # Must align with the application's graceful shutdown timeout
      terminationGracePeriodSeconds: 30

---
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: myapp-hpa
  namespace: production
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: myapp
  minReplicas: 3
  maxReplicas: 20
  metrics:
    - type: Resource
      resource:
        name: cpu
        target:
          type: Utilization
          averageUtilization: 60
    - type: Resource
      resource:
        name: memory
        target:
          type: Utilization
          averageUtilization: 70
  behavior:
    scaleUp:
      stabilizationWindowSeconds: 60
      policies:
        - type: Percent
          value: 100
          periodSeconds: 60
    scaleDown:
      stabilizationWindowSeconds: 300  # 5-minute cooldown to prevent thrashing

Health Check Endpoint Implementation

Kubernetes liveness and readiness probes require the application to expose health endpoints:

package health

import (
    "context"
    "encoding/json"
    "net/http"
    "time"
)

type HealthChecker struct {
    db    DBPinger
    redis RedisPinger
}

type HealthStatus struct {
    Status    string            `json:"status"`
    Timestamp string            `json:"timestamp"`
    Checks    map[string]string `json:"checks,omitempty"`
}

// LivenessHandler: is the application running?
// Returns non-200 only when the app enters an unrecoverable state.
func (h *HealthChecker) LivenessHandler(w http.ResponseWriter, r *http.Request) {
    w.Header().Set("Content-Type", "application/json")
    json.NewEncoder(w).Encode(HealthStatus{
        Status:    "ok",
        Timestamp: time.Now().UTC().Format(time.RFC3339),
    })
}

// ReadinessHandler: is the application ready to receive traffic?
// Returns 503 when the database is disconnected or dependencies are unavailable.
func (h *HealthChecker) ReadinessHandler(w http.ResponseWriter, r *http.Request) {
    checks := make(map[string]string)
    allOk := true

    ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
    defer cancel()

    if err := h.db.PingContext(ctx); err != nil {
        checks["database"] = "unhealthy: " + err.Error()
        allOk = false
    } else {
        checks["database"] = "ok"
    }

    if err := h.redis.Ping(ctx); err != nil {
        checks["redis"] = "unhealthy: " + err.Error()
        allOk = false
    } else {
        checks["redis"] = "ok"
    }

    status := HealthStatus{
        Status:    "ok",
        Timestamp: time.Now().UTC().Format(time.RFC3339),
        Checks:    checks,
    }

    if !allOk {
        status.Status = "unhealthy"
        w.WriteHeader(http.StatusServiceUnavailable)
    }

    w.Header().Set("Content-Type", "application/json")
    json.NewEncoder(w).Encode(status)
}

Canary Deployment Strategy

Canary deployment sends new versions to a small subset of traffic first, then promotes to full rollout after validation:

# Kubernetes canary: two Deployments sharing the same Service selector
# stable: 9 Pods (90% traffic)
# canary: 1 Pod  (10% traffic)

apiVersion: apps/v1
kind: Deployment
metadata:
  name: myapp-canary
  labels:
    app: myapp
    track: canary
spec:
  replicas: 1  # 1/(9+1) = 10% traffic
  selector:
    matchLabels:
      app: myapp
      track: canary
  template:
    metadata:
      labels:
        app: myapp
        track: canary
    spec:
      containers:
        - name: myapp
          image: ghcr.io/myorg/myapp:v1.3.0  # new version

Automated canary analysis in CI/CD:

#!/bin/bash
# canary-deploy.sh

CANARY_IMAGE="ghcr.io/myorg/myapp:$1"
STABLE_IMAGE="ghcr.io/myorg/myapp:stable"
ANALYSIS_DURATION=300  # 5 minutes

echo "Deploying canary: $CANARY_IMAGE"

kubectl set image deployment/myapp-canary myapp="$CANARY_IMAGE"
kubectl rollout status deployment/myapp-canary

echo "Monitoring canary for ${ANALYSIS_DURATION}s..."
sleep $ANALYSIS_DURATION

# Check error rate from Prometheus
ERROR_RATE=$(curl -s "http://prometheus:9090/api/v1/query" \
  --data-urlencode 'query=rate(http_requests_total{status=~"5..",track="canary"}[5m]) / rate(http_requests_total{track="canary"}[5m])' \
  | jq -r '.data.result[0].value[1]')

if (( $(echo "$ERROR_RATE > 0.01" | bc -l) )); then
    echo "Canary error rate too high ($ERROR_RATE), rolling back..."
    kubectl set image deployment/myapp-canary myapp="$STABLE_IMAGE"
    exit 1
fi

echo "Canary analysis passed, promoting to stable..."
kubectl set image deployment/myapp-stable myapp="$CANARY_IMAGE"
kubectl rollout status deployment/myapp-stable

docker tag "$CANARY_IMAGE" "$STABLE_IMAGE"
docker push "$STABLE_IMAGE"

Rollback Procedures

#!/bin/bash
# rollback.sh: quickly revert to the previous version

# Kubernetes rollback using Deployment rollout history
kubectl rollout undo deployment/myapp

# Roll back to a specific revision
kubectl rollout undo deployment/myapp --to-revision=3

# View rollout history
kubectl rollout history deployment/myapp

# Confirm rollback is complete
kubectl rollout status deployment/myapp

# For systemd deployments, rollback means swapping the binary
ssh root@server "
  systemctl stop myapp
  mv /opt/myapp/myapp /opt/myapp/myapp.failed
  mv /opt/myapp/myapp.old /opt/myapp/myapp
  systemctl start myapp
  systemctl is-active myapp
"

Go's build and deployment ecosystem embodies the language's core values: complexity should be hidden inside tools, not exposed to users. Cross-compilation in one command, multi-stage Docker builds in a dozen lines of configuration, health checks in a few dozen lines of code—these mechanisms combined transform Go application deployment from a complex operation requiring dedicated DevOps engineers into a daily task any developer can master. Once you have these tools in hand, your code can travel steadily from go run main.go on your laptop all the way to users worldwide.

Rate this chapter
4.6  / 5  (3 ratings)

💬 Comments