Zero-Downtime Blue-Green Deployment Pipelines for Ruby Applications on DigitalOcean
Understanding the Blue-Green Deployment Pattern
The Blue-Green deployment strategy is a technique for releasing new versions of software with zero downtime. It involves maintaining two identical production environments, referred to as “Blue” and “Green.” At any given time, one environment (e.g., Blue) is running the current production version, while the other (Green) is idle or running a previous version. To deploy a new version, we provision the idle environment (Green) with the new code, test it thoroughly, and then switch the traffic from the Blue environment to the Green environment. The Blue environment then becomes the idle environment, ready for the next deployment.
Leveraging DigitalOcean Load Balancers and Droplets
For this setup on DigitalOcean, we’ll utilize Load Balancers to direct traffic and multiple Droplets to host our Ruby application. The core idea is to have two distinct sets of Droplets, each capable of running the full application stack. The Load Balancer will be configured to point to one of these sets. During a deployment, we’ll update the *other* set of Droplets and then reconfigure the Load Balancer to switch traffic.
Infrastructure Setup: Two Application Stacks
We need two identical sets of Droplets. For simplicity, let’s assume a basic Rails application with a web server (e.g., Nginx) and a database. In a production scenario, you’d likely have multiple Droplets per environment for high availability, but for this example, we’ll focus on the traffic switching mechanism.
Environment Blue:
- Droplet 1 (Web Server/App): `app-blue-1`
- Droplet 2 (Web Server/App): `app-blue-2`
Environment Green:
- Droplet 1 (Web Server/App): `app-green-1`
- Droplet 2 (Web Server/App): `app-green-2`
A single managed DigitalOcean Database instance will serve both environments. This simplifies database management but requires careful consideration during schema migrations.
Configuring the DigitalOcean Load Balancer
We’ll create a Load Balancer that initially points to the “Blue” Droplets. The health check is crucial for ensuring traffic is only sent to healthy instances.
Load Balancer Configuration (Conceptual):
- Frontend: Port 80 (HTTP), Port 443 (HTTPS)
- Backend Pool: Target Droplets `app-blue-1`, `app-blue-2`
- Health Check: HTTP GET request to `/health` endpoint on port 3000 (or your app’s port). Timeout: 5s, Interval: 10s, Healthy Threshold: 2, Unhealthy Threshold: 3.
The Load Balancer’s IP address will be the public-facing endpoint for your application.
Automating Deployments with Capistrano
Capistrano is a powerful tool for automating application deployment. We’ll configure it to deploy to either the Blue or Green environment based on a variable. This requires a slightly modified Capistrano setup.
First, ensure you have a `Capfile` and `deploy.rb` in your Rails application’s root directory. We’ll add a task to switch the target environment.
Capistrano `deploy.rb` Modifications
We’ll introduce a variable, `target_environment`, which can be set to `:blue` or `:green`. Capistrano will then use the appropriate server definitions.
# deploy.rb # ... other configurations ... set :application, 'my_ruby_app' set :repo_url, '[email protected]:your_username/my_ruby_app.git' # Define target environment: :blue or :green # This can be set via an environment variable or command-line argument set :target_environment, ENV.fetch('TARGET_ENV', :blue).to_sym # Define server roles based on the target environment set :servers, [] if fetch(:target_environment) == :blue set :servers, [ { host: 'app-blue-1.your_domain.com', roles: %w{app web} }, { host: 'app-blue-2.your_domain.com', roles: %w{app web} } ] else # :green set :servers, [ { host: 'app-green-1.your_domain.com', roles: %w{app web} }, { host: 'app-green-2.your_domain.com', roles: %w{app web} } ] end # ... other Capistrano configurations (e.g., linked_dirs, linked_files) ... namespace :deploy do desc 'Switch the Load Balancer to the Green environment' task :switch_to_green do on roles(:app) do # This is a placeholder. Actual implementation depends on DigitalOcean API client. # Example: execute("doctl loadbalancer update #{lb_id} --forwarding-rules '{\"entry_points\": [{\"protocol\": \"http\", \"port\": 80, \"targets\": [{\"port\": 80, \"service_name\": \"app-green-pool\"}]}]}'") puts "--- Switching Load Balancer to GREEN environment ---" puts "NOTE: This is a placeholder. Implement actual DO API call here." end end desc 'Switch the Load Balancer to the Blue environment' task :switch_to_blue do on roles(:app) do # This is a placeholder. Actual implementation depends on DigitalOcean API client. # Example: execute("doctl loadbalancer update #{lb_id} --forwarding-rules '{\"entry_points\": [{\"protocol\": \"http\", \"port\": 80, \"targets\": [{\"port\": 80, \"service_name\": \"app-blue-pool\"}]}]}'") puts "--- Switching Load Balancer to BLUE environment ---" puts "NOTE: This is a placeholder. Implement actual DO API call here." end end # Override the default deploy task to include switching logic # This is a simplified example. A more robust solution would involve # a separate task to deploy to the *inactive* environment first. # For this example, we assume the inactive environment is already updated. task :perform_deployment do # Deploy to the current target environment invoke 'deploy:updating' invoke 'deploy:published' end end # Add a task to deploy to the *inactive* environment namespace :deploy do desc 'Deploy to the inactive environment (for blue-green switch)' task :deploy_inactive do # Determine the inactive environment inactive_env = fetch(:target_environment) == :blue ? :green : :blue puts "--- Deploying to INACTIVE environment: #{inactive_env.to_s.upcase} ---" # Temporarily switch Capistrano's target to the inactive environment original_servers = fetch(:servers) original_target_env = fetch(:target_environment) set :target_environment, inactive_env if fetch(:target_environment) == :blue set :servers, [ { host: 'app-blue-1.your_domain.com', roles: %w{app web} }, { host: 'app-blue-2.your_domain.com', roles: %w{app web} } ] else # :green set :servers, [ { host: 'app-green-1.your_domain.com', roles: %w{app web} }, { host: 'app-green-2.your_domain.com', roles: %w{app web} } ] end # Perform the deployment to the inactive environment invoke 'deploy:updating' invoke 'deploy:published' # Restore original settings set :servers, original_servers set :target_environment, original_target_env puts "--- Deployment to inactive environment complete ---" end endDigitalOcean API Integration for Load Balancer Switching
The `switch_to_green` and `switch_to_blue` tasks are placeholders. You'll need to integrate with the DigitalOcean API to actually modify the Load Balancer's forwarding rules. This typically involves using the `doctl` CLI tool or a Ruby SDK.
First, ensure you have `doctl` installed and authenticated:
# Install doctl (example for macOS) brew install doctl # Authenticate doctl auth initYou'll need the ID of your Load Balancer and the names of your backend pools (e.g., `app-blue-pool`, `app-green-pool`). You can find these using `doctl compute loadbalancer list` and `doctl compute loadbalancer get <lb-id>`.
Here's how you might implement the switching tasks using `doctl`:
# deploy.rb (continued) # Replace with your actual Load Balancer ID and pool names set :do_lb_id, 'YOUR_LOAD_BALANCER_ID' set :do_blue_pool_name, 'app-blue-pool' # Or the actual name of your blue backend pool set :do_green_pool_name, 'app-green-pool' # Or the actual name of your green backend pool namespace :deploy do desc 'Switch the Load Balancer to the Green environment' task :switch_to_green do on roles(:app) do # This task doesn't strictly need to run on app roles, but it's a common place puts "--- Switching Load Balancer to GREEN environment ---" # Get current LB config lb_config = JSON.parse(`doctl compute loadbalancer get #{fetch(:do_lb_id)} --format JSON`) # Find and update the forwarding rule for HTTP (port 80) # This assumes a single HTTP forwarding rule. Adjust if you have multiple. lb_config['forwarding_rules'].each do |rule| if rule['entry_points'].any? { |ep| ep['protocol'] == 'http' && ep['port'] == 80 } rule['targets'].first['service_name'] = fetch(:do_green_pool_name) break end end # Update the Load Balancer # We need to pass the entire updated config as a JSON string. # This requires careful JSON serialization and escaping. # A simpler approach might be to use individual doctl commands if available for specific rule updates. # For demonstration, we'll simulate the update command. # In a real scenario, you'd construct the JSON payload carefully. puts "Simulating: doctl compute loadbalancer update #{fetch(:do_lb_id)} --forwarding-rules '#{JSON.dump(lb_config['forwarding_rules'])}'" # execute("doctl compute loadbalancer update #{fetch(:do_lb_id)} --forwarding-rules '#{JSON.dump(lb_config['forwarding_rules'])}'") end end desc 'Switch the Load Balancer to the Blue environment' task :switch_to_blue do on roles(:app) do puts "--- Switching Load Balancer to BLUE environment ---" lb_config = JSON.parse(`doctl compute loadbalancer get #{fetch(:do_lb_id)} --format JSON`) lb_config['forwarding_rules'].each do |rule| if rule['entry_points'].any? { |ep| ep['protocol'] == 'http' && ep['port'] == 80 } rule['targets'].first['service_name'] = fetch(:do_blue_pool_name) break end end puts "Simulating: doctl compute loadbalancer update #{fetch(:do_lb_id)} --forwarding-rules '#{JSON.dump(lb_config['forwarding_rules'])}'" # execute("doctl compute loadbalancer update #{fetch(:do_lb_id)} --forwarding-rules '#{JSON.dump(lb_config['forwarding_rules'])}'") end end endThe Zero-Downtime Deployment Workflow
With the infrastructure and Capistrano setup, the deployment workflow becomes a sequence of automated steps:
Step 1: Deploy to the Inactive Environment
First, we deploy the new version of the application to the environment that is *not* currently receiving live traffic. If Blue is live, we deploy to Green. If Green is live, we deploy to Blue.
# Assuming Blue is currently live, deploy to Green TARGET_ENV=green bundle exec cap production deploy:deploy_inactiveThis command uses Capistrano to deploy the code to the Droplets designated as the "Green" environment. The `deploy_inactive` task temporarily tells Capistrano to target the Green servers.
Step 2: Run Smoke Tests and Health Checks
After the deployment to the inactive environment is complete, it's crucial to verify its health and functionality. This can involve:
- Automated integration tests against the inactive environment's IP address or a staging URL.
- Manual verification by QA or development teams.
- Checking application logs for errors.
- Ensuring the `/health` endpoint on the inactive environment returns a 200 OK status.
If any checks fail, you can immediately roll back by simply not switching the traffic. The currently live environment remains unaffected.
Step 3: Switch Traffic with the Load Balancer
Once you're confident the new version is stable, you switch the traffic. This is the critical step where the Load Balancer is reconfigured to point to the newly deployed environment.
# Assuming we just deployed to Green and Green is now ready to go live bundle exec cap production deploy:switch_to_green
This command executes the `switch_to_green` task, which uses `doctl` to update the Load Balancer's forwarding rules. Traffic will now be directed to the Green Droplets. The switch is typically instantaneous or takes only a few seconds, depending on the Load Balancer's configuration.
Step 4: Monitor and Rollback (if necessary)
After the switch, closely monitor your application's performance, error rates, and user feedback. If critical issues arise, you can quickly roll back by switching the traffic back to the previous environment.
# If issues are found with Green, switch back to Blue bundle exec cap production deploy:switch_to_blue
Handling Database Migrations
Database schema changes are the trickiest part of Blue-Green deployments. Since both environments share a single database instance, you cannot have incompatible schema versions running simultaneously. The standard approach is:
- No Schema Changes: Deploy code that does not require database schema changes.
- Backward-Compatible Schema Changes: Deploy a database migration that adds new columns or tables, or makes non-breaking changes. The new application code (deployed to the inactive environment) can then use these new structures. The old application code (still running on the live environment) will simply ignore the new columns/tables.
- Deploy New Code: Deploy the application code that *uses* the new schema elements.
- Remove Old Schema Elements: Once traffic is fully switched and you are confident, deploy a migration to remove old columns or tables. This migration should be deployed *after* the application code that relies on them has been deployed and is live.
This phased approach ensures that at no point is the database schema incompatible with the running application code in either the Blue or Green environment.
Considerations for Production
- Database Backups: Always ensure robust database backup and recovery strategies are in place before any deployment.
- Stateful Applications: If your application maintains state on the application servers (e.g., file uploads not stored in S3), ensure this state is managed or replicated across instances in both environments.
- Session Management: Use a shared session store (like Redis or Memcached) if user sessions need to persist across traffic switches.
- CI/CD Integration: Integrate these Capistrano tasks into your CI/CD pipeline (e.g., GitLab CI, GitHub Actions, Jenkins) for fully automated deployments.
- Configuration Management: Use tools like Ansible or Chef to provision and configure your Droplets consistently for both Blue and Green environments.
- Monitoring and Alerting: Implement comprehensive monitoring for both environments and the Load Balancer itself.
- Rollback Strategy: Have a well-defined and tested rollback procedure.
By carefully orchestrating your infrastructure and deployment process, you can achieve reliable zero-downtime deployments for your Ruby applications on DigitalOcean.