Profile picture Schedule a Meeting
c a n d l a n d . n e t

Beanstalk with Terraform

Dusty Candland | | elixir, terraform, beanstalk

This took the longest time to get working. Partly because of my own typos :p. But, also because I wanted to setup everything with Terraform.

The Beanstalk stack is just the basic Docker stack. I setup RDS for the database because both apps need it.

This is part of a larger set of posts, see Deploying Elixir Umbrella Apps for an overview.

Beanstalk

I wanted something like Heroku to deploy and run the Docker images. EB isn't as straight forward as Heroku, but it'll work for now.

Seems like the EB CLI tool and Terraform aren't a good mix because the CLI will change infrastructure which should be done by Terraform.

Using eb logs is nice though.

To get things running in Docker on Beanstalk, you need a Dockerrun.aws.json file zipped up on s3 & a Docker image tagged and pushed to a registry on AWS.

The Makefile will handle these details on deploy.

Terraform

This is the first time I've really used Terraform. I really like it! AWS however requires so many things that it can be hard to figure out all of them.

I used an existing module for the RDS Postgresql setup.

I ended up making my own module for Beanstalk, which I should have done to start with because Terraform will want to destroy and re-create everything after moving it into a module.

Basic modules are straight forward. Just a sub directory.

infrastructure/beanstalk
├── main.tf
└── variables.tf

Here's the module code. It's pretty rough and still needs some output vars.

The variables.tf defines all the things that need (or can) be passed into the module.

variable "application_name" {
  description = "Name of the application"
}
variable "environment" {
  description = "Name of the environment staging|prod|etc"
}

variable "vpc_id" {
  description = "VPC to run in"
}

variable "subnet_ids" {
  description = "Subnets to run in"
  type = "list"
}

variable "instance_type" {
  description = "EC2 Instance."
  default = "t2.micro"
}

variable "instance_count" {
  description = "Number of instances to run."
  default = "1"
}

variable "healthcheck_url" {
  description = "Endpoint to check the app is up."
}

variable "secret_key_base" {
  description = "Phoenix secret key base. `mix phx.gen.secret"
}

variable "port" {
  description = "The port the app should listen on."
}

variable "database_url" {
  description = "Database connection URL."
}

variable "erlang_cookie" {
  description = "Cookie for the BEAM"
}

variable "mix_env" {
  description = "Mix environment to use. dev|prod"
  default = "prod"
}

The main.tf file is the entry point for the module and defines the resources.

# S3 Bucket for storing Elastic Beanstalk task definitions
resource "aws_s3_bucket" "in_s3_bucket" {
  bucket = "${var.application_name}-${var.environment}-deployments"
}


# Beanstalk instance profile
resource "aws_iam_instance_profile" "in_beanstalk_ec2" {
  name  = "${var.application_name}-${var.environment}-beanstalk-ec2-user"
  role = "${aws_iam_role.in_beanstalk_ec2.name}"
}

resource "aws_iam_role" "in_beanstalk_ec2" {
  name = "${var.application_name}-${var.environment}-beanstalk-ec2-role"

  assume_role_policy = <<EOF
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Action": "sts:AssumeRole",
      "Principal": {
        "Service": "ec2.amazonaws.com"
      },
      "Effect": "Allow",
      "Sid": ""
    }
  ]
}
EOF
}

# Beanstalk EC2 Policy
# Overriding because by default Beanstalk does not have a permission to Read ECR
resource "aws_iam_role_policy" "in_beanstalk_ec2_policy" {
  name = "in_beanstalk_ec2_policy_with_ECR"
  role = "${aws_iam_role.in_beanstalk_ec2.id}"

  policy = <<EOF
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Action": [
        "cloudwatch:PutMetricData",
        "ds:CreateComputer",
        "ds:DescribeDirectories",
        "ec2:DescribeInstanceStatus",
        "logs:*",
        "ssm:*",
        "ec2messages:*",
        "ecr:GetAuthorizationToken",
        "ecr:BatchCheckLayerAvailability",
        "ecr:GetDownloadUrlForLayer",
        "ecr:GetRepositoryPolicy",
        "ecr:DescribeRepositories",
        "ecr:ListImages",
        "ecr:DescribeImages",
        "ecr:BatchGetImage",
        "s3:*"
      ],
      "Effect": "Allow",
      "Resource": "*"
    }
  ]
}
EOF
}

