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-deploySave the code below as main.go.
Running It
go run . v1.3.0Expected 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.