Class: Gjallarhorn::Deployment::ZeroDowntime

Inherits:
Strategy
  • Object
show all
Defined in:
lib/gjallarhorn/deployment/zero_downtime.rb

Overview

Zero-downtime deployment strategy

Implements zero-downtime deployments by:

  1. Starting new containers alongside existing ones
  2. Waiting for new containers to pass health checks
  3. Switching proxy traffic to new containers
  4. Gracefully stopping old containers

This ensures continuous service availability during deployments.

Since:

  • 0.1.0

Instance Attribute Summary

Attributes inherited from Strategy

#adapter, #logger, #proxy_manager

Instance Method Summary collapse

Methods inherited from Strategy

#initialize, #name

Constructor Details

This class inherits a constructor from Gjallarhorn::Deployment::Strategy

Instance Method Details

#build_container_labels(service, environment) ⇒ Hash (private)

Build container labels for identification and management

Parameters:

  • service (Hash)

    Service configuration

  • environment (String)

    Target environment

Returns:

  • (Hash)

    Container labels

Since:

  • 0.1.0



191
192
193
194
195
196
197
198
199
# File 'lib/gjallarhorn/deployment/zero_downtime.rb', line 191

def build_container_labels(service, environment)
  {
    "gjallarhorn.service" => service[:name],
    "gjallarhorn.environment" => environment,
    "gjallarhorn.role" => service[:role] || "web",
    "gjallarhorn.deployed_at" => Time.now.utc.iso8601,
    "gjallarhorn.strategy" => "zero_downtime"
  }
end

#build_environment_variables(service, environment) ⇒ Hash (private)

Build environment variables for container

Parameters:

  • service (Hash)

    Service configuration

  • environment (String)

    Target environment

Returns:

  • (Hash)

    Environment variables

Since:

  • 0.1.0



171
172
173
174
175
176
177
178
179
180
181
182
183
184
# File 'lib/gjallarhorn/deployment/zero_downtime.rb', line 171

def build_environment_variables(service, environment)
  env_vars = {
    "GJALLARHORN_SERVICE" => service[:name],
    "GJALLARHORN_ENVIRONMENT" => environment,
    "GJALLARHORN_DEPLOYED_AT" => Time.now.utc.iso8601
  }

  # Add service-specific environment variables
  # Handle both string and symbol keys from YAML
  service_env = service[:env] || service["env"]
  env_vars.merge!(service_env) if service_env

  env_vars
end

#cleanup_old_containers(service_name, exclude_container_id, keep_count = 2) ⇒ void (private)

This method returns an undefined value.

Clean up old containers, keeping a configurable number for rollback

Parameters:

  • service_name (String)

    Service name

  • exclude_container_id (String)

    Container ID to exclude from cleanup

  • keep_count (Integer) (defaults to: 2)

    Number of old containers to keep

Since:

  • 0.1.0



254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
# File 'lib/gjallarhorn/deployment/zero_downtime.rb', line 254

def cleanup_old_containers(service_name, exclude_container_id, keep_count = 2)
  all_containers = @adapter.get_all_containers(service_name)
  old_containers = all_containers.reject { |c| c[:id] == exclude_container_id }

  # Sort by creation time (newest first) and keep only the specified count
  containers_to_remove = old_containers.sort_by { |c| c[:created_at] }.reverse.drop(keep_count)

  containers_to_remove.each do |container|
    @logger.info "Removing old container: #{container[:name]} (#{container[:id]})"
    @adapter.remove_container(container[:id])
  end

  @logger.info "Cleaned up #{containers_to_remove.length} old containers" if containers_to_remove.any?
rescue StandardError => e
  @logger.warn "Failed to cleanup old containers: #{e.message}"
  # Don't fail deployment if cleanup fails
end

#deploy(image:, environment:, services:) ⇒ void

This method returns an undefined value.

Deploy services with zero downtime

Parameters:

  • image (String)

    Container image to deploy

  • environment (String)

    Target environment

  • services (Array<Hash>)

    Services to deploy

Since:

  • 0.1.0



25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
# File 'lib/gjallarhorn/deployment/zero_downtime.rb', line 25

def deploy(image:, environment:, services:)
  @logger.info "Starting zero-downtime deployment of #{image} to #{environment}"

  # Set the environment on the adapter so it knows which environment we're deploying to
  @adapter.set_environment(environment) if @adapter.respond_to?(:set_environment)

  deployment_results = []

  services.each do |service|
    # Convert string keys to symbols for consistency
    service = service.transform_keys(&:to_sym) if service.is_a?(Hash)
    @logger.info "Deploying service: #{service[:name]}"
    result = deploy_service_zero_downtime(service, image, environment)
    deployment_results << result
  end

  @logger.info "Zero-downtime deployment completed successfully"
  deployment_results
end

#deploy_service_zero_downtime(service, image, environment) ⇒ Hash (private)

Deploy a single service with zero downtime

Parameters:

  • service (Hash)

    Service configuration

  • image (String)

    Container image to deploy

  • environment (String)

    Target environment

Returns:

  • (Hash)

    Deployment result

Since:

  • 0.1.0



60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
# File 'lib/gjallarhorn/deployment/zero_downtime.rb', line 60

