Skip to main content

EKS with Spot Instances using Terraform

info

Published Date: 18-SEP-2020

The objective of this post is not to get a fully running AWS EKS cluster running with spot instances, but rather the key "pain points" I run into when trying to spin up this infrastructure using Terraform.

Overview

I am building:

  • 1 x AWS EKS cluster
  • using the AWS official 'eks' module
  • Spot Instances
  • 2 x Worker Groups (nodes)
  • Autoscaling Groups
  • using Terraform 0.12

Module: AWS EKS

Using the official EKS terraform module by AWS, looks like this:

module "eks" {
source = "terraform-aws-modules/eks/aws"
version = "12.2.0"
cluster_name = local.cluster_name
subnets = data.terraform_remote_state.vpc.outputs.private_subnets

tags = {
Environment = "prod"
}

vpc_id = data.terraform_remote_state.vpc.outputs.vpc_id

worker_groups = [
{
name = "worker-group-1"
instance_type = local.instance_type
spot_price = local.spot_price
asg_desired_capacity = local.asg_desired_capacity
asg_max_size = local.asg_max_size
asg_min_size = local.asg_min_size
additional_security_group_ids = [aws_security_group.worker_group_mgmt_one.id]
additional_userdata = "worker group config"
tags = [{
key = "worker-group-tag"
value = "worker-group-1"
propagate_at_launch = true
}]
},
{
name = "worker-group-2"
instance_type = local.instance_type
spot_price = local.spot_price
asg_desired_capacity = local.asg_desired_capacity
asg_max_size = local.asg_max_size
asg_min_size = local.asg_min_size
additional_security_group_ids = [aws_security_group.worker_group_mgmt_two.id]
additional_userdata = "worker group config"
tags = [{
key = "worker-group-tag"
value = "worker-group-2"
propagate_at_launch = true
}]
},
]
}

Breaking down what's going on here:

locals

I'm using a locals block for cluster specific variables

locals{
cluster_name = "prod-eks-cluster"
cluster_enabled_log_types = ["api", "audit", "authenticator", "controllerManager", "scheduler"]
asg_desired_capacity = 1
asg_max_size = 3
asg_min_size = 1
instance_type = "m4.large"
spot_price = "0.20"
}

terraform_remote_state

I'm using terraform_remote_state data source to import the state-file of the VPC I created in another folder

data "terraform_remote_state" "vpc" {
backend = "s3"

config = {
bucket = "tfstates3"
key = "prod/network/terraform.tfstate"
region = var.region
}
}

These are the references to the vpc subnets and id

subnets      = data.terraform_remote_state.vpc.outputs.private_subnets
vpc_id = data.terraform_remote_state.vpc.outputs.vpc_id

Since v0.12 the .outputs. part is what calls whatever is defined in the modules outputs.tf file for the resources.

See more details on this below.

kubernetes provider

make sure you have these 3 resources:

data "aws_eks_cluster" "cluster" {
name = module.eks.cluster_id
}

data "aws_eks_cluster_auth" "cluster" {
name = module.eks.cluster_id
}

provider "kubernetes" {
host = data.aws_eks_cluster.cluster.endpoint
cluster_ca_certificate = base64decode(data.aws_eks_cluster.cluster.certificate_authority.0.data)
token = data.aws_eks_cluster_auth.cluster.token
load_config_file = false
version = "~> 1.11"
}

otherwise you'll see this error near end of EKS setup

...
module.eks.aws_autoscaling_group.workers[1]: Creation complete after 1m50s [id=prod-eks-cluster-worker-group-220200917111118233900000009]

Error: Post "https://prod-bc5a71e2.hcp.australiaeast.azmk8s.io:443/api/v1/namespaces/kube-system/configmaps": dial tcp: lookup prod-bc5a71e2.hcp.australiaeast.azmk8s.io on 127.0.0.53:53: no such host

