Rolling Deploy Controller

A deployment controller that performs a rolling update across a fleet of instances with automatic rollback on failure. It deploys one instance at a time, verifies health after each update, and if consecutive failures exceed a threshold, rolls back only the instances that were modified. Exits with a non-zero code for CI integration.

To demonstrate rollback, the deploy function is wired to fail on app-3. The controller updates app-1 and app-2, fails twice on app-3 and app-4, then automatically rolls back app-1 and app-2 to the previous version.

Setup

No external dependencies. Uses only the standard library.

mkdir rolling-deploy && cd rolling-deploy
go mod init rolling-deploy

Save the code below as main.go.

Running It

go run . v1.3.0

Expected output:

2026/08/11 10:00:00 Starting rolling deploy: v1.2.0 -> v1.3.0
2026/08/11 10:00:00   Deploying app-1: v1.2.0 -> v1.3.0
2026/08/11 10:00:00     app-1 healthy
2026/08/11 10:00:00   Deploying app-2: v1.2.0 -> v1.3.0
2026/08/11 10:00:00     app-2 healthy
2026/08/11 10:00:00   Deploying app-3: v1.2.0 -> v1.3.0
2026/08/11 10:00:00     Deploy failed for app-3: simulated deploy failure
2026/08/11 10:00:00   Deploying app-4: v1.2.0 -> v1.3.0
2026/08/11 10:00:00     Deploy failed for app-4: simulated deploy failure
2026/08/11 10:00:00 Deploy failed: max failures (2) reached
2026/08/11 10:00:00 Initiating rollback for 2 instance(s)...
2026/08/11 10:00:00   Rolling back app-1 to v1.2.0
2026/08/11 10:00:00   Rolling back app-2 to v1.2.0
2026/08/11 10:00:00 Rollback complete.
Deploy rolled back. Updated then reverted: [app-1 app-2]
exit status 1
// Run: go run . v1.3.0
package main

import (
	"context"
	"fmt"
	"log"
	"net/http"
	"os"
	"time"
)

// Instance represents a deployable service instance.
type Instance struct {
	ID      string
	Address string
	Version string
}

// DeployResult holds the outcome of a rolling deploy.
type DeployResult struct {
	Status           string
	UpdatedInstances []string
}

// HealthChecker verifies instance health via HTTP.
type HealthChecker struct {
	Client   *http.Client
	Endpoint string
	Retries  int
	Interval time.Duration
}

// Check polls address/Endpoint until it returns 200 or Retries are exhausted.
// Respects ctx cancellation between retry attempts.
func (h *HealthChecker) Check(ctx context.Context, address string) error {
	url := "http://" + address + h.Endpoint
	for attempt := 0; attempt <= h.Retries; attempt++ {
		req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
		if err != nil {
			return err
		}
		resp, err := h.Client.Do(req)
		if err == nil && resp.StatusCode == http.StatusOK {
			resp.Body.Close()
			return nil
		}
		if resp != nil {
			resp.Body.Close()
		}
		if attempt < h.Retries {
			select {
			case <-ctx.Done():
				return ctx.Err()
			case <-time.After(h.Interval):
			}
		}
	}
	return fmt.Errorf("health check failed for %s after %d retries", address, h.Retries)
}

// RollingDeployer performs a rolling deploy with a failure threshold.
type RollingDeployer struct {
	Health    *HealthChecker
	MaxFailed int
}

// Deploy performs a rolling update, calling deployFn then health-checking each instance.
// Records an instance in UpdatedInstances as soon as deployFn succeeds, before health check,
// because the new version is already running and must be rolled back on failure.
// Aborts once MaxFailed consecutive failures are reached.
func (rd *RollingDeployer) Deploy(
	ctx context.Context,
	instances []Instance,
	newVersion string,
	deployFn func(ctx context.Context, inst Instance, version string) error,
) (*DeployResult, error) {
	result := &DeployResult{Status: "success"}
	consecutiveFails := 0

	for _, inst := range instances {
		log.Printf("  Deploying %s: %s -> %s", inst.ID, inst.Version, newVersion)

		if err := deployFn(ctx, inst, newVersion); err != nil {
			consecutiveFails++
			log.Printf("    Deploy failed for %s: %v", inst.ID, err)
		} else {
			// Record as updated before health check: new version is running
			// and must be rolled back even if health fails.
			result.UpdatedInstances = append(result.UpdatedInstances, inst.ID)

			if err := rd.Health.Check(ctx, inst.Address); err != nil {
				consecutiveFails++
				log.Printf("    Health check failed for %s: %v", inst.ID, err)
			} else {
				consecutiveFails = 0
				log.Printf("    %s healthy", inst.ID)
			}
		}

		if consecutiveFails >= rd.MaxFailed {
			result.Status = "failed"
			return result, fmt.Errorf("max failures (%d) reached", rd.MaxFailed)
		}
	}

	return result, nil
}