def deploy_service_zero_downtime(service, image, environment)
  # Ensure service hash uses symbol keys
  service = service.transform_keys(&:to_sym) if service.is_a?(Hash)
  service_name = service[:name]

  # Step 1: Get current running containers
  current_containers = get_current_containers(service_name)
  @logger.info "Found #{current_containers.length} existing containers for #{service_name}"

  # Step 2: Start new container
  new_container = start_new_container(service, image, environment)
  @logger.info "Started new container: #{new_container[:name]} (#{new_container[:id]})"

  # Step 3: Wait for new container to be healthy
  if service[:healthcheck]
    @logger.info "Waiting for health check to pass..."
    wait_for_container_health(new_container, service[:healthcheck])
  else
    @logger.info "No health check configured, waiting for container to be running..."
    wait_for_container_running(new_container)
  end

  # Step 4: Update proxy routing to new container
  if @proxy_manager
    @logger.info "Switching proxy traffic to new container..."
    @proxy_manager.switch_traffic(
      service_name: service_name,
      from_containers: current_containers,
      to_container: new_container
    )
  else
    @logger.warn "No proxy manager configured, skipping traffic switch"
  end

  # Step 5: Gracefully stop old containers
  if current_containers.any?
    @logger.info "Stopping #{current_containers.length} old containers..."
    stop_old_containers(current_containers, service[:drain_timeout] || 30)
  end

  # Step 6: Clean up old containers
  cleanup_old_containers(service_name, new_container[:id])

  {
    service: service_name,
    old_containers: current_containers.map { |c| c[:id] },
    new_container: new_container[:id],
    status: "success"
  }
rescue StandardError => e
  @logger.error "Failed to deploy #{service_name}: #{e.message}"

  # Cleanup: Remove the new container if deployment failed
  if defined?(new_container) && new_container
    @logger.info "Cleaning up failed deployment container..."
    @adapter.stop_container(new_container[:id], graceful: false)
  end

  raise DeploymentError, "Zero-downtime deployment failed for #{service_name}: #{e.message}"
end

#get_current_containers(service_name) ⇒ Array<Hash> (private)

Get currently running containers for a service

Parameters:

  • service_name (String)

    Service name

Returns:

  • (Array<Hash>)

    Array of container information

Since:

  • 0.1.0



125
126
127
128
129
130
131
132
# File 'lib/gjallarhorn/deployment/zero_downtime.rb', line 125

def get_current_containers(service_name)
  @logger.debug "get_current_containers: Calling adapter.get_running_containers for service: #{service_name}"
  @adapter.get_running_containers(service_name)
rescue StandardError => e
  @logger.warn "Failed to get current containers for #{service_name}: #{e.message}"
  @logger.debug "get_current_containers error backtrace: #{e.backtrace.join("\n")}"
  []
end

#start_new_container(service, image, environment) ⇒ Hash (private)

Start a new container for the service

Parameters:

  • service (Hash)

    Service configuration

  • image (String)

    Container image

  • environment (String)

    Target environment

Returns:

  • (Hash)

    New container information

Since:

  • 0.1.0



140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
# File 'lib/gjallarhorn/deployment/zero_downtime.rb', line 140

def start_new_container(service, image, environment)
  container_name = generate_container_name(service[:name])

  container_config = {
    name: container_name,
    image: image,
    ports: service[:ports] || [],
    env: build_environment_variables(service, environment),
    volumes: service[:volumes] || [],
    command: service[:cmd],
    labels: build_container_labels(service, environment),
    restart_policy: service[:restart_policy] || "unless-stopped"
  }

  @logger.debug "start_new_container: Calling adapter.start_container with config: #{container_config.inspect}"
  begin
    result = @adapter.start_container(container_config)
    @logger.debug "start_new_container: Result: #{result.inspect}"
    result
  rescue StandardError => e
    @logger.error "start_new_container failed: #{e.message}"
    @logger.debug "start_new_container error backtrace: #{e.backtrace.join("\n")}"
    raise
  end
end

#stop_old_containers(containers, drain_timeout = 30) ⇒ void (private)

This method returns an undefined value.

Gracefully stop old containers

Parameters:

  • containers (Array<Hash>)

    Containers to stop

  • drain_timeout (Integer) (defaults to: 30)

    Time to wait for graceful shutdown

Since:

  • 0.1.0



233
234
235
236
237
238
239
240
241
242
243
244
245
246
# File 'lib/gjallarhorn/deployment/zero_downtime.rb', line 233

def stop_old_containers(containers, drain_timeout = 30)
  containers.each do |container|
    @logger.info "Stopping container: #{container[:name]} (#{container[:id]})"

    begin
      # Give container time to finish current requests
      @adapter.stop_container(container[:id], graceful: true, timeout: drain_timeout)
      @logger.info "Successfully stopped container: #{container[:name]}"
    rescue StandardError => e
      @logger.error "Failed to stop container #{container[:name]}: #{e.message}"
      # Continue with other containers even if one fails
    end
  end
end

#wait_for_container_running(container, timeout = 60) ⇒ Boolean (private)

Wait for container to be in running state

Parameters:

  • container (Hash)

    Container information

  • timeout (Integer) (defaults to: 60)

    Timeout in seconds

Returns:

  • (Boolean)

    True when container is running

Since:

  • 0.1.0



206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
# File 'lib/gjallarhorn/deployment/zero_downtime.rb', line 206

def wait_for_container_running(container, timeout = 60)
  start_time = Time.now

  loop do
    status = @adapter.get_container_status(container[:id])

    if status == "running"
      @logger.info "Container #{container[:name]} is running"
      return true
    end

    elapsed = Time.now - start_time
    if elapsed >= timeout
      raise DeploymentError,
            "Container #{container[:name]} failed to start within #{timeout}s (status: #{status})"
    end

    @logger.debug "Container #{container[:name]} status: #{status}, waiting..."
    sleep 2
  end
end

#zero_downtime?Boolean

Check if strategy supports zero-downtime deployments

Returns:

  • (Boolean)

    Always true for this strategy

Since:

  • 0.1.0



48
49
50
# File 'lib/gjallarhorn/deployment/zero_downtime.rb', line 48

def zero_downtime?
  true
end