on .terraform/modules/eks/aws_auth.tf line 64, in resource "kubernetes_config_map" "aws_auth":
64: resource "kubernetes_config_map" "aws_auth" {
...

worker_groups

Self-explanatory, you can define however many "worker groups" you want (I think). In here you will define the autoscaling min, max, desired instances, and instance type (m4.large is the smallest type you can use with errors), and spot instance bid price (0.20).

  worker_groups = [
{
name = "worker-group-1"
instance_type = local.instance_type
spot_price = local.spot_price
asg_desired_capacity = local.asg_desired_capacity
asg_max_size = local.asg_max_size
asg_min_size = local.asg_min_size
additional_security_group_ids = [aws_security_group.worker_group_mgmt_one.id]
additional_userdata = "worker group config"
tags = [{
key = "worker-group-tag"
value = "worker-group-1"
propagate_at_launch = true
}]
},

The security groups referenced in additional_security_group_ids = [aws_security_group.worker_group_mgmt_one.id] (list), is in another security.tf and basically sets up all nodes to open port 22 from specific cidr_blocks.

Terraform: Remote State Files

If you have the "best practice" setup of having each component/section of your infrastructure layout in separate folders e.g. eks in one folder, vpc in another -- and they have their own state files, which means they can't just reference each other.

The solution is using terraform_remote_state data source.

In your VPC module, your remote state file key looks like this:

terraform {
backend "s3" {
key = "prod/vpc/terraform.tfstate"
}
}

to use this in another folder which builds your EKS infrastructure, you need this reference:

data "terraform_remote_state" "vpc" {
backend = "s3"

config = {
bucket = "tfstates3"
key = "prod/vpc/terraform.tfstate" # references the VPC statefile 'key'
region = "us-east-2"
}
}

and now you can call the VPC modules outputs like this:

module "eks" {
source = "terraform-aws-modules/eks/aws"
version = "12.2.0"
cluster_name = local.cluster_name
subnets = data.terraform_remote_state.vpc.outputs.private_subnets

tags = {
Environment = "prod"
}

vpc_id = data.terraform_remote_state.vpc.outputs.vpc_id

your VPC outputs.tf file needs to have the corresponding outputs e.g.

output "vpc_id" {
description = "The ID of the VPC"
value = module.vpc.vpc_id
}

output "private_subnets" {
description = "List of IDs of private subnets"
value = module.vpc.private_subnets
}

CLI: aws-cli and kubectl

kubectl needs your aws-cli to be able to find the same aws user you used to create the EKS cluster i.e. either your 'default' ~/.aws/credentials profile is the terraform one you used as environment variable credentials to run terraform apply OR you just have a default [default] block that has the same credentials.

This is the error you see when aws can't find the terraform aws creds

from your terraform outout...

Error: Error running command 'aws eks --region us-west-2 update-kubeconfig --name prod-eks-cluster': exit status 255. Output: 
An error occurred (ResourceNotFoundException) when calling the DescribeCluster operation: No cluster found for name: prod-eks-cluster.

to trying to update your local kubeconfig file via aws command

$ aws eks --region us-west-2 update-kubeconfig --name prod-eks-cluster
Unable to locate credentials. You can configure credentials by running "aws configure".

to running kubectl with --kubeconfig on the kubeconfig file that was outputted by terraform to make a call to the EKS cluster

$ kubectl --kubeconfig ./kubeconfig_prod-eks-cluster get nodes
could not get token: NoCredentialProviders: no valid providers in chain. Deprecated.
For verbose messaging see aws.Config.CredentialsChainVerboseErrors

The solution was to add the [default] block to ~/.aws/credentials and voila:

kubectl --kubeconfig ./kubeconfig_prod-eks-cluster get nodes
NAME STATUS ROLES AGE VERSION
ip-10-0-1-130.us-east-2.compute.internal Ready <none> 4m56s v1.16.13-eks-2ba888
ip-10-0-3-180.us-east-2.compute.internal Ready <none> 4m53s v1.16.13-eks-2ba888

and configure kubectl

 aws eks --region us-east-2 update-kubeconfig --name prod-eks-cluster
Added new context arn:aws:eks:us-east-2:000000000000:cluster/prod-eks-cluster to /home/user/.kube/config

run it

kubectl get nodes
NAME STATUS ROLES AGE VERSION
ip-10-0-1-130.us-east-2.compute.internal Ready <none> 32m v1.16.13-eks-2ba888
ip-10-0-3-180.us-east-2.compute.internal Ready <none> 32m v1.16.13-eks-2ba888

IAM: minimum permissions

When you create a user for terraform to be able to create your EKS infrastructure, the bare minimum permissions you need to assign, as a policy, to your user is the following:

{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "VisualEditor0",
"Effect": "Allow",
"Action": [
"autoscaling:AttachInstances",
"autoscaling:CreateAutoScalingGroup",
"autoscaling:CreateLaunchConfiguration",
"autoscaling:CreateOrUpdateTags",
"autoscaling:DeleteAutoScalingGroup",
"autoscaling:DeleteLaunchConfiguration",
"autoscaling:DeleteTags",
"autoscaling:Describe*",
"autoscaling:DetachInstances",
"autoscaling:SetDesiredCapacity",
"autoscaling:UpdateAutoScalingGroup",
"autoscaling:SuspendProcesses",
"ec2:AllocateAddress",
"ec2:AssignPrivateIpAddresses",
"ec2:Associate*",
"ec2:AttachInternetGateway",
"ec2:AttachNetworkInterface",
"ec2:AuthorizeSecurityGroupEgress",
"ec2:AuthorizeSecurityGroupIngress",
"ec2:CreateDefaultSubnet",
"ec2:CreateDhcpOptions",
"ec2:CreateEgressOnlyInternetGateway",
"ec2:CreateInternetGateway",
"ec2:CreateNatGateway",
"ec2:CreateNetworkInterface",
"ec2:CreateRoute",
"ec2:CreateRouteTable",
"ec2:CreateSecurityGroup",
"ec2:CreateSubnet",
"ec2:CreateTags",
"ec2:CreateVolume",
"ec2:CreateVpc",
"ec2:DeleteDhcpOptions",
"ec2:DeleteEgressOnlyInternetGateway",
"ec2:DeleteInternetGateway",
"ec2:DeleteNatGateway",
"ec2:DeleteNetworkInterface",
"ec2:DeleteRoute",
"ec2:DeleteRouteTable",
"ec2:DeleteSecurityGroup",
"ec2:DeleteSubnet",
"ec2:DeleteTags",
"ec2:DeleteVolume",
"ec2:DeleteVpc",
"ec2:DeleteVpnGateway",
"ec2:Describe*",
"ec2:DetachInternetGateway",
"ec2:DetachNetworkInterface",
"ec2:DetachVolume",
"ec2:Disassociate*",
"ec2:ModifySubnetAttribute",
"ec2:ModifyVpcAttribute",
"ec2:ModifyVpcEndpoint",
"ec2:ReleaseAddress",
"ec2:RevokeSecurityGroupEgress",
"ec2:RevokeSecurityGroupIngress",
"ec2:UpdateSecurityGroupRuleDescriptionsEgress",
"ec2:UpdateSecurityGroupRuleDescriptionsIngress",
"ec2:CreateLaunchTemplate",
"ec2:CreateLaunchTemplateVersion",
"ec2:DeleteLaunchTemplate",
"ec2:DeleteLaunchTemplateVersions",
"ec2:DescribeLaunchTemplates",
"ec2:DescribeLaunchTemplateVersions",
"ec2:GetLaunchTemplateData",
"ec2:ModifyLaunchTemplate",
"ec2:RunInstances",
"eks:CreateCluster",
"eks:DeleteCluster",
"eks:DescribeCluster",
"eks:ListClusters",
"eks:UpdateClusterConfig",
"eks:UpdateClusterVersion",
"eks:DescribeUpdate",
"eks:TagResource",
"eks:UntagResource",
"eks:ListTagsForResource",
"eks:CreateFargateProfile",
"eks:DeleteFargateProfile",
"eks:DescribeFargateProfile",
"eks:ListFargateProfiles",
"eks:CreateNodegroup",
"eks:DeleteNodegroup",
"eks:DescribeNodegroup",
"eks:ListNodegroups",
"eks:UpdateNodegroupConfig",
"eks:UpdateNodegroupVersion",
"iam:AddRoleToInstanceProfile",
"iam:AttachRolePolicy",
"iam:CreateInstanceProfile",
"iam:CreateOpenIDConnectProvider",
"iam:CreateServiceLinkedRole",
"iam:CreatePolicy",
"iam:CreatePolicyVersion",
"iam:CreateRole",
"iam:DeleteInstanceProfile",
"iam:DeleteOpenIDConnectProvider",
"iam:DeletePolicy",
"iam:DeleteRole",
"iam:DeleteRolePolicy",
"iam:DeleteServiceLinkedRole",
"iam:DetachRolePolicy",
"iam:GetInstanceProfile",
"iam:GetOpenIDConnectProvider",
"iam:GetPolicy",
"iam:GetPolicyVersion",
"iam:GetRole",
"iam:GetRolePolicy",
"iam:List*",
"iam:PassRole",
"iam:PutRolePolicy",
"iam:RemoveRoleFromInstanceProfile",
"iam:TagRole",
"iam:UntagRole",
"iam:UpdateAssumeRolePolicy",
// Following permissions are needed if cluster_enabled_log_types is enabled
"logs:CreateLogGroup",
"logs:DescribeLogGroups",
"logs:DeleteLogGroup",
"logs:ListTagsLogGroup",
"logs:PutRetentionPolicy",
// Following permissions for working with secrets_encryption example
"kms:CreateGrant",
"kms:CreateKey",
"kms:DescribeKey",
"kms:GetKeyPolicy",
"kms:GetKeyRotationStatus",
"kms:ListResourceTags",
"kms:ScheduleKeyDeletion"
],
"Resource": "*"
}
]
}

As long as your AWS user has this policy attached, it will be able to create all the resources EKS requires.

That's it for now, I'll update this if I come across any more pain points.

References