├── README.md ├── aws.tf ├── ec2.tf ├── ecr.tf ├── ecs-role.json ├── ecs.tf ├── efs.tf ├── iam.tf ├── outputs.tf ├── packer ├── playbook.yml └── wordpress-packer.json ├── rds.tf ├── route53.tf ├── securitygroup.tf ├── subnet.tf ├── userdata.sh ├── vpc.tf └── wordpress_task.json /README.md: -------------------------------------------------------------------------------- 1 | # Wordpress on ECS 2 | This project contains `terraform` and `packer` files to provision a Wordpress service on top of AWS EC2 Container Service. It deploys by default in region `us-west-2` and spans two availability zones. 3 | 4 | ## Instructions 5 | As we're using AWS `ECR` to store our docker containers and that our `ECS` cluster is pulling from it, we'll need to deploy our infrastructure first and then build and push our Wordpress container with `packer`. 6 | 7 | What you'll need on your machine 8 | - packer 9 | - terraform 10 | - docker 11 | - ansible 12 | 13 | Export your AWS credentials 14 | ``` 15 | export AWS_ACCESS_KEY_ID=your_access_key 16 | export AWS_SECRET_ACCESS_KEY=your_secret_access_key 17 | ``` 18 | Deploy the infrastructure and get the `ECR` url (without the repo name) 19 | ``` 20 | terraform apply 21 | export ECR_REPOSITORY=$(terraform output ecr_repository | sed 's/\/.*//') 22 | ``` 23 | 24 | Build and push our Wordpress container to `ECR` 25 | ``` 26 | cd packer 27 | packer build wordpress-packer.json 28 | ``` 29 | 30 | `ECS` agents should automatically pull our freshly pushed Wordpress image and start it. Wait a few minutes and point your web-browser to the `ELB` address: 31 | ``` 32 | cd .. 33 | terraform output elb_dns 34 | ``` 35 | 36 | ## Technical 37 | We want our wordpress to 38 | - scale easily 39 | - be highly available 40 | - be secure 41 | 42 | #### Making the container stateless 43 | To achieve our goal of easy scalability we want to make our Wordpress container stateless, meaning that no particular data are attached to the host the container is running on. 44 | Wordpress text article content is stored on an external database so we're good on this side. We'll use a `RDS` mysql instance for that. 45 | Wordpress static content is stored at path `/var/www/html/wp-content` of the container. We'll store this on some storage space shared between hosts and mounted in the container. We'll use the `EFS` service for that (AWS nfs as a service). 46 | We'll use an internal route53 DNS zone to associate simple names to our services: 47 | - db.wordpress.ael for RDS 48 | - nfs.wordpress.ael for EFS 49 | 50 | We also want to put our ECS instances in an autoscaling group and put an ELB with HTTP healthcheck in front of it (cloudwatch alarms for autoscaling not implemented yet). 51 | 52 | #### HA 53 | To achieve HA we'll span our autoscaling group in two different availability zones. 54 | Our RDS and NFS services are accessible to those two zones but are not HA, we should add that for production deployement. 55 | 56 | #### Security 57 | We use a dedicated VPC for our project, associate restrictive security groups to instances and put our ECS instances, DB and NFS services in private subnets which access the internet through a NAT (ECS instances need to install nfs-utils at startup and pull ECR repo). Only The ELB resides in the public subnet. 58 | 59 | #### Implemented architecture 60 | (NAT and internet gateway are not shown for clarity purposes) 61 | ``` 62 | us-west-2 63 | +--------------------------------------------------------------+ 64 | | | 65 | | +----------------+ +----------------+ | 66 | | | +-----------------+ | | 67 | | | | |ELB | | | | 68 | |public | +-----------------+ | public | 69 | |us-west-2a +----------------+ || +----------------+ us-west-2b| 70 | | || | 71 | | +----------------+ || +----------------+ | 72 | | | | || | | | 73 | | | +------------+ | || | +------------+ | | 74 | | | |ECS instance| | || | |ECS instance| | | 75 | | | | +^------^+ | | | 76 | | | +-----^----^-+ | | +-----^-----^+ | | 77 | |private | | | | | | | | private | 78 | |us-west-2a +----------------+ +----------------+ us—west—2b| 79 | | | | | | | 80 | | +--+--+ +-------------+--+--+ | | 81 | | | RDS | | EFS | | | 82 | | +-----+ +-----+ | | 83 | | +------------------------+ | 84 | +--------------------------------------------------------------+ 85 | ``` 86 | # To improve 87 | For production deployments, the following should be implemented: 88 | - extract logs from Wordpress containers (push to elasticsearch/cloudwatch logs...) 89 | - increase instance capacity (t2.micro currently) 90 | - increase DB size, monitor remaining space and make backups (5GB at the moment) 91 | - set up CDN to serve static content (AWS one, clouflare, MaxCDN...) 92 | - set up Cloudwatch alarms on the ASG so we can really autoscale 93 | - customize Wordpress image for performance (use nginx, php fpm, tweak perf parameters...) 94 | -------------------------------------------------------------------------------- /aws.tf: -------------------------------------------------------------------------------- 1 | provider "aws" { 2 | region = "us-west-2" 3 | } 4 | -------------------------------------------------------------------------------- /ec2.tf: -------------------------------------------------------------------------------- 1 | resource "aws_launch_configuration" "ec2" { 2 | name = "ecs-configuration" 3 | instance_type = "t2.micro" 4 | image_id = "ami-022b9262" 5 | security_groups = ["${aws_security_group.ecs.id}", "${aws_security_group.ec2_egress.id}"] 6 | iam_instance_profile = "${aws_iam_instance_profile.ecs.name}" 7 | user_data = "${data.template_file.ec2_userdata.rendered}" 8 | } 9 | 10 | # We need this 'depend_on' line otherwise we may not be able to reach internet at first terraform apply command 11 | resource "aws_autoscaling_group" "ec2" { 12 | depends_on = ["aws_nat_gateway.natgw_zoneA", "aws_nat_gateway.natgw_zoneB"] 13 | name = "ecs-autoscale" 14 | vpc_zone_identifier = ["${aws_subnet.private_subnet_zoneA.id}", "${aws_subnet.private_subnet_zoneB.id}"] 15 | launch_configuration = "${aws_launch_configuration.ec2.name}" 16 | min_size = 1 17 | max_size = 3 18 | desired_capacity = 1 19 | load_balancers = ["${aws_elb.ec2.name}"] 20 | } 21 | 22 | resource "aws_elb" "ec2" { 23 | name = "wordpress-elb" 24 | security_groups = ["${aws_security_group.ecs.id}", "${aws_security_group.elb.id}"] 25 | subnets = ["${aws_subnet.public_subnet_zoneA.id}", "${aws_subnet.public_subnet_zoneB.id}"] 26 | listener { 27 | instance_port = 80 28 | instance_protocol = "http" 29 | lb_port = 80 30 | lb_protocol = "http" 31 | } 32 | health_check { 33 | healthy_threshold = 2 34 | unhealthy_threshold = 2 35 | timeout = 3 36 | target = "HTTP:80/wp-admin/install.php" 37 | interval = 30 38 | } 39 | } 40 | 41 | data "template_file" "ec2_userdata" { 42 | template = "${file("userdata.sh")}" 43 | vars { 44 | ecs_cluster = "${aws_ecs_cluster.ecs.name}" 45 | nfs_fqdn = "${var.nfs_fqdn}" 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /ecr.tf: -------------------------------------------------------------------------------- 1 | resource "aws_ecr_repository" "ecr" { 2 | name = "wordpress_ael" 3 | } 4 | -------------------------------------------------------------------------------- /ecs-role.json: -------------------------------------------------------------------------------- 1 | { 2 | "Version": "2008-10-17", 3 | "Statement": [ 4 | { 5 | "Action": "sts:AssumeRole", 6 | "Principal": { 7 | "Service": "ec2.amazonaws.com" 8 | }, 9 | "Effect": "Allow" 10 | } 11 | ] 12 | } 13 | -------------------------------------------------------------------------------- /ecs.tf: -------------------------------------------------------------------------------- 1 | resource "aws_ecs_cluster" "ecs" { 2 | name = "wordpress-cluster" 3 | } 4 | 5 | resource "aws_ecs_service" "ecs" { 6 | name = "wordpress-service" 7 | cluster = "${aws_ecs_cluster.ecs.id}" 8 | desired_count = 1 9 | task_definition = "${aws_ecs_task_definition.ecs.family}" 10 | } 11 | 12 | resource "aws_ecs_task_definition" "ecs" { 13 | family = "wordpress" 14 | container_definitions = "${data.template_file.wordpress_task.rendered}" 15 | volume { 16 | name = "nfs-storage" 17 | host_path = "/mnt/wordpress" 18 | } 19 | } 20 | 21 | data "template_file" "wordpress_task" { 22 | template = "${file("wordpress_task.json")}" 23 | vars { 24 | repository_url = "${aws_ecr_repository.ecr.repository_url}" 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /efs.tf: -------------------------------------------------------------------------------- 1 | resource "aws_efs_file_system" "efs" { 2 | creation_token = "wordpress-assets" 3 | } 4 | 5 | resource "aws_efs_mount_target" "efs_private_zoneA" { 6 | file_system_id = "${aws_efs_file_system.efs.id}" 7 | security_groups = ["${aws_security_group.efs.id}"] 8 | subnet_id = "${aws_subnet.private_subnet_zoneA.id}" 9 | } 10 | 11 | resource "aws_efs_mount_target" "efs_private_zoneB" { 12 | file_system_id = "${aws_efs_file_system.efs.id}" 13 | security_groups = ["${aws_security_group.efs.id}"] 14 | subnet_id = "${aws_subnet.private_subnet_zoneB.id}" 15 | } 16 | -------------------------------------------------------------------------------- /iam.tf: -------------------------------------------------------------------------------- 1 | resource "aws_iam_role" "ecs_role" { 2 | name = "ecs_role" 3 | assume_role_policy = "${file("ecs-role.json")}" 4 | } 5 | 6 | resource "aws_iam_role_policy_attachment" "ecs_instance_role_policy" { 7 | policy_arn = "arn:aws:iam::aws:policy/service-role/AmazonEC2ContainerServiceforEC2Role" 8 | role = "${aws_iam_role.ecs_role.id}" 9 | } 10 | 11 | resource "aws_iam_instance_profile" "ecs" { 12 | name = "ecs-instance-profile" 13 | path = "/" 14 | roles = ["${aws_iam_role.ecs_role.name}"] 15 | } 16 | -------------------------------------------------------------------------------- /outputs.tf: -------------------------------------------------------------------------------- 1 | output "ecr_repository" { 2 | value = "${aws_ecr_repository.ecr.repository_url}" 3 | } 4 | 5 | output "elb_dns" { 6 | value = "${aws_elb.ec2.dns_name}" 7 | } 8 | -------------------------------------------------------------------------------- /packer/playbook.yml: -------------------------------------------------------------------------------- 1 | --- 2 | 3 | - name: Customize wordpress container 4 | hosts: all 5 | 6 | tasks: 7 | - shell: "echo we can do what we want here!" 8 | -------------------------------------------------------------------------------- /packer/wordpress-packer.json: -------------------------------------------------------------------------------- 1 | { 2 | "variables": { 3 | "aws_access_key_id": "{{env `AWS_ACCESS_KEY_ID`}}", 4 | "aws_secret_access_key": "{{env `AWS_SECRET_ACCESS_KEY`}}", 5 | "ecr_repository": "{{env `ECR_REPOSITORY`}}" 6 | }, 7 | "builders": [ 8 | { 9 | "type": "docker", 10 | "image": "wordpress:4.7", 11 | "commit": true, 12 | "run_command": [] 13 | } 14 | ], 15 | "provisioners": [ 16 | { 17 | "type": "shell", 18 | "inline": ["apt-get update", "apt-get install -y python2.7", "ln -s /usr/bin/python2.7 /usr/bin/python"] 19 | }, 20 | { 21 | "type": "ansible", 22 | "playbook_file": "playbook.yml" 23 | } 24 | ], 25 | "post-processors": [ 26 | [ 27 | { 28 | "type": "docker-tag", 29 | "repository": "{{user `ecr_repository`}}/wordpress_ael", 30 | "tag": "1.0" 31 | }, 32 | { 33 | "type": "docker-push", 34 | "ecr_login": true, 35 | "aws_access_key": "{{user `aws_access_key_id`}}", 36 | "aws_secret_key": "{{user `aws_secret_access_key`}}", 37 | "login_server": "https://{{user `ecr_repository`}}/" 38 | } 39 | ] 40 | ] 41 | } 42 | -------------------------------------------------------------------------------- /rds.tf: -------------------------------------------------------------------------------- 1 | resource "aws_db_instance" "rds" { 2 | allocated_storage = 5 3 | engine = "mysql" 4 | engine_version = "5.6.27" 5 | instance_class = "db.t2.micro" 6 | name = "wordpress" 7 | username = "wp" 8 | password = "myverystrongpassword" 9 | db_subnet_group_name = "${aws_db_subnet_group.rds.name}" 10 | vpc_security_group_ids = ["${aws_security_group.rds.id}"] 11 | } 12 | 13 | resource "aws_db_subnet_group" "rds" { 14 | name = "subnet_group" 15 | subnet_ids = ["${aws_subnet.private_subnet_zoneA.id}", "${aws_subnet.private_subnet_zoneB.id}"] 16 | } 17 | -------------------------------------------------------------------------------- /route53.tf: -------------------------------------------------------------------------------- 1 | resource "aws_route53_zone" "wordpress_ael" { 2 | name = "wordpress.ael." 3 | vpc_id = "${aws_vpc.vpc_wordpress.id}" 4 | } 5 | 6 | resource "aws_route53_record" "db_wordpress_ael" { 7 | zone_id = "${aws_route53_zone.wordpress_ael.zone_id}" 8 | name = "${var.db_fqdn}" 9 | type = "CNAME" 10 | ttl = "300" 11 | records = [ 12 | "${aws_db_instance.rds.address}" 13 | ] 14 | } 15 | 16 | # zoneA and zoneB should have same dns name (?) 17 | resource "aws_route53_record" "nfs_wordpress_ael" { 18 | zone_id = "${aws_route53_zone.wordpress_ael.zone_id}" 19 | name = "${var.nfs_fqdn}" 20 | type = "CNAME" 21 | ttl = "300" 22 | records = [ 23 | "${aws_efs_mount_target.efs_private_zoneA.dns_name}" 24 | ] 25 | } 26 | 27 | variable "db_fqdn" { 28 | default = "db.wordpress.ael" 29 | } 30 | 31 | variable "nfs_fqdn" { 32 | default = "nfs.wordpress.ael" 33 | } 34 | -------------------------------------------------------------------------------- /securitygroup.tf: -------------------------------------------------------------------------------- 1 | resource "aws_security_group" "ecs" { 2 | name = "http" 3 | vpc_id = "${aws_vpc.vpc_wordpress.id}" 4 | description = "Allow http port for wordpress containers" 5 | ingress { 6 | from_port = 80 7 | to_port = 80 8 | protocol = "tcp" 9 | cidr_blocks = ["0.0.0.0/0"] 10 | } 11 | } 12 | 13 | resource "aws_security_group" "ec2_egress" { 14 | name = "ec2_egress" 15 | vpc_id = "${aws_vpc.vpc_wordpress.id}" 16 | description = "Every needed rules for the ec2 instances (nfs, mysql, http/S for yum install)" 17 | egress { 18 | from_port = 80 19 | to_port = 80 20 | protocol = "tcp" 21 | cidr_blocks = ["0.0.0.0/0"] 22 | } 23 | egress { 24 | from_port = 443 25 | to_port = 443 26 | protocol = "tcp" 27 | cidr_blocks = ["0.0.0.0/0"] 28 | } 29 | egress { 30 | from_port = 2049 31 | to_port = 2049 32 | protocol = "tcp" 33 | cidr_blocks = ["${aws_subnet.private_subnet_zoneA.cidr_block}", "${aws_subnet.private_subnet_zoneB.cidr_block}"] 34 | } 35 | egress { 36 | from_port = 3306 37 | to_port = 3306 38 | protocol = "tcp" 39 | cidr_blocks = ["${aws_subnet.private_subnet_zoneA.cidr_block}", "${aws_subnet.private_subnet_zoneB.cidr_block}"] 40 | } 41 | } 42 | 43 | resource "aws_security_group" "elb" { 44 | name = "http-egress" 45 | vpc_id = "${aws_vpc.vpc_wordpress.id}" 46 | description = "Allow http from elb to ecs instances" 47 | egress { 48 | from_port = 80 49 | to_port = 80 50 | protocol = "tcp" 51 | cidr_blocks = ["${aws_subnet.private_subnet_zoneA.cidr_block}", "${aws_subnet.private_subnet_zoneB.cidr_block}"] 52 | } 53 | } 54 | 55 | resource "aws_security_group" "rds" { 56 | name = "mysql" 57 | vpc_id = "${aws_vpc.vpc_wordpress.id}" 58 | description = "Allow mysql port" 59 | ingress { 60 | from_port = 3306 61 | to_port = 3306 62 | protocol = "tcp" 63 | cidr_blocks = ["${aws_subnet.private_subnet_zoneA.cidr_block}", "${aws_subnet.private_subnet_zoneB.cidr_block}"] 64 | } 65 | } 66 | 67 | resource "aws_security_group" "efs" { 68 | name = "nfs" 69 | vpc_id = "${aws_vpc.vpc_wordpress.id}" 70 | description = "Allow nfs port" 71 | ingress { 72 | from_port = 2049 73 | to_port = 2049 74 | protocol = "tcp" 75 | cidr_blocks = ["${aws_subnet.private_subnet_zoneA.cidr_block}", "${aws_subnet.private_subnet_zoneB.cidr_block}"] 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /subnet.tf: -------------------------------------------------------------------------------- 1 | resource "aws_subnet" "public_subnet_zoneA" { 2 | vpc_id = "${aws_vpc.vpc_wordpress.id}" 3 | availability_zone = "us-west-2a" 4 | cidr_block = "10.0.0.0/24" 5 | } 6 | 7 | resource "aws_subnet" "public_subnet_zoneB" { 8 | vpc_id = "${aws_vpc.vpc_wordpress.id}" 9 | availability_zone = "us-west-2b" 10 | cidr_block = "10.0.1.0/24" 11 | } 12 | 13 | resource "aws_subnet" "private_subnet_zoneA" { 14 | vpc_id = "${aws_vpc.vpc_wordpress.id}" 15 | availability_zone = "us-west-2a" 16 | cidr_block = "10.0.10.0/24" 17 | } 18 | 19 | resource "aws_subnet" "private_subnet_zoneB" { 20 | vpc_id = "${aws_vpc.vpc_wordpress.id}" 21 | availability_zone = "us-west-2b" 22 | cidr_block = "10.0.11.0/24" 23 | } 24 | -------------------------------------------------------------------------------- /userdata.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | echo ECS_CLUSTER=${ecs_cluster} > /etc/ecs/ecs.config 3 | yum install -y nfs-utils 4 | echo "nfs.wordpress.ael:/ /mnt/ nfs4 defaults 0 0" >> /etc/fstab 5 | mount -a 6 | service docker restart 7 | -------------------------------------------------------------------------------- /vpc.tf: -------------------------------------------------------------------------------- 1 | resource "aws_vpc" "vpc_wordpress" { 2 | enable_dns_hostnames = true 3 | cidr_block = "10.0.0.0/16" 4 | } 5 | 6 | resource "aws_internet_gateway" "igw" { 7 | vpc_id = "${aws_vpc.vpc_wordpress.id}" 8 | } 9 | 10 | resource "aws_eip" "natgw_ip_zoneA" { 11 | vpc = true 12 | } 13 | 14 | resource "aws_eip" "natgw_ip_zoneB" { 15 | vpc = true 16 | } 17 | 18 | resource "aws_nat_gateway" "natgw_zoneA" { 19 | allocation_id = "${aws_eip.natgw_ip_zoneA.id}" 20 | subnet_id = "${aws_subnet.public_subnet_zoneA.id}" 21 | } 22 | 23 | resource "aws_nat_gateway" "natgw_zoneB" { 24 | allocation_id = "${aws_eip.natgw_ip_zoneB.id}" 25 | subnet_id = "${aws_subnet.public_subnet_zoneB.id}" 26 | } 27 | 28 | resource "aws_route_table" "public" { 29 | vpc_id = "${aws_vpc.vpc_wordpress.id}" 30 | route { 31 | cidr_block = "0.0.0.0/0" 32 | gateway_id = "${aws_internet_gateway.igw.id}" 33 | } 34 | } 35 | 36 | resource "aws_route_table" "private_zoneA" { 37 | vpc_id = "${aws_vpc.vpc_wordpress.id}" 38 | route { 39 | cidr_block = "0.0.0.0/0" 40 | gateway_id = "${aws_nat_gateway.natgw_zoneA.id}" 41 | } 42 | } 43 | 44 | resource "aws_route_table" "private_zoneB" { 45 | vpc_id = "${aws_vpc.vpc_wordpress.id}" 46 | route { 47 | cidr_block = "0.0.0.0/0" 48 | gateway_id = "${aws_nat_gateway.natgw_zoneB.id}" 49 | } 50 | } 51 | 52 | resource "aws_route_table_association" "public_zoneA" { 53 | subnet_id = "${aws_subnet.public_subnet_zoneA.id}" 54 | route_table_id = "${aws_route_table.public.id}" 55 | } 56 | 57 | resource "aws_route_table_association" "public_zoneB" { 58 | subnet_id = "${aws_subnet.public_subnet_zoneB.id}" 59 | route_table_id = "${aws_route_table.public.id}" 60 | } 61 | 62 | resource "aws_route_table_association" "private_zoneA" { 63 | subnet_id = "${aws_subnet.private_subnet_zoneA.id}" 64 | route_table_id = "${aws_route_table.private_zoneA.id}" 65 | } 66 | 67 | resource "aws_route_table_association" "private_zoneB" { 68 | subnet_id = "${aws_subnet.private_subnet_zoneB.id}" 69 | route_table_id = "${aws_route_table.private_zoneB.id}" 70 | } 71 | -------------------------------------------------------------------------------- /wordpress_task.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "environment": [{ 4 | "name": "WORDPRESS_DB_USER", 5 | "value": "wp" 6 | }, 7 | { 8 | "name": "WORDPRESS_DB_PASSWORD", 9 | "value": "myverystrongpassword" 10 | }, 11 | { 12 | "name": "WORDPRESS_DB_NAME", 13 | "value": "wordpress" 14 | }, 15 | { 16 | "name": "WORDPRESS_DB_HOST", 17 | "value": "db.wordpress.ael" 18 | } 19 | ], 20 | "memory": 800, 21 | "cpu": 1024, 22 | "image": "${repository_url}:1.0", 23 | "name": "wordpress", 24 | "command": ["apache2-foreground"], 25 | "mountPoints": [ 26 | { 27 | "ContainerPath": "/var/www/html/", 28 | "SourceVolume": "nfs-storage" 29 | } 30 | ], 31 | "portMappings": [ 32 | { 33 | "hostPort": 80, 34 | "containerPort": 80, 35 | "protocol": "tcp" 36 | } 37 | ] 38 | } 39 | ] 40 | --------------------------------------------------------------------------------