Why Still EC2?
Nowadays we don't manage EC2s on our own due to the advent of ECS and the flexible scaling policies. However, an EC2 may still be useful in certain scenarios such as acting as a bastion host for an RDS instance sitting inside of a private VPC.
Let's study how to define EC2 and let's define a cluster of EC2s as a module, of which every EC2 instance executes a k3s
associated with a common RDS
instance to share kubenetes state
, these EC2s will form a small worker node.
Entrypoint --- main.tf
module "networking" { source = "./modules/networking" vpc_cidr = "10.123.0.0/16" // this is used to create a custom VPC public_cidrs = ["10.123.2.0/24", "10.123.4.0/24"] // this is for public subnets private_cidrs = ["10.123.1.0/24", "10.123.3.0/24", "10.123.5.0/24"] // this is for privae subnets ssh_access_ip = var.ssh_access_ip create_db_subnet_group = true } module "database" { source = "./modules/database" db_storage = 10 db_engine = "mysql" db_engine_version = "5.7.44" db_instance_class = "db.t3.micro" db_name = var.db_name db_username = var.db_username db_password = var.db_password db_identifier = "james-love-love-db" skip_final_snapshot = true db_subnet_group_name = length(module.networking.db_subnet_group_names) == 1 ? module.networking.db_subnet_group_names[0] : "" vpc_security_group_ids = [module.networking.db_security_group_id] } module "loadbalancing" { source = "./modules/loadbalancing" public_security_groups = [module.networking.public_http_sg.id, module.networking.public_ssh_sg.id] public_subnets = module.networking.public_subnet_ids tg_port = 8000 tg_protocol = "HTTP" vpc_id = module.networking.vpc_id lb_healthy_threshold = 2 lb_unhealthy_threshold = 2 lb_timeout = 3 lb_interval = 30 listener_port = 8000 listener_portocol = "HTTP" } module "compute" { source = "./modules/compute" instance_count = 1 instance_type = "t3.micro" vol_size = 10 ec2_security_group_id = module.networking.james_ec2_sg.id public_security_gp_ids = [ module.networking.public_ssh_sg.id, module.networking.public_http_sg.id ] public_subnet_ids = module.networking.public_subnet_ids key_name = "james_ec2_key" # public_key_path = "C:\\Users\\machingclee\\.ssh\\jameskey.pub" # windows specific # generated by ssh-keygen -t ed25519 -f ~/.ssh/james_ec2_key public_key_path = "/Users/chingcheonglee/.ssh/james_ec2_key.pub" db_endpoint = module.database.db_endpoint db_name = var.db_name db_password = var.db_password db_user = var.db_username user_data_path = "${path.root}/userdata.tpl" james_target_group_arn = module.loadbalancing.james_target_group_arn }
Compute Module
compute/main.tf
data "aws_ami" "server_ami" { most_recent = true # it can be found in ec2 > Images > AMIs > Public Images owners = ["099720109477"] filter { name = "name" values = ["ubuntu/images/hvm-ssd-gp3/ubuntu-noble-24.04-amd64-server-*"] } } resource "random_id" "james_node_id" { byte_length = 2 count = var.instance_count # this acts like an identifier in a map, and will create another # random_id with the same key_name /* The random_id resource with count will generate different random IDs, but it won't create additional instances by itself. The number of instances is controlled by var.instance_count. If var.instance_count = 1, you'll only get one instance regardless of random IDs. To create more instances: */ keepers = { key_name = var.key_name } } resource "aws_key_pair" "james_auth" { key_name = var.key_name public_key = file(var.public_key_path) } resource "aws_instance" "james_node" { count = var.instance_count # 1 instance_type = var.instance_type # t3.micro security_groups = [var.ec2_security_group_id] ami = data.aws_ami.server_ami.id key_name = aws_key_pair.james_auth.key_name vpc_security_group_ids = var.public_security_gp_ids subnet_id = var.public_subnet_ids[count.index] user_data = templatefile(var.user_data_path, { nodename = "james-node-${random_id.james_node_id[count.index].dec}" dbuser = var.db_user dbpass = var.db_password db_endpoint = var.db_endpoint dbname = var.db_name }) root_block_device { volume_size = var.vol_size # 10 } tags = { Name = "james_node-${random_id.james_node_id[count.index].dec}" } } # attach EC2 instances (target_id's) to the target group (target_group_arn) resource "aws_lb_target_group_attachment" "james_tg_attach" { count = var.instance_count target_group_arn = var.james_target_group_arn target_id = aws_instance.james_node[count.index].id // port = 8000 target port of the EC2, this will override the port in target group on a per instance basis }
compute/outputs.tf
output "instance" { value = aws_instance.james_node[*] sensitive = true }
compute/variables.tf
variable "instance_count" { type = number } variable "instance_type" { type = string } variable "public_security_gp_ids" { type = list(string) } variable "public_subnet_ids" { type = list(string) } variable "vol_size" { type = number } variable "key_name" { type = string } variable "public_key_path" { type = string } variable "user_data_path" { type = string } variable "db_user" { type = string } variable "db_password" { type = string } variable "db_endpoint" { type = string } variable "db_name" { type = string } variable "ec2_security_group_id" { type = string } variable "james_target_group_arn" { type = string }
Database Module
database/main.tf
resource "aws_db_instance" "james_db" { allocated_storage = var.db_storage engine = var.db_engine engine_version = var.db_engine_version instance_class = var.db_instance_class db_name = var.db_name username = var.db_username password = var.db_password db_subnet_group_name = var.db_subnet_group_name vpc_security_group_ids = var.vpc_security_group_ids // the RDS instance must be assigned at least one security group. identifier = var.db_identifier skip_final_snapshot = var.skip_final_snapshot tags = { Name = "james-db" } }
database/outputs.tf
output "db_endpoint" { value = aws_db_instance.james_db.endpoint }
database/variables.tf
variable "db_storage" { type = number description = "Should be an integer in GB" } variable "db_engine" { type = string default = "mysql" } variable "db_engine_version" { type = string } variable "db_instance_class" { type = string } variable "db_name" { type = string } variable "db_username" { type = string } variable "db_password" { type = string } variable "db_subnet_group_name" { type = string } variable "vpc_security_group_ids" { type = list(string) } variable "db_identifier" { type = string } variable "skip_final_snapshot" { type = bool }
Load Balancer Module
loadbalancing/main.tf
Here the binding (attachment) of aws_lb_target_group.james_target_group
is done in compute/main.tf
:
resource "aws_lb" "james_lb" { name = "james-loadbalancer" subnets = var.public_subnets security_groups = var.public_security_groups idle_timeout = 900 } /* Avoid naming conflicts during recreate/redeploy: When you destroy and recreate resources, AWS keeps the old name in a "cooling period" Without random suffixes, you might get errors like "name already exists" when redeploying */ resource "aws_lb_target_group" "james_target_group" { name = "james-lb-tg-${substr(uuid(), 0, 4)}" port = var.tg_port protocol = var.tg_protocol vpc_id = var.vpc_id lifecycle { ignore_changes = [name] create_before_destroy = true // when set to false, the destruction will be halted because: // when we change the port, the target group will be destroyed, // and the listener has no where to route the traffic until the new target group is created // but listener cannot live without target group, the deletion of the target group will get halted due to AWS's own validation // we use create_before_destroy = true to make sure there must be an existing target group assignable to the listener } health_check { healthy_threshold = var.lb_healthy_threshold unhealthy_threshold = var.lb_unhealthy_threshold timeout = var.lb_timeout interval = var.lb_interval } } resource "aws_lb_listener" "james_lb_listener" { load_balancer_arn = aws_lb.james_lb.arn port = var.listener_port protocol = var.listener_portocol default_action { type = "forward" target_group_arn = aws_lb_target_group.james_target_group.arn } }
loadbalancing/outputs.tf
output "james_target_group_arn" { value = aws_lb_target_group.james_target_group.arn } output "lb_endpoint" { value = aws_lb.james_lb.dns_name }
loadbalancing/variables.tf
variable "public_security_groups" { type = list(string) } variable "public_subnets" { type = list(string) } variable "tg_port" { type = string } variable "tg_protocol" { type = string } variable "vpc_id" { type = string } variable "lb_healthy_threshold" { type = string } variable "lb_unhealthy_threshold" { type = string } variable "lb_timeout" { type = number } variable "lb_interval" { type = number } variable "listener_port" { type = number } variable "listener_portocol" { type = string }
Network Module
network/main.tf
resource "random_integer" "random" { min = 1 max = 100 } data "aws_availability_zones" "available" { state = "available" } resource "aws_vpc" "james_vpc" { cidr_block = var.vpc_cidr enable_dns_hostnames = true enable_dns_support = true tags = { Name = "james_vpc-${random_integer.random.id}" } lifecycle { create_before_destroy = true } } resource "aws_route_table_association" "james_public_subnet_association" { count = length(var.public_cidrs) subnet_id = aws_subnet.james_public_subnet.*.id[count.index] route_table_id = aws_route_table.james_public_route_table.id } resource "aws_subnet" "james_public_subnet" { count = length(var.public_cidrs) vpc_id = aws_vpc.james_vpc.id cidr_block = var.public_cidrs[count.index] map_public_ip_on_launch = true availability_zone = data.aws_availability_zones.available.names[count.index] tags = { Name = "james_public_subnet_${count.index + 1}" } } resource "aws_subnet" "james_private_subnet" { count = length(var.private_cidrs) vpc_id = aws_vpc.james_vpc.id cidr_block = var.private_cidrs[count.index] map_public_ip_on_launch = false availability_zone = data.aws_availability_zones.available.names[count.index] tags = { Name = "james_private_subnet_${count.index + 1}" } } resource "aws_internet_gateway" "james_internet_gateway" { vpc_id = aws_vpc.james_vpc.id tags = { Name = "james_igw" } } resource "aws_route_table" "james_public_route_table" { vpc_id = aws_vpc.james_vpc.id tags = { Name = "james_public_route_table" } route { cidr_block = "0.0.0.0/0" gateway_id = aws_internet_gateway.james_internet_gateway.id } } # default VPC comes with an Internet Gateway and internet access pre-configured because # it's designed for immediate use and backward compatibility with EC2-Classic. # It's meant to help users get started quickly, while custom VPCs follow stricter security practices. # for new VPC there is no internet gateway and therefore its default route table is a suitable candidate to be assigned # to private subnet, and we create additional one for public subnet with a record from igw to 0.0.0.0/0 resource "aws_default_route_table" "james_private_route_table" { default_route_table_id = aws_vpc.james_vpc.default_route_table_id tags = { Name = "james_private_route_table" } } resource "aws_security_group" "james_ssh_sg" { name = "ssh_sg" description = "Security Group for SSH Access" vpc_id = aws_vpc.james_vpc.id ingress { from_port = 22 to_port = 22 protocol = "tcp" cidr_blocks = [var.ssh_access_ip] description = "for SSH access" } egress { from_port = 0 to_port = 0 protocol = "-1" cidr_blocks = ["0.0.0.0/0"] } } resource "aws_security_group" "james_http_sg" { name = "james_http_sg" description = "Security Group for Http" vpc_id = aws_vpc.james_vpc.id ingress { from_port = 80 to_port = 80 protocol = "tcp" cidr_blocks = ["0.0.0.0/0"] description = "port 80 for public access" } ingress { from_port = 8000 to_port = 8000 protocol = "tcp" cidr_blocks = ["0.0.0.0/0"] description = "port 8000 for public access" } } resource "aws_security_group" "ec2_security_group" { name = "james_ec2_sg" description = "Security Group for EC2" vpc_id = aws_vpc.james_vpc.id } resource "aws_security_group" "james_private_rds" { name = "james_private_rds" description = "Security Group for Private RDS" vpc_id = aws_vpc.james_vpc.id ingress { from_port = 3306 to_port = 3306 protocol = "tcp" cidr_blocks = [var.vpc_cidr] description = "pgsql allows everything in the VPC to access" } lifecycle { create_before_destroy = true } } resource "aws_db_subnet_group" "james_rds_subnet_group" { count = var.create_db_subnet_group == true ? 1 : 0 name = "james_rds_subnet_group" subnet_ids = aws_subnet.james_private_subnet.*.id tags = { Name = "james_rds_subnet_group" } }
network/outputs.tf
output "vpc_id" { value = aws_vpc.james_vpc.id } output "db_subnet_group_names" { value = aws_db_subnet_group.james_rds_subnet_group.*.name } output "db_security_group_id" { value = aws_security_group.james_private_rds.id } output "public_subnet_ids" { value = aws_subnet.james_public_subnet.*.id } output "public_http_sg" { value = aws_security_group.james_http_sg } output "public_ssh_sg" { value = aws_security_group.james_ssh_sg } output "james_ec2_sg" { value = aws_security_group.ec2_security_group }
network/variables.tf
variable "vpc_cidr" { type = string } variable "public_cidrs" { type = list(string) } variable "private_cidrs" { type = list(string) } variable "ssh_access_ip" { type = string } variable "create_db_subnet_group" { type = bool }
<project-root>/userdata.tpl
#!/bin/bash sudo hostnamectl set-hostname ${nodename} && curl -sfL https://get.k3s.io | sh -s - server \ --datastore-endpoint="mysql://${dbuser}:${dbpass}@tcp(${db_endpoint})/${dbname}" \ --write-kubeconfig-mode 644 \ --tls-san=$(curl http://169.254.169.254/latest/meta-data/public-ipv4) \ --token="th1s1sat0k3n!"