├── .gitignore ├── Gemfile ├── LICENSE ├── README.md ├── example_template ├── variables.tf └── web.tf ├── put_asg_in_standby.rb ├── remove_asg_from_tfstate.rb └── terminate_asg.rb /.gitignore: -------------------------------------------------------------------------------- 1 | *.tfstate 2 | *.tfstate.* 3 | Gemfile.lock 4 | *_override.tf 5 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # A sample Gemfile 2 | source "https://rubygems.org" 3 | 4 | gem "aws-sdk" 5 | gem "json" 6 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 ClearCare Online 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # sagan 2 | Blue/Green deployments in AWS using Terraform 3 | 4 | Carl Sagan originally proposed terraforming Venus to make it habitable 5 | for human life. 6 | 7 | This repo contains a set of scripts that can be used to trigger a 8 | blue/green deployment in AWS using auto scaling groups and immutable 9 | AMIs. 10 | 11 | The process is as follows: 12 | 13 | 1. Remove the reference to the ASG and Launch config from terraform 14 | state file (terraform.tfstate) 15 | 1. Run terraform apply which will create a new ASG and launch 16 | configuration with the new AMI. 17 | 1. Verify functionality of the application. 18 | 1. Put the instances in the original ASG in 'Standby' mode so that it 19 | doesn't recieve new traffic. 20 | 1. Verify functionality of the application. 21 | 1. Delete the ASG (and associated instances) from AWS. 22 | 23 | # How to 24 | 25 | Given the terraform apply has been ran with the example template... 26 | 27 | Set your AWS keys and install gem dependencies: 28 | 29 | ``` 30 | ➜ sagan git:(master) ✗ export AWS_ACCESS_KEY_ID= 31 | ➜ sagan git:(master) ✗ export AWS_SECRET_ACCESS_KEY= 32 | ➜ sagan git:(master) ✗ sudo gem install bundler 33 | ➜ sagan git:(master) ✗ bundle 34 | ``` 35 | 36 | Remove the reference to the ASG and Launch config from terraform state 37 | file: 38 | 39 | ``` 40 | ➜ sagan git:(master) ✗ ./remove_asg_from_tfstate.rb example_template/terraform.tfstate 41 | Creating tfstate backup at example_template/terraform.tfstate.1430149712 42 | Removed the following asg from example_template/terraform.tfstate: 43 | web_ami-2593a715 44 | ``` 45 | 46 | Note the asg id above... you'll need this later (to take it out of service) 47 | 48 | Run terraform apply to create the new ASG and launch config: 49 | 50 | ``` 51 | ➜ sagan git:(master) ✗ cd example_template 52 | 53 | ➜ example_template git:(master) ✗ terraform apply 54 | var.ami 55 | the AMI to use 56 | 57 | Enter a value: ami-c1a194f1 58 | 59 | var.availability_zones 60 | A comma separated list of availability zones (no spaces) 61 | 62 | Enter a value: 63 | 64 | var.count 65 | the number of instances to run 66 | 67 | Default: 3 68 | Enter a value: 69 | 70 | var.instance_size 71 | EC2 instance size 72 | 73 | Default: t2.micro 74 | Enter a value: 75 | 76 | var.key_name 77 | The name of the aws key used to launch instances 78 | 79 | Enter a value: 80 | 81 | var.private_subnets 82 | A comma separated list of subnet ids (no spaces) 83 | 84 | Enter a value: 85 | 86 | var.region 87 | the region we are gong to run in 88 | 89 | Enter a value: 90 | 91 | var.vpc_id 92 | A vpc id 93 | 94 | Enter a value: 95 | 96 | aws_security_group.web_server: Refreshing state... (ID: sg-fcccc799) 97 | aws_elb.web_lb: Refreshing state... (ID: web-elb) 98 | aws_launch_configuration.launch_config: Creating... 99 | ... 100 | aws_launch_configuration.launch_config: Creation complete 101 | aws_autoscaling_group.asg: Creating... 102 | ... 103 | aws_autoscaling_group.asg: Creation complete 104 | 105 | Apply complete! Resources: 2 added, 0 changed, 0 destroyed. 106 | 107 | The state of your infrastructure has been saved to the path 108 | below. This state is required to modify and destroy your 109 | infrastructure, so keep it safe. To inspect the complete state 110 | use the `terraform show` command. 111 | 112 | State path: terraform.tfstate 113 | ``` 114 | 115 | Verify functionality of the application and then put the original ASG 116 | in standby: 117 | 118 | ``` 119 | ➜ example_template git:(master) ✗ cd .. 120 | 121 | ➜ sagan git:(master) ✗ ./put_asg_in_standby.rb -h 122 | Usage: put_asg_in_standby.rb -a ASG [-r REGION] [-x] 123 | -a, --asg ASG Name of the ASG to remove (required) 124 | -r, --region REGION Name of the AWS region to use (default: us-west-2) 125 | -x, --exit-standby If set, remove the ASG from standby 126 | -h, --help Prints this help 127 | 128 | ➜ sagan git:(master) ✗ ./put_asg_in_standby.rb -a web_ami-2593a715 129 | Putting the following instances on standby: 130 | i-56e078a0 131 | i-d8fcd211 132 | i-17be76e0 133 | ``` 134 | 135 | Verify the functionality of the application then terminate the original 136 | ASG (You will not be able to roll back after this step): 137 | 138 | ``` 139 | ➜ sagan git:(master) ✗ ./terminate_asg.rb -h 140 | Usage: remove_asg_from_elb.rb -a ASG [-r REGION] [-x] 141 | -a, --asg ASG Name of the ASG to remove (required) 142 | -r, --region REGION Name of the AWS region to use (default: us-west-2) 143 | -h, --help Prints this help 144 | 145 | ➜ sagan git:(master) ✗ ./terminate_asg.rb -a web_ami-2593a715 146 | ``` 147 | 148 | # Rollback 149 | 150 | To remove an ASG from standby and put it back in service run: 151 | 152 | ``` 153 | ➜ sagan git:(master) ✗ ./put_asg_in_standby.rb -a web_ami-c1a194f1 -x 154 | Removing the following instances from standby: 155 | i-ade27a5b 156 | i-e1fad428 157 | i-09bf77fe 158 | ``` 159 | 160 | You can now put the new ASG in standby to debug it (or delete it for 161 | a full rollback). 162 | -------------------------------------------------------------------------------- /example_template/variables.tf: -------------------------------------------------------------------------------- 1 | variable "ami" { 2 | description = "the AMI to use" 3 | } 4 | 5 | variable "count" { 6 | description = "the number of instances to run" 7 | } 8 | 9 | variable "region" { 10 | description = "the region we are gong to run in" 11 | } 12 | 13 | variable "instance_size" { 14 | description = "EC2 instance size" 15 | default = "t2.micro" 16 | } 17 | 18 | variable "private_subnets" { 19 | description = "A comma separated list of subnet ids (no spaces)" 20 | } 21 | 22 | variable "availability_zones" { 23 | description = "A comma separated list of availability zones (no spaces)" 24 | } 25 | 26 | variable "vpc_id" { 27 | description = "A vpc id" 28 | } 29 | 30 | variable "key_name" { 31 | description = "The name of the aws key used to launch instances" 32 | } 33 | -------------------------------------------------------------------------------- /example_template/web.tf: -------------------------------------------------------------------------------- 1 | provider "aws" { 2 | region = "${var.region}" 3 | } 4 | 5 | resource "aws_elb" "web_lb" { 6 | name = "web-elb" 7 | internal = true 8 | security_groups = ["${aws_security_group.web_server.id}"] 9 | subnets = [ 10 | "${element(split(\",\", var.private_subnets), 0)}", 11 | "${element(split(\",\", var.private_subnets), 1)}", 12 | "${element(split(\",\", var.private_subnets), 2)}" 13 | ] 14 | listener { 15 | instance_port = 80 16 | instance_protocol = "http" 17 | lb_port = 80 18 | lb_protocol = "http" 19 | } 20 | health_check { 21 | healthy_threshold = 2 22 | unhealthy_threshold = 2 23 | timeout = 3 24 | target = "HTTP:80/" 25 | interval = 10 26 | } 27 | } 28 | 29 | resource "aws_security_group" "web_server" { 30 | name = "web_server" 31 | description = "Used for all web servers" 32 | vpc_id = "${var.vpc_id}" 33 | ingress { #SSH invar 34 | from_port = 22 35 | to_port = 22 36 | protocol = "tcp" 37 | cidr_blocks = [ "10.0.0.0/8" ] 38 | } 39 | ingress { #SSH in from the VPC 40 | from_port = 80 41 | to_port = 80 42 | protocol = "tcp" 43 | cidr_blocks = [ "10.0.0.0/8" ] 44 | } 45 | } 46 | 47 | resource "aws_launch_configuration" "launch_config" { 48 | image_id = "${var.ami}" 49 | instance_type = "${var.instance_size}" 50 | security_groups = ["${aws_security_group.web_server.id}"] 51 | key_name = "${var.key_name}" 52 | lifecycle { 53 | create_before_destroy = true 54 | } 55 | } 56 | 57 | resource "aws_autoscaling_group" "asg" { 58 | availability_zones = [ 59 | "${element(split(\",\", var.availability_zones), 0)}", 60 | "${element(split(\",\", var.availability_zones), 1)}", 61 | "${element(split(\",\", var.availability_zones), 2)}" 62 | ] 63 | name = "web_${var.ami}" 64 | max_size = "${var.count}" 65 | min_size = "${var.count}" 66 | health_check_grace_period = 300 67 | load_balancers = ["${aws_elb.web_lb.name}"] 68 | health_check_type = "EC2" 69 | desired_capacity = "${var.count}" 70 | force_delete = true 71 | launch_configuration = "${aws_launch_configuration.launch_config.name}" 72 | vpc_zone_identifier = [ 73 | "${element(split(\",\", var.private_subnets), 0)}", 74 | "${element(split(\",\", var.private_subnets), 1)}", 75 | "${element(split(\",\", var.private_subnets), 2)}" 76 | ] 77 | } 78 | -------------------------------------------------------------------------------- /put_asg_in_standby.rb: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require 'optparse' 4 | require 'rubygems' 5 | require 'aws-sdk' 6 | 7 | options = { 8 | 'region' => 'us-west-2', 9 | 'standby' => 'true' 10 | } 11 | 12 | parser = OptionParser.new do |opts| 13 | opts.banner = 'Usage: put_asg_in_standby.rb -a ASG [-r REGION] [-x]' 14 | 15 | opts.on("-a", "--asg ASG", "Name of the ASG to remove (required)") do |opt| 16 | options['asg'] = opt 17 | end 18 | 19 | opts.on("-r", "--region REGION", "Name of the AWS region to use (default: #{options['region']})") do |opt| 20 | options['region'] = opt 21 | end 22 | 23 | opts.on("-x", "--exit-standby", "If set, remove the ASG from standby") do 24 | options['standby'] = false 25 | end 26 | 27 | opts.on("-h", "--help", "Prints this help") do 28 | puts opts 29 | exit 30 | end 31 | end 32 | 33 | # Parse options 34 | parser.parse! 35 | 36 | required_options = %w{asg} 37 | required_options.each do |option| 38 | if options[option].nil? 39 | puts "Error required argument (#{option}) is missing." 40 | puts parser.help 41 | exit 1 42 | end 43 | end 44 | 45 | auto_scaling = Aws::AutoScaling::Client.new(region: options['region']) 46 | instances = auto_scaling.describe_auto_scaling_groups(auto_scaling_group_names: [options['asg']]).auto_scaling_groups[0].instances 47 | if options['standby'] 48 | instance_ids = [] 49 | instances.each do |instance| 50 | instance_ids << instance.instance_id if instance.lifecycle_state == 'InService' 51 | end 52 | auto_scaling.update_auto_scaling_group(auto_scaling_group_name: options['asg'], min_size: 0) 53 | puts "Putting the following instances on standby:" 54 | puts instance_ids 55 | auto_scaling.enter_standby(auto_scaling_group_name: options['asg'], should_decrement_desired_capacity: true, instance_ids: instance_ids) 56 | else 57 | instance_ids = [] 58 | instances.each do |instance| 59 | instance_ids << instance.instance_id if instance.lifecycle_state == 'Standby' 60 | end 61 | puts "Removing the following instances from standby:" 62 | puts instance_ids 63 | auto_scaling.exit_standby(auto_scaling_group_name: options['asg'], instance_ids: instance_ids) 64 | auto_scaling.update_auto_scaling_group(auto_scaling_group_name: options['asg'], min_size: instance_ids.length) 65 | end 66 | -------------------------------------------------------------------------------- /remove_asg_from_tfstate.rb: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require 'optparse' 4 | require 'rubygems' 5 | require 'json' 6 | require 'fileutils' 7 | 8 | options = { 9 | 'asg' => 'asg', 10 | 'launch_config' => 'launch_config' 11 | } 12 | 13 | parser = OptionParser.new do |opts| 14 | opts.banner = 'Usage: remote_asg_from_tfstate.rb [-a ASG] [-l LAUNCH_CONFIG] PATH' 15 | 16 | opts.on("-a", "--asg", "Name of the asg to remove (default: #{options['asg']}") do |opt| 17 | options['asg'] = opt 18 | end 19 | 20 | opts.on("-l", "--launch-config", "Name of the launch configuration to remove (default: #{options['launch_config']}") do |opt| 21 | options['launch_config'] = opt 22 | end 23 | 24 | opts.on("-h", "--help", "Prints this help") do 25 | puts opts 26 | exit 27 | end 28 | end 29 | 30 | # Parse options 31 | parser.parse! 32 | 33 | # Verify Account IDs have been provided 34 | if ARGV.empty? 35 | puts "Error: Path to tfstate not found" 36 | puts parser.help 37 | exit 1 38 | end 39 | 40 | tf_state_path = ARGV.pop 41 | 42 | tf_state = JSON.parse(File.read(tf_state_path)) 43 | asg_id = tf_state['modules'][0]['resources']["aws_autoscaling_group.#{options['asg']}"]['primary']['id'] 44 | 45 | tf_state['modules'][0]['resources'].reject!{ |k,v| k == "aws_autoscaling_group.#{options['asg']}" } 46 | tf_state['modules'][0]['resources'].reject!{ |k,v| k == "aws_launch_configuration.#{options['launch_config']}" } 47 | 48 | backup_file = "#{tf_state_path}.#{Time.now.to_i}" 49 | puts "Creating tfstate backup at #{backup_file}" 50 | FileUtils.mv(tf_state_path, backup_file) 51 | 52 | File.write(tf_state_path, JSON.pretty_generate(tf_state)) 53 | 54 | puts "Removed the following asg from #{tf_state_path}:" 55 | puts asg_id 56 | -------------------------------------------------------------------------------- /terminate_asg.rb: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require 'optparse' 4 | require 'rubygems' 5 | require 'aws-sdk' 6 | 7 | options = { 8 | 'region' => 'us-west-2', 9 | 'standby' => 'true' 10 | } 11 | 12 | parser = OptionParser.new do |opts| 13 | opts.banner = 'Usage: remove_asg_from_elb.rb -a ASG [-r REGION] [-x]' 14 | 15 | opts.on("-a", "--asg ASG", "Name of the ASG to remove (required)") do |opt| 16 | options['asg'] = opt 17 | end 18 | 19 | opts.on("-r", "--region REGION", "Name of the AWS region to use (default: #{options['region']})") do |opt| 20 | options['region'] = opt 21 | end 22 | 23 | opts.on("-h", "--help", "Prints this help") do 24 | puts opts 25 | exit 26 | end 27 | end 28 | 29 | # Parse options 30 | parser.parse! 31 | 32 | required_options = %w{asg} 33 | required_options.each do |option| 34 | if options[option].nil? 35 | puts "Error required argument (#{option}) is missing." 36 | puts parser.help 37 | exit 1 38 | end 39 | end 40 | 41 | auto_scaling = Aws::AutoScaling::Client.new(region: options['region']) 42 | launch_configuration_name = auto_scaling.describe_auto_scaling_groups(auto_scaling_group_names: [options['asg']]).auto_scaling_groups[0].launch_configuration_name 43 | auto_scaling.delete_auto_scaling_group(auto_scaling_group_name: options['asg'], force_delete: true) 44 | auto_scaling.delete_launch_configuration(launch_configuration_name: launch_configuration_name) 45 | --------------------------------------------------------------------------------