diff --git a/.gitignore b/.gitignore index 0c2dc01..22354d3 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,4 @@ terraform.tfstate.d .env .idea tf-plan* +mongodb.key diff --git a/CHANGELOG.md b/CHANGELOG.md index 4383e6d..7934b4b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,18 @@ All notable changes to this project will be documented in this file. +## [1.1.0] - 2024-01-23 + +[1.1.0]: https://github.com/abdullahkhawer/mongodb-cluster-on-aws-ecs/releases/tag/v1.1.0 + +### Features + +- Update code to set the threshold for CPU, memory, and Disk space utilization to 85%, create locals to define AWS VPC private subnets along with their length, select the correct AWS VPC private subnet ID even if there are fewer subnets than the number of AWS EC2 instances, select the correct private AWS Route 53 hosted zone if both public and private exist with the same name/domain, set correct AWS ECS cluster name under dimensions for AWS CloudWatch metric alarms, fix Terraform code with respect to the AWS Terraform provider v4.65.0, update backups AWS S3 bucket's lifecycle policy rules to set a rule for INTELLIGENT_TIERING, add code to wait for the first AWS EC2 instance to be running and complete status checks, refactor the whole Terraform code and update the README accordingly. + +### Miscellaneous Tasks + +- Add mongodb.key in .gitignore file. + ## [1.0.0] - 2024-01-15 [1.0.0]: https://github.com/abdullahkhawer/mongodb-cluster-on-aws-ecs/releases/tag/v1.0.0 diff --git a/README.md b/README.md index 4527668..122b5d7 100644 --- a/README.md +++ b/README.md @@ -1,20 +1,21 @@ -# MongoDB cluster on AWS ECS +# MongoDB Cluster on AWS ECS - Terraform Module - Founder: Abdullah Khawer (LinkedIn: https://www.linkedin.com/in/abdullah-khawer/) ## Introduction -A Terraform module developed to quickly deploy a secure, persistent, highly available, self healing, efficient and cost effective single-node or multi-node MongoDB NoSQL document database cluster on AWS ECS cluster as there is no managed service available for MongoDB on AWS with such features. +A Terraform module developed to quickly deploy a secure, persistent, highly available, self healing, efficient and cost effective single-node or multi-node MongoDB NoSQL document database cluster on AWS ECS cluster with monitoring and alerting enabled as there is no managed service available for MongoDB on AWS with such features. ## Key Highlights -- A single-node or multi-node MongoDB cluster under AWS Auto Scaling group to launch multiple MongoDB nodes as replicas to make it highly available, efficient and self healing with a help of bootstrapping script with some customizations. -- Using AWS ECS service registry with awsvpc as network mode instead of AWS ELB to save cost on networking side and make it more secure. AWS ECS task IPs are updated by the bootstrapping script on an AWS Route 53 hosted zone. +- A single-node (1 node) or multi-node (2 or 3 nodes) MongoDB cluster under AWS Auto Scaling group to launch multiple MongoDB nodes as replicas to make it highly available, efficient and self healing with a help of bootstrapping script with some customizations. +- Using AWS Route 53 private hosted zone for AWS ECS services with `awsvpc` as the network mode instead of AWS ELB to save cost on networking side and make it more secure. AWS ECS services' task IPs are updated on the AWS Route 53 private hosted zone by the bootstrapping script that runs on each AWS EC2 instance node as user data. - Persistent and encrypted AWS EBS volumes of type gp3 using rexray/ebs Docker plugin so the data stays secure and reliable. -- AWS S3 bucket for backups storage for disaster recovery along with lifecycle rules for data archival and deletion. -- Custom backup and restore scripts for data migration and disaster recovery capabilities available on each AWS EC2 instance due to a bootstrapping script. -- Each AWS EC2 instance is configured with various customizations like pre-installed wget, unzip, awscli, Docker, ECS agent, MongoDB, Mongosh, MongoDB database tools, key file for MongoDB Cluster, custom agent for AWS EBS volumes disk usage monitoring and cronjobs to take a backup at 03:00 AM daily and to send disk usage metrics to AWS CloudWatch at every minute. -- Each AWS EC2 instance is configured with soft rlimits and ulimits defined and transparent huge pages disabled to make MongoDB database more efficient. +- AWS S3 bucket for backups storage for disaster recovery along with a lifecycle rule with Intelligent-Tiering as storage class for objects to save data storage cost. +- Custom backup and restore scripts for data migration and disaster recovery capabilities are available on each AWS EC2 instance node by the bootstrapping script running as user data. +- Each AWS EC2 instance node is configured with various customizations like pre-installed wget, unzip, awscli, Docker, ECS agent, MongoDB, Mongosh, MongoDB database tools, key file for MongoDB Cluster, custom agent for AWS EBS volumes disk usage monitoring and cronjobs to take a backup at 03:00 AM UTC daily and to send disk usage metrics to AWS CloudWatch at every minute. +- Each AWS EC2 instance node is configured with soft rlimits and ulimits defined and transparent huge pages disabled to make MongoDB database more efficient. +- AWS CloudWatch alarms to send alerts when the utilization of CPU, Memory and Disk Space goes beyond 85%. ## Usage Notes @@ -28,8 +29,9 @@ Following are the resources that should exist already before starting the deploy - `openssl rand -base64 756 > mongodb.key` - `chmod 400 mongodb.key` - 1 key pair named `[PROJECT]-[ENVIRONMENT_NAME]-mongodb` under **AWS EC2 Key Pairs**. -- 1 private hosted zone under **AWS Route53** with any working domain. -- 1 vpc under **AWS VPC** having at least 1 private subnet or ideally, 3 private and 3 public subnets with name tags (e.g., Private-1-Subnet, Private-2-Subnet, etc). +- 1 private hosted zone under **AWS Route53** with a working domain. +- 1 vpc under **AWS VPC** having at least 1, 2 or 3 private subnets having a name tag on each (e.g., Private-1-Subnet, Private-2-Subnet, etc). +- 1 topic under **AWS SNS** to send notifications via AWS CloudWatch alarms. ## Deployment Instructions @@ -37,40 +39,40 @@ Simply deploy it from the terraform directory directly or either as a Terraform ## Post Deployment Replica Set Configuration -Once the deployment is done, log into the MongoDB cluster via its 1st AWS EC2 instance node using AWS SSM Session Manager using the following command: `mongosh "mongodb://[USERNAME]:[PASSWORD]@mongodb1.[ENVIRONMENT_NAME]-local:27017/admin?&retryWrites=false"` +Once the deployment is done, log into the MongoDB cluster via its 1st AWS EC2 instance node using AWS SSM Session Manager using the following command after replacing `[USERNAME]`, `[PASSWORD]`, `[ENVIRONMENT_NAME]` and `[AWS_ROUTE_53_PRIVATE_HOSTED_ZONE_NAME]` in it: `mongosh "mongodb://[USERNAME]:[PASSWORD]@[ENVIRONMENT_NAME]-mongodb1.[AWS_ROUTE_53_PRIVATE_HOSTED_ZONE_NAME]:27017/admin?&retryWrites=false"` -Then initiate the replica set using the following command: +Then initiate the replica set using the following command after replacing `[ENVIRONMENT_NAME]` and `[AWS_ROUTE_53_PRIVATE_HOSTED_ZONE_NAME]` in it: ``` rs.initiate({ _id: "rs0", members: [ - { _id: 0, host: "mongodb1.[ENVIRONMENT_NAME]-local:27017" }, - { _id: 1, host: "mongodb2.[ENVIRONMENT_NAME]-local:27017" }, - { _id: 2, host: "mongodb3.[ENVIRONMENT_NAME]-local:27017" } + { _id: 0, host: "[ENVIRONMENT_NAME]-mongodb1.[AWS_ROUTE_53_PRIVATE_HOSTED_ZONE_NAME]:27017" }, + { _id: 1, host: "[ENVIRONMENT_NAME]-mongodb2.[AWS_ROUTE_53_PRIVATE_HOSTED_ZONE_NAME]:27017" }, + { _id: 2, host: "[ENVIRONMENT_NAME]-mongodb3.[AWS_ROUTE_53_PRIVATE_HOSTED_ZONE_NAME]:27017" } ] }) ``` -You can now connect to the replica set using the following command: `mongosh "mongodb://[USERNAME]:[PASSWORD]@mongodb1.[ENVIRONMENT_NAME]-local:27017,mongodb2.[ENVIRONMENT_NAME]-local:27017,mongodb3.[ENVIRONMENT_NAME]-local:27017/admin?replicaSet=rs0&readPreference=secondaryPreferred&retryWrites=true"` +You can now connect to the replica set using the following command after replacing `[USERNAME]`, `[PASSWORD]`, `[ENVIRONMENT_NAME]` and `[AWS_ROUTE_53_PRIVATE_HOSTED_ZONE_NAME]` in it: `mongosh "mongodb://[USERNAME]:[PASSWORD]@[ENVIRONMENT_NAME]-mongodb1.[AWS_ROUTE_53_PRIVATE_HOSTED_ZONE_NAME]:27017,[ENVIRONMENT_NAME]-mongodb2.[AWS_ROUTE_53_PRIVATE_HOSTED_ZONE_NAME]:27017,[ENVIRONMENT_NAME]-mongodb3.[AWS_ROUTE_53_PRIVATE_HOSTED_ZONE_NAME]:27017/admin?replicaSet=rs0&readPreference=secondaryPreferred&retryWrites=true"` *Note: The sample commands in the above example assumes that the cluster has 3 nodes.* ## Replica Set Recovery -If you lost the replica set, you can reconfigure it using the following commands: +If you lost the replica set, you can reconfigure it using the following commands after replacing `[ENVIRONMENT_NAME]` and `[AWS_ROUTE_53_PRIVATE_HOSTED_ZONE_NAME]` in them: ``` rs.reconfig({ _id: "rs0", members: [ - { _id: 0, host: "mongodb1.stage-local:27017" } + { _id: 0, host: "[ENVIRONMENT_NAME]-mongodb1.[AWS_ROUTE_53_PRIVATE_HOSTED_ZONE_NAME]:27017" } ] }, {"force":true}) -rs.add({ _id: 1, host: "mongodb2.stage-local:27017" }) +rs.add({ _id: 1, host: "[ENVIRONMENT_NAME]-mongodb2.[AWS_ROUTE_53_PRIVATE_HOSTED_ZONE_NAME]:27017" }) -rs.add({ _id: 2, host: "mongodb3.stage-local:27017" }) +rs.add({ _id: 2, host: "[ENVIRONMENT_NAME]-mongodb3.[AWS_ROUTE_53_PRIVATE_HOSTED_ZONE_NAME]:27017" }) ``` *Note: The sample commands in the above example assumes that the cluster has 3 nodes.* diff --git a/VERSION b/VERSION index 0ec25f7..795460f 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -v1.0.0 +v1.1.0 diff --git a/terraform-usage-example.tf b/terraform-usage-example.tf index ccefabb..8dd6117 100644 --- a/terraform-usage-example.tf +++ b/terraform-usage-example.tf @@ -20,12 +20,12 @@ module "mongodb-cluster-on-aws-ecs" { image = "docker.io/mongo:5.0.6" hosted_zone_name = "project.net" # dummy value ec2_key_pair_name = "project-dev-mongodb" # dummy value - number_of_instances = 3 - private_subnet_tag_name = "Private-1*" # dummy value + number_of_instances = 3 # Minimum 1, Maximum 3 + private_subnet_tag_name = "Private-1*" # dummy value # If you want to enable disk usage monitoring monitoring_enabled = true - alarm_treat_missing_data = "missing" + alarm_treat_missing_data = "ignore" aws_sns_topic = "arn:aws:sns:eu-west-1:012345678910:AWS_SNS_TOPIC_NAME" # If you want to enable backups diff --git a/terraform/cloudwatch.tf b/terraform/cloudwatch.tf deleted file mode 100644 index 084d358..0000000 --- a/terraform/cloudwatch.tf +++ /dev/null @@ -1,43 +0,0 @@ -resource "aws_cloudwatch_metric_alarm" "ecs_cpu_utilization" { - count = var.monitoring_enabled == true ? var.number_of_instances : 0 - - alarm_name = "${var.namespace}-${var.env_name}-${var.service_name}${count.index + 1}-cpu-utilization" - comparison_operator = "GreaterThanOrEqualToThreshold" - evaluation_periods = "2" - metric_name = "CPUUtilization" - namespace = "AWS/ECS" - period = "300" - statistic = "Average" - threshold = "80" - treat_missing_data = var.alarm_treat_missing_data - alarm_description = "${var.namespace}-${var.env_name}-${var.service_name}${count.index + 1} Container CPU Utilization" - alarm_actions = [var.aws_sns_topic] - ok_actions = [var.aws_sns_topic] - - dimensions = { - ServiceName = "${var.service_name}${count.index + 1}" - ClusterName = "${var.namespace}-${var.env_name}-${var.service_name}-cluster" - } -} - -resource "aws_cloudwatch_metric_alarm" "ecs_memory_utilization" { - count = var.monitoring_enabled == true ? var.number_of_instances : 0 - - alarm_name = "${var.namespace}-${var.env_name}-${var.service_name}${count.index + 1}-memory-utilization" - comparison_operator = "GreaterThanOrEqualToThreshold" - evaluation_periods = "2" - metric_name = "MemoryUtilization" - namespace = "AWS/ECS" - period = "300" - statistic = "Average" - threshold = "90" - treat_missing_data = var.alarm_treat_missing_data - alarm_description = "${var.namespace}-${var.env_name}-${var.service_name}${count.index + 1} Container Memory Utilization" - alarm_actions = [var.aws_sns_topic] - ok_actions = [var.aws_sns_topic] - - dimensions = { - ServiceName = "${var.service_name}${count.index + 1}" - ClusterName = "${var.namespace}-${var.env_name}-${var.service_name}-cluster" - } -} diff --git a/terraform/data.tf b/terraform/data.tf deleted file mode 100644 index c04ce7e..0000000 --- a/terraform/data.tf +++ /dev/null @@ -1,34 +0,0 @@ -data "aws_vpc" "this" { - id = var.vpc_id -} - -data "aws_subnets" "this" { - filter { - name = "vpc-id" - values = [var.vpc_id] - } - - tags = { - Name = var.private_subnet_tag_name - } -} - -data "aws_route53_zone" "this" { - name = var.hosted_zone_name -} - -data "aws_ami" "amzlinux2" { - most_recent = true - - filter { - name = "name" - values = ["amzn2-ami-ecs-hvm-*-x86_64*"] - } - - filter { - name = "virtualization-type" - values = ["hvm"] - } - - owners = ["amazon"] -} diff --git a/terraform/ec2_asg.tf b/terraform/ec2_asg.tf deleted file mode 100644 index cece66e..0000000 --- a/terraform/ec2_asg.tf +++ /dev/null @@ -1,83 +0,0 @@ -resource "aws_launch_template" "this" { - count = var.number_of_instances - - name = "${var.namespace}-${var.env_name}-${var.service_name}${count.index + 1}-launch-template" - image_id = data.aws_ami.amzlinux2.id - instance_type = var.instance_type - key_name = var.ec2_key_pair_name - ebs_optimized = true - vpc_security_group_ids = [aws_security_group.this.id] - update_default_version = true - - block_device_mappings { - device_name = "/dev/xvda" - - ebs { - volume_type = var.volume_type - volume_size = 30 - iops = var.volume_iops - encrypted = var.volume_encrypted - kms_key_id = var.volume_encryption_key == null ? aws_kms_key.ebs[0].arn : var.volume_encryption_key - } - } - - iam_instance_profile { - name = aws_iam_instance_profile.this.name - } - - monitoring { - enabled = true - } - - user_data = base64encode(templatefile("${path.module}/scripts/user_data.sh", { - ECS_CLUSTER = aws_ecs_cluster.this.name - ECS_INSTANCE_ATTRIBUTES = "{\"name\":\"${var.service_name}${count.index + 1}\"}" - AWS_REGION = var.aws_region - ASG_NAME = "${var.namespace}-${var.env_name}-${var.service_name}${count.index + 1}-asg" - LINE = "$LINE" - ENABLE_MONITORING = var.monitoring_enabled == true ? "YES" : "NO" - ALARM_NAME_PREFIX = "${var.namespace}-${var.env_name}-${var.service_name}${count.index + 1}" - ALARM_TREAT_MISSING_DATA = var.alarm_treat_missing_data - ALARM_SNS_TOPIC = var.aws_sns_topic - HOSTED_ZONE_ID = data.aws_route53_zone.this.zone_id - DNS_NAME = "${var.env_name}-${var.service_name}${count.index + 1}.${var.hosted_zone_name}" - BACKUP_S3_BUCKET_NAME = "${var.namespace}-${var.env_name}-mongodb-backups-bucket" - ENABLE_BACKUP = var.backup_enabled == true && count.index == 0 ? "YES" : "NO" - MONGODB_USER_PARAMETER_NAME = "/docker/${var.env_name}/MONGODB_USERNAME" - MONGODB_PASSWORD_PARAMETER_NAME = "/docker/${var.env_name}/MONGODB_PASSWORD" - MONGODB_KEYFILE_PARAMETER_NAME = "/docker/${var.env_name}/MONGODB_KEYFILE" - MONGO_DATABASES = var.mongodb_backup_databases - })) - - tag_specifications { - resource_type = "instance" - - tags = { - Name = "${var.namespace}-${var.env_name}-${var.service_name}${count.index + 1}" - } - } -} - -resource "aws_autoscaling_group" "this" { - count = var.number_of_instances - - name = "${var.namespace}-${var.env_name}-${var.service_name}${count.index + 1}-asg" - vpc_zone_identifier = [tolist(data.aws_subnets.this.ids)[count.index]] - desired_capacity = 1 - max_size = 1 - min_size = 1 - health_check_type = "EC2" - - launch_template { - id = aws_launch_template.this[count.index].id - version = aws_launch_template.this[count.index].latest_version - } - - instance_refresh { - strategy = "Rolling" - preferences { - min_healthy_percentage = 0 - instance_warmup = 0 - } - } -} \ No newline at end of file diff --git a/terraform/ecs_cluster.tf b/terraform/ecs_cluster.tf deleted file mode 100644 index 3e28c40..0000000 --- a/terraform/ecs_cluster.tf +++ /dev/null @@ -1,8 +0,0 @@ -resource "aws_ecs_cluster" "this" { - name = "${var.namespace}-${var.env_name}-${var.service_name}" - - setting { - name = "containerInsights" - value = "enabled" - } -} \ No newline at end of file diff --git a/terraform/ecs_service.tf b/terraform/ecs_service.tf deleted file mode 100644 index 30fbf0e..0000000 --- a/terraform/ecs_service.tf +++ /dev/null @@ -1,12 +0,0 @@ -resource "aws_ecs_service" "this" { - count = var.number_of_instances - - name = "${var.service_name}${count.index + 1}" - cluster = aws_ecs_cluster.this.id - launch_type = "EC2" - task_definition = aws_ecs_task_definition.this[count.index].arn - force_new_deployment = false - desired_count = 1 - deployment_maximum_percent = 100 - deployment_minimum_healthy_percent = 0 -} \ No newline at end of file diff --git a/terraform/ecs_task_definition.tf b/terraform/ecs_task_definition.tf deleted file mode 100644 index 0a0c33b..0000000 --- a/terraform/ecs_task_definition.tf +++ /dev/null @@ -1,82 +0,0 @@ -resource "aws_ecs_task_definition" "this" { - count = var.number_of_instances - - family = "${var.service_name}${count.index + 1}-${var.env_name}" - requires_compatibilities = ["EC2"] - task_role_arn = aws_iam_role.task.arn - execution_role_arn = aws_iam_role.task.arn - network_mode = "host" - skip_destroy = false - - placement_constraints { - type = "memberOf" - expression = "attribute:name == ${var.service_name}${count.index + 1}" - } - - volume { - name = "${var.namespace}-${var.env_name}-${var.service_name}${count.index + 1}-ebs" - - docker_volume_configuration { - scope = "shared" - autoprovision = true - driver = "rexray/ebs" - labels = {} - - driver_opts = { - volumetype = var.volume_type - size = var.volume_size - iops = var.volume_iops - encrypted = var.volume_encrypted - encryptionkey = var.volume_encryption_key == null ? aws_kms_key.ebs[0].arn : var.volume_encryption_key - } - } - } - - volume { - name = "mongodb-keys" - host_path = "/usr/bin/keys" - } - - container_definitions = templatefile("${path.module}/templates/container-definition.json.tpl", { - aws_region = var.aws_region - namespace = var.namespace - env_name = var.env_name - service_name = var.service_name - name = "${var.service_name}${count.index + 1}" - image = var.image - cpu = var.cpu - memory = var.memory - privileged = true - command = var.number_of_instances > 1 ? jsonencode(["--replSet", "rs0", "--keyFile", "/keys/mongodb.key"]) : jsonencode([]) - - portMappings = jsonencode([ - { - "protocol" : "tcp", - "containerPort" : 27017, - "hostPort" : 27017 - } - ]) - - mountPoints = jsonencode([ - { - "containerPath" : "/data/db", - "sourceVolume" : "${var.namespace}-${var.env_name}-${var.service_name}${count.index + 1}-ebs" - }, - { - "containerPath" : "/keys", - "sourceVolume" : "mongodb-keys" - } - ]) - - secrets = jsonencode([ - { - "name" : "MONGO_INITDB_ROOT_USERNAME", - "valueFrom" : "arn:aws:ssm:${var.aws_region}:${var.account_id}:parameter/docker/${var.env_name}/MONGODB_USERNAME" - }, - { - "name" : "MONGO_INITDB_ROOT_PASSWORD", - "valueFrom" : "arn:aws:ssm:${var.aws_region}:${var.account_id}:parameter/docker/${var.env_name}/MONGODB_PASSWORD" - } - ]) - }) -} diff --git a/terraform/iam_ec2.tf b/terraform/iam_ec2.tf deleted file mode 100644 index f0af289..0000000 --- a/terraform/iam_ec2.tf +++ /dev/null @@ -1,167 +0,0 @@ -resource "aws_iam_instance_profile" "this" { - name = "${var.namespace}-${var.env_name}-${var.service_name}-ec2-profile" - role = aws_iam_role.this.name -} - -resource "aws_iam_role_policy_attachment" "this" { - count = length(local.ec2_role_policies) - - role = aws_iam_role.this.name - policy_arn = element(local.ec2_role_policies, count.index) -} - -resource "aws_iam_role_policy" "ec2" { - name = "${var.namespace}-${var.env_name}-${var.service_name}-ec2-inline-policy" - role = aws_iam_role.this.id - - policy = < 1 ? jsonencode(["--replSet", "rs0", "--keyFile", "/keys/mongodb.key"]) : jsonencode([]) + + portMappings = jsonencode([ + { + "protocol" : "tcp", + "containerPort" : 27017, + "hostPort" : 27017 + } + ]) + + mountPoints = jsonencode([ + { + "containerPath" : "/data/db", + "sourceVolume" : "${var.namespace}-${var.env_name}-${var.service_name}${count.index + 1}-ebs" + }, + { + "containerPath" : "/keys", + "sourceVolume" : "mongodb-keys" + } + ]) + + secrets = jsonencode([ + { + "name" : "MONGO_INITDB_ROOT_USERNAME", + "valueFrom" : "arn:aws:ssm:${var.aws_region}:${var.account_id}:parameter/docker/${var.env_name}/MONGODB_USERNAME" + }, + { + "name" : "MONGO_INITDB_ROOT_PASSWORD", + "valueFrom" : "arn:aws:ssm:${var.aws_region}:${var.account_id}:parameter/docker/${var.env_name}/MONGODB_PASSWORD" + } + ]) + }) +} + +resource "aws_iam_instance_profile" "this" { + name = "${var.namespace}-${var.env_name}-${var.service_name}-ec2-profile" + role = aws_iam_role.this.name +} + +resource "aws_iam_role_policy_attachment" "this" { + count = length(local.ec2_role_policies) + + role = aws_iam_role.this.name + policy_arn = element(local.ec2_role_policies, count.index) +} + +resource "aws_iam_role_policy" "ec2" { + name = "${var.namespace}-${var.env_name}-${var.service_name}-ec2-inline-policy" + role = aws_iam_role.this.id + + policy = <