resource "aws_elastic_beanstalk_application" "in_eb_application" {
  name        = "${var.application_name}-${var.environment}"
  description = "${var.application_name}-${var.environment}"
}

resource "aws_elastic_beanstalk_environment" "in_eb_environment" {
  name                = "${var.application_name}-${var.environment}"
  application         = "${aws_elastic_beanstalk_application.in_eb_application.name}"
  solution_stack_name = "64bit Amazon Linux 2018.03 v2.12.2 running Docker 18.03.1-ce"
  tier                = "WebServer"

  setting {
    namespace = "aws:autoscaling:launchconfiguration"
    name      = "InstanceType"

    # Todo: As Variable
    value = "t2.micro"
  }

  setting {
    namespace = "aws:autoscaling:asg"
    name      = "MaxSize"

    # Todo: As Variable
    value = "2"
  }

  setting {
    namespace = "aws:autoscaling:launchconfiguration"
    name      = "IamInstanceProfile"
    value     = "${aws_iam_instance_profile.in_beanstalk_ec2.name}"
  }

  setting {
    namespace = "aws:ec2:vpc"
    name      = "VPCId"
    value     = "${var.vpc_id}"
  }
  setting {
    namespace = "aws:ec2:vpc"
    name      = "Subnets"
    value     = "${join(",", var.subnet_ids)}"
  }

  #setting {
  #  namespace = "aws:elasticbeanstalk:application"
  #  name      = "Application Healthcheck URL"
  #  value     = "${var.healthcheck_url}"
  #}

  # ENV VARS
  setting {
    namespace = "aws:elasticbeanstalk:application:environment"
    name      = "SECRET_KEY_BASE"
    value     = "${var.secret_key_base}"
  }
  setting {
    namespace = "aws:elasticbeanstalk:application:environment"
    name      = "PORT"
    value     = "${var.port}"
  }
  setting {
    namespace = "aws:elasticbeanstalk:application:environment"
    name      = "DATABASE_URL"
    value     = "${var.database_url}"
  }
  setting {
    namespace = "aws:elasticbeanstalk:application:environment"
    name      = "REPLACE_OS_VARS"
    value     = "true"
  }
  setting {
    namespace = "aws:elasticbeanstalk:application:environment"
    name      = "LANG"
    value     = "en_US.UTF-8"
  }
  setting {
    namespace = "aws:elasticbeanstalk:application:environment"
    name      = "ERLANG_COOKIE"
    value     = "${var.erlang_cookie}"
  }
  setting {
    namespace = "aws:elasticbeanstalk:application:environment"
    name      = "MIX_ENV"
    value     = "${var.mix_env}"
  }
}

Again, needs work. One thing with modules, is you'll want give everything a name value that's made from the vars passed in so that you can use the module to create different apps or environments.

I should have output vars for most of this so that things can be changed outside the module. It'd be good if the ENV vars where passed in with a map.

This Terraform Tips post has a ton of good info for working with Terraform

I had to create and destroy things a few times to get it all working.

The rest of the Terraform stuff is pretty basic and really I'm sure it could be a lot better.

The advantage to all this is I can easily create multiple applications, like one for the Web app and one for the Admin app. And different environments.

To use the module, you just need a unique name and the source path.

...
module "eb_web_staging" {
  source = "./beanstalk"

  application_name = "web"
  environment = "staging"
  vpc_id = "${var.vpc_id}"
  subnet_ids = "${var.subnet_ids}"

  healthcheck_url = "/status"
  secret_key_base = "${var.web_staging_secret_key_base}"
  port = "4000"
  database_url = "postgresql://company:${var.admin_staging_database_password}@company-staging.xxxxxxxjbg2g.us-west-2.rds.amazonaws.com/company_staging"
  erlang_cookie = "company_web_staging"
  mix_env = "prod"
}
...

TODO

Still some work that needs to happen here.

  • Setup SSH access to the Beanstalk instances.
  • Create an outputs.tf file to get info back out of the module.
  • Pass the ENV vars into the module with a map.
  • Setup health monitoring.

Tie it all together with Elixir Deploys with Make

References

Webmentions

These are webmentions via the IndieWeb and webmention.io. Mention this post from your site: