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:
- Smaller Docker images: A Go application's Docker image can be built from
scratch(empty image) oralpine(5MB), with final images typically 10-30MB. By comparison, Python application images are usually 200-500MB, Node.js 100-300MB. - Faster deployments: A single binary transfers quickly, starts fast (typically milliseconds), and new instances are ready during scale-out.
- High environment determinism: The same binary behaves identically in development, testing, and production environments (assuming identical external dependencies like databases and configuration).
- 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:
- Cross-compilation: Build Linux/Windows versions on macOS with no cross-compilation toolchain required
- Build tags: Conditional compilation—different environments include different code
//go:embed: Compile static files (HTML, CSS, configuration) into the binaryldflags: Inject variable values at build time, such as version numbers and build timestamps- CGO control:
CGO_ENABLED=0disables CGO, ensuring completely static linking
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:
- Include the compilation toolchain in the final image: massive images (the Go compiler alone is ~300MB) with a large attack surface
- 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.