// Rollback reverts updated instances to a previous version.
type Rollback struct {
	PreviousVersion   string
	DeployFn          func(ctx context.Context, inst Instance, version string) error
	ModifiedInstances []Instance // only instances that were actually updated
	Health            *HealthChecker
}

// Execute reverts each instance in ModifiedInstances to PreviousVersion and verifies health.
// Only call this with the instances that were actually updated, not the full fleet.
func (rb *Rollback) Execute(ctx context.Context) error {
	for _, inst := range rb.ModifiedInstances {
		log.Printf("  Rolling back %s to %s", inst.ID, rb.PreviousVersion)
		if err := rb.DeployFn(ctx, inst, rb.PreviousVersion); err != nil {
			return fmt.Errorf("rollback failed for %s: %w", inst.ID, err)
		}
		if err := rb.Health.Check(ctx, inst.Address); err != nil {
			return fmt.Errorf("rollback health check failed for %s: %w", inst.ID, err)
		}
	}
	return nil
}

// startHealthServer starts a minimal HTTP server on addr that returns 200 on Endpoint.
// Returns a shutdown function. Used to provide real health check targets in this demo.
func startHealthServer(addr, endpoint string) func() {
	mux := http.NewServeMux()
	mux.HandleFunc(endpoint, func(w http.ResponseWriter, r *http.Request) {
		w.WriteHeader(http.StatusOK)
	})
	srv := &http.Server{Addr: addr, Handler: mux}
	go func() { _ = srv.ListenAndServe() }()
	// Give the server a moment to start accepting connections.
	time.Sleep(20 * time.Millisecond)
	return func() { _ = srv.Close() }
}

func main() {
	const endpoint = "/healthz"

	// Start local health check servers for app-1 through app-4.
	for _, addr := range []string{
		"127.0.0.1:18081",
		"127.0.0.1:18082",
		"127.0.0.1:18083",
		"127.0.0.1:18084",
	} {
		stop := startHealthServer(addr, endpoint)
		defer stop()
	}

	instances := []Instance{
		{ID: "app-1", Address: "127.0.0.1:18081", Version: "v1.2.0"},
		{ID: "app-2", Address: "127.0.0.1:18082", Version: "v1.2.0"},
		{ID: "app-3", Address: "127.0.0.1:18083", Version: "v1.2.0"},
		{ID: "app-4", Address: "127.0.0.1:18084", Version: "v1.2.0"},
	}

	newVersion := "v1.3.0"
	if len(os.Args) > 1 {
		newVersion = os.Args[1]
	}

	health := &HealthChecker{
		Client:   &http.Client{Timeout: 2 * time.Second},
		Endpoint: endpoint,
		Retries:  1,
		Interval: 100 * time.Millisecond,
	}

	deployer := &RollingDeployer{
		Health:    health,
		MaxFailed: 2,
	}

	// Simulate a deploy that fails starting at app-3.
	// In production this calls Docker/K8s API.
	deployFn := func(ctx context.Context, inst Instance, version string) error {
		if inst.ID == "app-3" || inst.ID == "app-4" {
			return fmt.Errorf("simulated deploy failure")
		}
		return nil
	}

	ctx := context.Background()
	previousVersion := instances[0].Version

	log.Printf("Starting rolling deploy: %s -> %s", previousVersion, newVersion)

	result, err := deployer.Deploy(ctx, instances, newVersion, deployFn)
	if err != nil {
		log.Printf("Deploy failed: %v", err)
		log.Printf("Initiating rollback for %d instance(s)...", len(result.UpdatedInstances))

		// Build the []Instance slice for only the instances that were actually updated.
		var rollbackTargets []Instance
		for _, inst := range instances {
			for _, updated := range result.UpdatedInstances {
				if inst.ID == updated {
					rollbackTargets = append(rollbackTargets, inst)
					break
				}
			}
		}

		rb := &Rollback{
			PreviousVersion:   previousVersion,
			DeployFn:          deployFn,
			ModifiedInstances: rollbackTargets,
			Health:            health,
		}

		if rbErr := rb.Execute(ctx); rbErr != nil {
			log.Fatalf("CRITICAL: Rollback failed: %v", rbErr)
		}

		log.Printf("Rollback complete.")
		result.Status = "rolled_back"
		fmt.Printf("Deploy rolled back. Updated then reverted: %v\n", result.UpdatedInstances)
		os.Exit(1)
	}

	fmt.Printf("Deploy complete. Updated: %v\n", result.UpdatedInstances)
}

This controller handles the full lifecycle: attempt rolling deploy, detect failure threshold, automatically roll back affected instances, and exit with the appropriate status code for CI integration.

💻 Run locally

Copy the code above and run it on your machine

© 2026 ByteLearn.dev. Free courses for developers. · Privacy