A guide to networking, security, autoscaling and high-availability
It’s not an easy task to set up durable architecture for your web application. And if you try to build it as you go, you’ll soon get tired of clicking around the AWS console. What if you had one go-to architecture and repeatable process for all your projects, while ensuring maximum security, performance and availability? Here is how you should deploy your Laravel application on AWS.
How we will enforce security: — Create VPC subnets to deploy our application into. A VPC is your own virtual network within AWS and lets you design private subnets where instances can’t be accessed directly from outside your VPC. This is where we will deploy our web and database instances. — Use temporary bastions (also called jump boxes) that we will deploy in our public subnets when we need to connect to web and database instances, reducing the surface of attack — Enforce firewalls rules by whitelisting which servers can talk to each other, using VPC security groups (SGs). SGs are default-deny stateful firewalls applied at the instance level. — Simplify secret management by avoiding passwords where possible and instead specifying IAM roles to control access to our resources. Using IAM roles for EC2 removes the need to store AWS credentials in a configuration file. Roles use temporary security tokens under the hood which AWS takes care of rotating so we don’t have to worry about updating passwords.
How we will enforce high availability: — Span our application instances across Availability Zones (AZs below). An AZ is one or more data centers within a region that are designed to be isolated from failures in other AZs. By placing resources in separate AZs, organisations can protect their application from a service disruption impacting a single location — Serve our application from an Elastic Load Balancer. ELB is a highly available (distributed) service that distributes traffic across a group of EC2 instances in one or more AZs. ELB supports health checks to ensure traffic is not routed to unhealthy or failing instances — Host our application on ECS, describing through ECS services what minimum number of healthy application containers should be running at any given time. ECS services will start new containers if one ever crashes. — Distribute our database as a cluster across multiple AZs. RDS allows you to place a secondary copy of your database in another AZ for disaster recovery purposes. You are assigned a database endpoint in the form of a DNS name that AWS takes responsibility for resolving to a specific IP address. RDS will automatically fail over to the standby instance without user intervention. Preferably we will be using Amazon Aurora, which will maintain a read replica of our database in a separate AZ and that Amazon will promote as the primary instance should our main instance (or its AZ) fail. — Finally, we rely on as many distributed services as possible to delegate failure management to AWS: services like S3, SQS, ELB/ALB, ECR and CloudWatch are designed for maximum resiliency without us having to care for the instances they run on.
Laravel, made highly available with almost a one-click deploy!
How we will build ourselves a repeatable process: We will be deploying an empty Laravel application on a fresh domain name using Docker, CloudFormation and the AWS CLI. CloudFormation defines a templating language that can be used to describe all the AWS resources that are necessary for a workload. Templates are submitted to CloudFormation and the service will provision and configure those resources in appropriate order. Docker container images are stand-alone, executable packages of a piece of software that include everything needed to run it. With the AWS CLI, you can control all services from the command line and automate them through scripts. By combining all three, both our infrastructure and our application configuration can be written as code and as such be versioned, branched, documented.
This is the procedure I use to deploy my clients’ Laravel applications on AWS. I hope this can be helpful to deploy yours. If your use case is more complex, I provide on-going support packages ranging from mentoring your developers up to hands-on building your application on AWS. Ping me at [email protected]
Let’s do it step by step: 1. Set up your AWS credentials Start with authenticating your command line by downloading the API key and secret for a new user in the IAM section of your AWS console. This user will need to have to have permissions to create resources for all the services we will use below. Follow the prompts from:
aws configure
Use the profile option to save different credentials for different projects
2. Order SSL certificates We need two certificates: one for our web application itself and another one for our custom domain on CloudFront. The one for your web application needs to be created in the AWS region you want to deploy your application into whereas CloudFront will only accept certificates generated in region us-east-1. AWS SSL/TLS certificates are free, automatically provisioned and renewed, even if you did not buy your domain in Route53. They seamlessly integrate with AWS load balancers, CloudFront distributions and API Gateway endpoints so you can just set them and forget them.
# a certificate in your default region for your web application - certificates.sh
aws acm request-certificate
--domain-name laravelaws.com
--idempotency-token=random_string_here
--subject-alternative-names *.laravelaws.com
# a certificate from us-east-1 specifically for our CloudFront custom domain
aws --region us-east-1 acm request-certificate
--domain-name laravelaws.com
--idempotency-token=random_string_here
--subject-alternative-names *.laravelaws.com
3. Create a key pair to be used by your EC2 instances It is recommended to create a new SSH key pair for all EC2 instances of this new project, still using the CLI:
# Create the key pair and extract the private key from the JSON response gistfile1.sh
aws ec2 create-key-pair
--key-name=laravelaws
--query 'KeyMaterial'
--output text > laravelaws.pem
# Assign appropriate permissions to the key file for it to be usable
chmod 400 laravelaws.pem
Remember that AWS won’t store SSH keys for you and you are responsible for storing and sharing them securely.
4. Launch our CloudFormation stacks Here comes the infrastructure-as-code! Our whole deployment will be described in one master YAML template, itself referencing nested stacks YAML templates to make it more readable and reusable. This is the directory structure of our templates:
├── master.yaml # the root template
├── infrastructure
├── vpc.yaml # our VPC and security groups
├── storage.yaml # our database cluster and S3 bucket
├── web.yaml # our ECS cluster
└── services.yaml # our ECS Tasks Definitions & Services
And the complete code can be downloaded from GitHub here:
The vpc.yaml template defines our VPC subnets and route tables:
# This template creates a VPC and a pair public and private subnets spanning the first two AZs of your current region. network.yaml
# Each instance in the public subnet can accessed the internet and be accessed from the internet
# thanks to a route table routing traffic through the Internet Gateway.
# Private subnets feature a NAT Gateway located in the public subnet of the same AZ, so they can receive traffic
# from within the VPC.
VPC:
Type: AWS::EC2::VPC
Properties:
CidrBlock: !Ref VpcCIDR
Tags:
- Key: Name
Value: !Ref EnvironmentName
InternetGateway:
Type: AWS::EC2::InternetGateway
Properties:
Tags:
- Key: Name
Value: !Ref EnvironmentName
InternetGatewayAttachment:
Type: AWS::EC2::VPCGatewayAttachment
Properties:
InternetGatewayId: !Ref InternetGateway
VpcId: !Ref VPC
PublicSubnet1:
Type: AWS::EC2::Subnet
Properties:
VpcId: !Ref VPC
AvailabilityZone: !Select [ 0, !GetAZs ]
CidrBlock: !Ref PublicSubnet1CIDR
MapPublicIpOnLaunch: true
Tags:
- Key: Name
Value: !Sub ${EnvironmentName} Public Subnet (AZ1)
PublicSubnet2:
Type: AWS::EC2::Subnet
Properties:
VpcId: !Ref VPC
AvailabilityZone: !Select [ 1, !GetAZs ]
CidrBlock: !Ref PublicSubnet2CIDR
MapPublicIpOnLaunch: true
Tags:
- Key: Name
Value: !Sub ${EnvironmentName} Public Subnet (AZ2)
PrivateSubnet1:
Type: AWS::EC2::Subnet
Properties:
VpcId: !Ref VPC
AvailabilityZone: !Select [ 0, !GetAZs ]
CidrBlock: !Ref PrivateSubnet1CIDR
MapPublicIpOnLaunch: false
Tags:
- Key: Name
Value: !Sub ${EnvironmentName} Private Subnet (AZ1)
PrivateSubnet2:
Type: AWS::EC2::Subnet
Properties:
VpcId: !Ref VPC
AvailabilityZone: !Select [ 1, !GetAZs ]
CidrBlock: !Ref PrivateSubnet2CIDR
MapPublicIpOnLaunch: false
Tags:
- Key: Name
Value: !Sub ${EnvironmentName} Private Subnet (AZ2)
NatGateway1EIP:
Type: AWS::EC2::EIP
DependsOn: InternetGatewayAttachment
Properties:
Domain: vpc
NatGateway2EIP:
Type: AWS::EC2::EIP
DependsOn: InternetGatewayAttachment
Properties:
Domain: vpc
NatGateway1:
Type: AWS::EC2::NatGateway
Properties:
AllocationId: !GetAtt NatGateway1EIP.AllocationId
SubnetId: !Ref PublicSubnet1
NatGateway2:
Type: AWS::EC2::NatGateway
Properties:
AllocationId: !GetAtt NatGateway2EIP.AllocationId
SubnetId: !Ref PublicSubnet2
PublicRouteTable:
Type: AWS::EC2::RouteTable
Properties:
VpcId: !Ref VPC
Tags:
- Key: Name
Value: !Sub ${EnvironmentName} Public Routes
DefaultPublicRoute:
Type: AWS::EC2::Route
DependsOn: InternetGatewayAttachment
Properties:
RouteTableId: !Ref PublicRouteTable
DestinationCidrBlock: 0.0.0.0/0
GatewayId: !Ref InternetGateway
PublicSubnet1RouteTableAssociation:
Type: AWS::EC2::SubnetRouteTableAssociation
Properties:
RouteTableId: !Ref PublicRouteTable
SubnetId: !Ref PublicSubnet1
PublicSubnet2RouteTableAssociation:
Type: AWS::EC2::SubnetRouteTableAssociation
Properties:
RouteTableId: !Ref PublicRouteTable
SubnetId: !Ref PublicSubnet2
PrivateRouteTable1:
Type: AWS::EC2::RouteTable
Properties:
VpcId: !Ref VPC
Tags:
- Key: Name
Value: !Sub ${EnvironmentName} Private Routes (AZ1)
DefaultPrivateRoute1:
Type: AWS::EC2::Route
Properties:
RouteTableId: !Ref PrivateRouteTable1
DestinationCidrBlock: 0.0.0.0/0
NatGatewayId: !Ref NatGateway1
PrivateSubnet1RouteTableAssociation:
Type: AWS::EC2::SubnetRouteTableAssociation
Properties:
RouteTableId: !Ref PrivateRouteTable1
SubnetId: !Ref PrivateSubnet1
PrivateRouteTable2:
Type: AWS::EC2::RouteTable
Properties:
VpcId: !Ref VPC
Tags:
- Key: Name
Value: !Sub ${EnvironmentName} Private Routes (AZ2)
DefaultPrivateRoute2:
Type: AWS::EC2::Route
Properties:
RouteTableId: !Ref PrivateRouteTable2
DestinationCidrBlock: 0.0.0.0/0
NatGatewayId: !Ref NatGateway2
PrivateSubnet2RouteTableAssociation:
Type: AWS::EC2::SubnetRouteTableAssociation
Properties:
RouteTableId: !Ref PrivateRouteTable2
SubnetId: !Ref PrivateSubnet2
This is quite verbose and is everything it takes to set up public and private subnets spanning two AZs. You can see why you wouldn’t want to implement this in the AWS console!
We also need three SGs. The first one is to secure our EC2 instances and only allow inbound traffic coming from the load-balancer plus any SSH inbound traffic (remember our instances will be in a private subnet and won’t be able to receive traffic from the internet anyway):
# This security group defines who/where is allowed to access the ECS hosts directly. security-group-ecs.yml
# By default we're just allowing access from the load balancer. If you want to SSH
# into the hosts, or expose non-load balanced services you can open their ports here.
ECSSecurityGroup:
Type: AWS::EC2::SecurityGroup
Properties:
VpcId: !Ref VPC
GroupDescription: Access to the ECS hosts and the tasks/containers that run on them
SecurityGroupIngress:
# Only allow inbound access to ECS from the ELB
- SourceSecurityGroupId: !Ref LoadBalancerSecurityGroup
IpProtocol: -1
- IpProtocol: tcp
CidrIp: 0.0.0.0/0
FromPort: '22'
ToPort: '22'
Tags:
- Key: Name
Value: !Sub ${EnvironmentName}-ECS-Hosts
The load balancer’s SG will allow any traffic from the internet (while only responding to HTTP and HTTPS):
# This security group defines who/where is allowed to access the Application Load Balancer. load-balancer-security-group.yaml
# By default, we've opened this up to the public internet (0.0.0.0/0) but can you restrict
# it further if you want.
LoadBalancerSecurityGroup:
Type: AWS::EC2::SecurityGroup
Properties:
VpcId: !Ref VPC
GroupDescription: Access to the load balancer that sits in front of ECS
SecurityGroupIngress:
# Allow access from anywhere to our ECS services
- CidrIp: 0.0.0.0/0
IpProtocol: -1
Tags:
- Key: Name
Value: !Sub ${EnvironmentName}-LoadBalancers
Finally, the database SG only allows ingress traffic on MySQL port and coming from our EC2 instances, and nothing from the internet. Our database will also be hosted inside our private subnets so it can’t receive any traffic from outside the VPC.
# This security group defines who/where is allowed to access the RDS instance. database-security-group.yaml
# Only instances associated with our ECS security group can reach to the database endpoint.
DBSecurityGroup:
Type: AWS::EC2::SecurityGroup
Properties:
GroupDescription: Open database for access
VpcId: !Ref VPC
SecurityGroupIngress:
- IpProtocol: tcp
FromPort: '3306'
ToPort: '3306'
SourceSecurityGroupId: !Ref ECSSecurityGroup
Tags:
- Key: Name
Value: !Sub ${EnvironmentName}-DB-Host
Let’s now launch our storage.yaml stack:
# I recommend to encrypt your database to make sure your snapshots and logs are encrypted too. storage.yaml
# Automatic snapshots are stored by AWS itself, however manual snapshots will be stored in your S3 account.
# You don't want to accidentally open access to an unencrypted version of your data!
# It is also preferable not to use your default AWS master key if you ever need to transfer a snapshot to another
# AWS account later as you can't give cross-account access to your master key.
#
# Not that we only create one primary DB instance for now, no read replica.
KmsKey:
Type: AWS::KMS::Key
Properties:
Description: !Sub KMS Key for our ${AWS::StackName} DB
KeyPolicy:
Id: !Ref AWS::StackName
Version: "2012-10-17"
Statement:
-
Sid: "Allow administration of the key"
Effect: "Allow"
Action:
- kms:Create*
- kms:Describe*
- kms:Enable*
- kms:List*
- kms:Put*
- kms:Update*
- kms:Revoke*
- kms:Disable*
- kms:Get*
- kms:Delete*
- kms:ScheduleKeyDeletion
- kms:CancelKeyDeletion
Principal:
AWS: !Ref AWS::AccountId
Resource: '*'
-
Sid: "Allow use of the key"
Effect: "Allow"
Principal:
AWS: !Ref AWS::AccountId
Action:
- "kms:Encrypt"
- "kms:Decrypt"
- "kms:ReEncrypt*"
- "kms:GenerateDataKey*"
- "kms:DescribeKey"
Resource: "*"
DatabaseSubnetGroup:
Type: AWS::RDS::DBSubnetGroup
Properties:
DBSubnetGroupDescription: CloudFormation managed DB subnet group.
SubnetIds: !Ref DatabaseSubnets
DatabaseCluster:
Type: AWS::RDS::DBCluster
Properties:
Engine: aurora
DatabaseName: !Ref DatabaseName
MasterUsername: !Ref DatabaseUsername
MasterUserPassword: !Ref DatabasePassword
BackupRetentionPeriod: 7
PreferredBackupWindow: 01:00-02:30
PreferredMaintenanceWindow: mon:03:00-mon:04:00
DBSubnetGroupName: !Ref DatabaseSubnetGroup
KmsKeyId: !GetAtt KmsKey.Arn
StorageEncrypted: true
VpcSecurityGroupIds:
- !Ref DatabaseSecurityGroup
DatabasePrimaryInstance:
Type: AWS::RDS::DBInstance
Properties:
Engine: aurora
DBClusterIdentifier: !Ref DatabaseCluster
DBInstanceClass: !Ref DatabaseInstanceType
DBSubnetGroupName: !Ref DatabaseSubnetGroup
Plus one public-read S3 bucket:
# CloudFormation will generate one unique bucket name for us - s3.yaml
# Nothing else to do!
Bucket:
Type: AWS::S3::Bucket
Properties:
AccessControl: PublicRead
The web.yaml stack is composed of one ECS cluster and a Launch Configuration for our instances. The LC defines the bootstrap code to execute on each new instance at launch, this is called the User Data. We use here a third-party Docker credential helper that authenticates the Docker client to our ECR registry by turning the instance’s IAM role into security tokens.
# This template defines our ECS cluster and its desired size. ecs.yaml
# The Launch Configuration defines how each new instance in our cluster should be bootstrapped
# through its User Data
# The Metadata object gets EC2 instances to register in the ECS cluster
ECSCluster:
Type: AWS::ECS::Cluster
Properties:
ClusterName: !Ref EnvironmentName
ECSAutoScalingGroup:
Type: AWS::AutoScaling::AutoScalingGroup
Properties:
VPCZoneIdentifier: !Ref PrivateSubnets
LaunchConfigurationName: !Ref ECSLaunchConfiguration
MinSize: !Ref ClusterSize
MaxSize: !Ref ClusterSize
DesiredCapacity: !Ref ClusterSize
Tags:
- Key: Name
Value: !Sub ${EnvironmentName} ECS host
PropagateAtLaunch: true
CreationPolicy:
ResourceSignal:
Timeout: PT15M
UpdatePolicy:
AutoScalingReplacingUpdate:
WillReplace: true
AutoScalingRollingUpdate:
MinInstancesInService: 1
MaxBatchSize: 1
PauseTime: PT15M
SuspendProcesses:
- HealthCheck
- ReplaceUnhealthy
- AZRebalance
- AlarmNotification
- ScheduledActions
WaitOnResourceSignals: true
ECSLaunchConfiguration:
Type: AWS::AutoScaling::LaunchConfiguration
Properties:
ImageId: !FindInMap [AWSRegionToAMI, !Ref "AWS::Region", AMI]
InstanceType: !Ref InstanceType
SecurityGroups:
- !Ref ECSSecurityGroup
IamInstanceProfile: !Ref ECSInstanceProfile
KeyName: laravelaws
UserData:
"Fn::Base64": !Sub |
#!/bin/bash
yum update -y
yum install -y aws-cfn-bootstrap aws-cli go
echo '{ "credsStore": "ecr-login" }' > ~/.docker/config.json
go get -u github.com/awslabs/amazon-ecr-credential-helper/ecr-login/cli/docker-credential-ecr-login
cd /home/ec2-user/go/src/github.com/awslabs/amazon-ecr-credential-helper/ecr-login/cli/docker-credential-ecr-login
go build
export PATH=$PATH:/home/ec2-user/go/bin
/opt/aws/bin/cfn-init -v --region ${AWS::Region} --stack ${AWS::StackName} --resource ECSLaunchConfiguration
/opt/aws/bin/cfn-signal -e $? --region ${AWS::Region} --stack ${AWS::StackName} --resource ECSAutoScalingGroup
Metadata:
AWS::CloudFormation::Init:
config:
commands:
01_add_instance_to_cluster:
command: !Sub echo ECS_CLUSTER=${ECSCluster} >> /etc/ecs/ecs.config
files:
"/etc/cfn/cfn-hup.conf":
mode: 000400
owner: root
group: root
content: !Sub |
[main]
stack=${AWS::StackId}
region=${AWS::Region}
"/etc/cfn/hooks.d/cfn-auto-reloader.conf":
content: !Sub |
[cfn-auto-reloader-hook]
triggers=post.update
path=Resources.ECSLaunchConfiguration.Metadata.AWS::CloudFormation::Init
action=/opt/aws/bin/cfn-init -v --region ${AWS::Region} --stack ${AWS::StackName} --resource ECSLaunchConfiguration
services:
sysvinit:
cfn-hup:
enabled: true
ensureRunning: true
files:
- /etc/cfn/cfn-hup.conf
- /etc/cfn/hooks.d/cfn-auto-reloader.conf
# This IAM Role is attached to all of the ECS hosts. It is based on the default role - ecs-role.yaml
# published here:
# http://docs.aws.amazon.com/AmazonECS/latest/developerguide/instance_IAM_role.html
#
# You can add other IAM policy statements here to allow access from your ECS hosts
# to other AWS services. Please note that this role will be used by ALL containers
# running on the ECS host.
ECSRole:
Type: AWS::IAM::Role
Properties:
Path: /
RoleName: !Sub ${EnvironmentName}-ECSRole-${AWS::Region}
AssumeRolePolicyDocument: |
{
"Statement": [{
"Action": "sts:AssumeRole",
"Effect": "Allow",
"Principal": {
"Service": "ec2.amazonaws.com"
}
}]
}
ManagedPolicyArns:
- "arn:aws:iam::aws:policy/service-role/AmazonEC2ContainerServiceforEC2Role"
Policies:
- PolicyName: ecs-service
PolicyDocument: |
{
"Statement": [{
"Effect": "Allow",
"Action": [
"ecs:CreateCluster",
"ecs:DeregisterContainerInstance",
"ecs:DiscoverPollEndpoint",
"ecs:Poll",
"ecs:RegisterContainerInstance",
"ecs:StartTelemetrySession",
"ecs:Submit*",
"logs:CreateLogStream",
"logs:PutLogEvents",
"ecr:BatchCheckLayerAvailability",
"ecr:BatchGetImage",
"ecr:GetDownloadUrlForLayer",
"ecr:GetAuthorizationToken"
],
"Resource": "*"
}]
}
- PolicyName: ec2-s3-write-access
PolicyDocument:
Statement:
- Effect: Allow
Action:
- s3:PutObject
- s3:GetBucketAcl
- s3:PutObjectTagging
- s3:ListBucket
- s3:PutObjectAcl
Resource: !Sub arn:aws:s3:::${S3BucketName}/*
- PolicyName: ec2-cloudwatch-write-access
PolicyDocument:
Statement:
- Effect: Allow
Action:
- logs:CreateLogStream
- logs:PutLogEvents
- logs:CreateLogGroup
Resource: "*"
ECSInstanceProfile:
Type: AWS::IAM::InstanceProfile
Properties:
Path: /
Roles:
- !Ref ECSRole
# One Docker registry that we will use both for the Laravel application - ecr.yaml
# image and our Nginx image.
# Note that if you give a name to the repository, CloudFormation can't
# update it without a full replacement.
ECR:
Type: AWS::ECR::Repository
Properties:
# RepositoryName: !Sub ${AWS::StackName}-nginx
RepositoryPolicyText:
Version: "2012-10-17"
Statement:
-
Sid: AllowPushPull
Effect: Allow
Principal:
AWS:
- !Sub arn:aws:iam::${AWS::AccountId}:role/${ECSRole}
Action:
- "ecr:GetDownloadUrlForLayer"
- "ecr:BatchGetImage"
- "ecr:BatchCheckLayerAvailability"
- "ecr:PutImage"
- "ecr:InitiateLayerUpload"
- "ecr:UploadLayerPart"
- "ecr:CompleteLayerUpload"
# One ALB with two listeners for HTTP and HTTPS - alb.yaml
# The HTTP listener will pointed to a specific Nginx container redirecting traffic to HTTPS
# because neither ALB or ELB allow you to handle this through their configuration
LoadBalancer:
Type: AWS::ElasticLoadBalancingV2::LoadBalancer
Properties:
Name: !Ref EnvironmentName
Subnets: !Ref PublicSubnets
SecurityGroups:
- !Ref LBSecurityGroup
Tags:
- Key: Name
Value: !Ref EnvironmentName
LoadBalancerListenerHTTP:
Type: AWS::ElasticLoadBalancingV2::Listener
Properties:
LoadBalancerArn: !Ref LoadBalancer
Port: 80
Protocol: HTTP
DefaultActions:
- Type: forward
TargetGroupArn: !Ref DefaultTargetGroup
LoadBalancerListenerHTTPS:
Type: AWS::ElasticLoadBalancingV2::Listener
Properties:
LoadBalancerArn: !Ref LoadBalancer
Port: 443
Protocol: HTTPS
Certificates:
- CertificateArn: !Ref LBCertificateArn
DefaultActions:
- Type: forward
TargetGroupArn: !Ref DefaultTargetGroup
# We define a default target group here, as this is a mandatory Parameters
# when creating an Application Load Balancer Listener. This is not used, instead
# a target group is created per-service in each service template (../services/*)
DefaultTargetGroup:
Type: AWS::ElasticLoadBalancingV2::TargetGroup
Properties:
Name: !Sub ${EnvironmentName}-default
VpcId: !Ref VPC
Port: 80
Protocol: HTTP
In more complex setups, we can have our freshly created load balancer registering itself to Route53 so that your service is always available at the same DNS address. This design pattern is called service discovery and is not possible out of the box in CloudFormation. Instead, we will manually point our domain name to our load-balancer on Route53 in step 7 below.
Our load balancer responding but with no healthy container instances behind it
5. Build and push your Laravel Docker image
In the previous step, we created one ECR registry to store both the Docker image of our Laravel application and the one of our Nginx server. ECRs are standard Docker registries which you authenticate to using tokens, that the AWS CLI can generate for us:
# The get-login command outputs the "docker login" command you need to execute with a temporary token - script.sh
# You can run both directly using eval
# The --no-include-email tells get-login not to return the -e option that does not work for all of Docker versions
eval $(aws ecr get-login --no-include-email)
Below are the two Dockerfiles we use to build our Docker images:
FROM php:7.1-fpm
# Update packages and install composer and PHP dependencies. Dockerfile
RUN apt-get update && \
DEBIAN_FRONTEND=noninteractive apt-get install -y \
postgresql-client \
libpq-dev \
libfreetype6-dev \
libjpeg62-turbo-dev \
libmcrypt-dev \
libpng12-dev \
libbz2-dev \
php-pear \
cron \
&& pecl channel-update pecl.php.net \
&& pecl install apcu
# PHP Extensions
RUN docker-php-ext-install mcrypt zip bz2 mbstring pdo pdo_pgsql pdo_mysql pcntl \
&& docker-php-ext-configure gd --with-freetype-dir=/usr/include/ --with-jpeg-dir=/usr/include/ \
&& docker-php-ext-install gd
# Memory Limit
RUN echo "memory_limit=2048M" > $PHP_INI_DIR/conf.d/memory-limit.ini
RUN echo "max_execution_time=900" >> $PHP_INI_DIR/conf.d/memory-limit.ini
RUN echo "extension=apcu.so" > $PHP_INI_DIR/conf.d/apcu.ini
RUN echo "post_max_size=20M" >> $PHP_INI_DIR/conf.d/memory-limit.ini
RUN echo "upload_max_filesize=20M" >> $PHP_INI_DIR/conf.d/memory-limit.ini
# Time Zone
RUN echo "date.timezone=${PHP_TIMEZONE:-UTC}" > $PHP_INI_DIR/conf.d/date_timezone.ini
# Display errors in stderr
RUN echo "display_errors=stderr" > $PHP_INI_DIR/conf.d/display-errors.ini
# Disable PathInfo
RUN echo "cgi.fix_pathinfo=0" > $PHP_INI_DIR/conf.d/path-info.ini
# Disable expose PHP
RUN echo "expose_php=0" > $PHP_INI_DIR/conf.d/path-info.ini
# Install Composer
RUN curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer
ADD . /var/www/html
WORKDIR /var/www/html
RUN mkdir storage/logs
RUN touch storage/logs/laravel.log
RUN chmod 777 storage/logs/laravel.log
RUN composer install
RUN php artisan optimize --force
# RUN php artisan route:cache
RUN chmod -R 777 /var/www/html/storage
RUN touch /var/log/cron.log
ADD deploy/cron/artisan-schedule-run /etc/cron.d/artisan-schedule-run
RUN chmod 0644 /etc/cron.d/artisan-schedule-run
RUN chmod +x /etc/cron.d/artisan-schedule-run
# CMD ["php-fpm"]
CMD ["/bin/sh", "-c", "php-fpm -D | tail -f storage/logs/laravel.log"],
We install cron here so we can reuse the same image for our Laravel scheduled tasks and our Laravel workers
## Dockerfile-nginx
FROM nginx
ADD deploy/nginx/nginx.conf /etc/nginx/
ADD deploy/nginx/default.conf /etc/nginx/conf.d/
ADD public /usr/share/nginx/html
WORKDIR /usr/share/nginx/html
Here we simply add our custom Nginx config and the public assets from the Laravel public directory into the Docker image. Each time you rebuild your front-end assets, you will need to re-build both the Laravel and Nginx images
And the command to build them:
# Building our Nginx Docker image and tagging it with the ECR URL - dockerbuild.sh
docker build -f Dockerfile-nginx -t YOUR_ECR_REGISTRY_URL_HERE:nginx .
docker push YOUR_ECR_REGISTRY_URL_HERE:nginx
# Building our Laravel Docker image and tagging it with the ECR URL
docker build -t YOUR_ECR_REGISTRY_URL_HERE:laravel .
docker push YOUR_ECR_REGISTRY_URL_HERE:laravel
Finally, we launch our web service with ECS. At the core level, task definitions describe which Docker images should be used to create containers, how containers should be linked together and which environment variables to run them with. At an higher level, an ECS service maintains a specified number of instances of a task definition simultaneously in an ECS cluster. The cluster is the pool of EC2 instances ie the infrastructure on which the tasks are hosted.
It will take a few seconds for our instances to be considered healthy by ELB so it starts directing traffic to them, and that what we see then is:
At least this is a Laravel page, though displaying the default HTTP 500 error message. By checking Laravel logs which are streamed to CloudWatch, we see that we’re missing the session table in the DB. So how can we now connect to one of our instances in the private subnets, across the internet, to run our database migrations?
6. Launch a bastion & run database migrations
A bastion (also called jump box) is a temporary EC2 instance that we will place in a public subnet of our VPC. It will enable us to SSH into it from outside the VPC and from there still being able to access our instances (including database instances) in private subnets. When creating the bastion, make sure to associate to it the SG allowing access to the database.
## bastion.sh
aws ec2 run-instances
--image-id ami-c1a6bda2
--key-name laravelaws # the SSH key pair we created earlier
--security-group-ids sg-xxxxxxxx # our previous SG allowing access to the DB
--subnet-id subnet-xxxxxxxx # one of our public subnets
--count 1
--instance-type t2.micro # the smallest instance type allowed
--tag-specifications 'ResourceType=instance,Tags=[{Key=Name,Value=bastion}]'
Launch one bastion, to be deleted once we’re done.
# Add your key to your SSH agent - bastion.sh
ssh-add -K laravelaws.pem
# Verify that your private key is successfully loaded in your local SSH agent
ssh-add –L
# Use the -A option to enable forwarding of the authentication agent connection
ssh –A ec2-user@
# Once you are connected to the bastion, you can SSH into a private subnet instance
# without copying any SSH key on the bastion
ssh ec2-user@
You’re now connected to an instance inside your VPC private subnets without copying keys around
# Use the Docker exec command to execute the Artisan commands inside the application container-migrations.sh
docker exec -it CONTAINER_ID php artisan session:table
docker exec -it CONTAINER_ID php artisan migrate --force
The bastion can also be a host for a SSH tunnel between our machine and our public subnet so we can connect a local mysql/pgsql client to our remote database. Below is an example for PostgreSQL:
# create a SSH tunnel to RDS through your bastion: ssh-tunnel.sh
ssh -L 54320:your_rds_database_endpoint_here.your_region_here.rds.amazonaws.com:5432
ec2-user@
-i ./laravelaws.pem
# Your remote database is now accessible from port 54320 on your local machine
# I strongly recommend to create first thing a read-only user in your database
psql -h localhost -p 54320 -U postgres -W db_name_here
> CREATE ROLE lionel LOGIN PASSWORD 'a_unique_password_here';
> GRANT CONNECT ON DATABASE crvs TO lionel;
> GRANT USAGE ON SCHEMA public TO lionel;
> GRANT SELECT ON ALL TABLES IN SCHEMA public TO lionel;
> GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO lionel
# You can then use pg_dump, pg_restore, or pgsql command line tools to create/restore a DB dump
pg_dump -h localhost -U lionel -W -p 54320 db_name_here > dump_db_name_here_$(date +"%m_%d_%Y").sql
# Import it into a local database using:
psql -U lionel -w db_name_here -f dump_db_name_here_11_23_2017.sql
Back to our database migrations that we just ran. Here’s how it looks now when connecting to the load balancer:
Laravel served through our load balancer URL
Yay! Our application is now served through our load balancer and our EC2 and database instances are running from the safety of a private subnet. The next step is to point our domain name to our load balancer.
7. Migrate DNS service to AWS Route53 If you have bought your domain name outside of AWS, you usually don’t need to migrate either the registration or the DNS service to your AWS account. There is an edge case though if you want your root domain (also known as APEX) to point to your load balancer. This needs a CNAME record which is not allowed for APEXs but AWS Route53 offers a special type of ALIAS records that lets you do just that.
First we will migrate your DNS service to AWS:
# create a hosted zone for AWS to select NS servers for your domain - create-hosted-zone.sh
aws route53 create-hosted-zone
--name laravelaws.com
--caller-reference random_string_here
# wait for the hosted zone to be created
# retrieve NS records
aws route53 get-hosted-zone
--id /hostedzone/YOUR_HOSTED_ZONE_ID
# the NS addresses in the response are the one to upload to your current domain name registrar
{
"HostedZone": {
"Id": "/hostedzone/YOUR_HOSTED_ZONE_ID",
"Name": "laravelaws.com.",
"CallerReference": "RISWorkflow-RD:824653d6-3f9d-415a-a2e8-8d6fa63fb4c8",
"Config": {
"Comment": "HostedZone created by Route53 Registrar",
"PrivateZone": false
},
"ResourceRecordSetCount": 6
},
"DelegationSet": {
"NameServers": [
"ns-1308.awsdns-03.org",
"ns-265.awsdns-32.com",
"ns-583.awsdns-08.net",
"ns-1562.awsdns-03.co.uk"
]
}
}
# retrieve the TTL for your NS records.
# This is the maximum time it will take for all clients to point to Route53
# after you uploaded them to your current domain name registrar
dig laravelaws.com
Once the DNS service is assumed by Route53, we can create an ALIAS record to our ELB URL.
# Add an ALIAS record to ELB URL - change-resource-record-sets.sh
aws route53 change-resource-record-sets
--hosted-zone-id /hostedzone/YOUR_HOSTED_ZONE_ID
--change-batch '{
"Changes":[
{
"Action":"CREATE",
"ResourceRecordSet":{
"Name":"laravelaws.com.",
"Type":"A",
"AliasTarget":{
"DNSName":"laravelaws2-1297867430.ap-southeast-2.elb.amazonaws.com",
"EvaluateTargetHealth":true,
"HostedZoneId":"YOUR_HOSTED_ZONE_ID"
}
}
}
]
}'
# Track the propagation of the record
aws route53 get-change --id /change/YOUR_CHANGE_ID
# Test your record even before it is propagated
aws route53 test-dns-answer
--hosted-zone-id /hostedzone/YOUR_HOSTED_ZONE_ID
--record-name laravelaws.com
--record-type A
All done!
Domain name pointing to the load balancer, SSL certificate working
You are potentially done at this point. You can also improve your stack and deployment systems by following the steps below.
8. Speed up your application by using CloudFront
Add a CloudFront distribution in your CloudFormation template and update your stack:
You will need to create beforehand a CloudFront Origin Access Identity, which is a special CloudFront user who will be able query objects in your S3 bucket:
9. (Optional) Publish your Laravel workers and crons Well done! Our Laravel application is now highly available in the cloud. This step will show how we can reuse our exact same Laravel Docker image to deploy our scheduled tasks and workers. They will run in their own containers and be managed by another ECS service so we can scale them independently to the php-fpm containers. We also make sure we have only a single instance of cron running, even if we have multiple front-end containers.
For the worker jobs, we create an SQS queue using CloudFormation, for the front-end to dispatch jobs to our workers in the background:
# That's all it takes to create a queue in CloudFormation - sqs.yaml
# CloudFormation will assign a unique name to it, that we
# will pass to our Laravel containers
Queue:
Type: AWS::SQS::Queue
# Then in the web.yaml stack, we update our ECSRole to grant
# our ECS instances access to this one queue we just created
- PolicyName: sqs-read-write-access
PolicyDocument:
Statement:
- Effect: Allow
Action:
- sqs:*
Resource: !GetAtt Queue.Arn
Finally we create two more tasks definitions in CloudFormation by starting from the same Laravel Docker image, same environment variables, but just overriding the Docker CMD (i.e. the command executed by Docker when the container starts):
# The worker containers simply execute the Laravel artisan queue:work - task-definitions.yaml
# command instead of php-fpm
TaskDefinitionWorker:
Type: AWS::ECS::TaskDefinition
Properties:
Family: laravel-workers
ContainerDefinitions:
- Name: app
Essential: true
Image: !Join [ ".", [ !Ref "AWS::AccountId", "dkr.ecr", !Ref "AWS::Region", !Join [ ":", [ !Join [ "/", [ "amazonaws.com", !Ref ECR ] ], "laravel" ] ] ] ]
Command:
- "/bin/sh"
- "-c"
- "php artisan queue:work"
Memory: 128
LogConfiguration:
LogDriver: awslogs
Options:
awslogs-group: !Ref AWS::StackName
awslogs-region: !Ref AWS::Region
Environment:
- Name: APP_NAME
Value: Laravel
......
# The cron container command is a bit more intricate
# since we need to load the container's environment
# variables in the same console session context than cron
# for Laravel to use them
TaskDefinitionCron:
Type: AWS::ECS::TaskDefinition
Properties:
Family: laravel-cron
ContainerDefinitions:
- Name: app
Essential: true
Image: !Join [ ".", [ !Ref "AWS::AccountId", "dkr.ecr", !Ref "AWS::Region", !Join [ ":", [ !Join [ "/", [ "amazonaws.com", !Ref ECR ] ], "laravel" ] ] ] ]
EntryPoint:
- /bin/bash
- -c
Command:
- env /bin/bash -o posix -c 'export -p' > /etc/cron.d/project_env.sh && chmod +x /etc/cron.d/project_env.sh && crontab /etc/cron.d/artisan-schedule-run && cron && tail -f /var/log/cron.log
Memory: 128
LogConfiguration:
LogDriver: awslogs
Options:
awslogs-group: !Ref AWS::StackName
awslogs-region: !Ref AWS::Region
Environment:
- Name: APP_NAME
Value: Laravel
The crontab file we use to call the artisan scheduler loads the container’s environment variables in the cron console session. If you don’t, Laravel won’t see your container’s env vars when called from the cron.
# artisan-schedule-run
* * * * * root . /etc/cron.d/project_env.sh ; /usr/local/bin/php /var/www/html/artisan schedule:run &> /var/log/cron.log
# An empty line is required at the end of this file for a valid cron file.
That’s it! We now have in our cluster a mix of Laravel front-end containers (php-fpm with Nginx as a reverse proxy), Laravel workers and one cron.
10. (Optional) Add an ElasticSearch domain Most web applications would need a search engine like ElasticSearch. This is how you can create a managed ES cluster with CloudFormation.
Note that we only allow ingress traffic from both our NAT gateway IPs, ie only instances from our private subnets
11. (Optional) High availability for the storage tier As we discussed previously, we only have one database instance and no read replica in a separate AZ. You can add a replica in CloudFormation with the below template:
## alarms.yaml
AlarmTopic:
Type: AWS::SNS::Topic
Properties:
Subscription:
- Endpoint: [email protected]
Protocol: email
CPUAlarmHigh:
Type: AWS::CloudWatch::Alarm
Properties:
EvaluationPeriods: '1'
Statistic: Average
Threshold: '50'
AlarmDescription: Alarm if CPU too high or metric disappears indicating instance is down
Period: '60'
# AlarmActions:
# - Ref: ScaleUpPolicy
AlarmActions:
- Ref: AlarmTopic
Namespace: AWS/EC2
Dimensions:
- Name: AutoScalingGroupName
Value: !Ref ECSAutoScalingGroup
ComparisonOperator: GreaterThanThreshold
MetricName: CPUUtilization
13. (Optional) Updating your stack manually — vertical scaling or manual horizontal scaling
To create your CloudFormation stack the first time, use the below command:
# Create your CloudFormation stack from scratch using the create-stack command - cloudformation.sh
aws cloudformation create-stack
--stack-name=laravel
--template-body=file://master.yaml
--capabilities CAPABILITY_NAMED_IAM
--parameters
ParameterKey=CloudFrontOAI,ParameterValue=origin-access-identity/cloudfront/YOUR_CF_OAI_HERE
ParameterKey=CertificateArnCF,ParameterValue=arn:aws:acm:us-east-1:your_cloudfront_certificate_arn_here
ParameterKey=CertificateArn,ParameterValue=arn:aws:acm:us-east-1:your_certificate_arn_here
ParameterKey=BaseUrl,ParameterValue=laravelaws.com
ParameterKey=DBMasterPwd,ParameterValue=your_master_db_pwd_here
ParameterKey=ECSInstanceType,ParameterValue=t2.micro
ParameterKey=ECSDesiredCount,ParameterValue=1
If you later want to modify the number or size of the instances in your cluster, update the parameters ECSInstanceType and ECSDesiredCount in your command line and call the update-stack command instead. CloudFormation will un-provision your previous instances and launch the new instances without further intervention needed from you.
14. (Optional) Auto scaling
Here we will use a combination of CloudWatch alarms, ScalableTargets and ScalingPolicies to trigger scaling of both our ECS cluster size and the desired number of container instances in our ECS. Scaling will happen both ways, so our infrastructure will typically be as light as possible at night and then scale up for peak times!
Coming soon
15. (Optional) Set up Continuous Deployment with CodePipeline
This is where we’ll automate the building of our images from our GitHub repository. Once images are built and tested (using built-in Laravel unit and integration tests), they will be deployed in production without further clicking. Containers will be replaced in sequence using a deployment pattern called Blue-Green deployment, so we get absolutely no downtime.
I’ve written about how to setup CodePipeline for Laravel here!
16. (Optional) Setup SES and a mail server
If you’ve bought your domain name from Route53 instead of another domain name registrar, you don’t have a mail service ie you can’t receive emails on your new domain name. AWS has no other solution for you than letting you host a mail server on an EC2 instance and get your MX records to point at it, or to set up a custom Lambda function to redirect your incoming emails to GMail for example.
Coming soon
17. Cost containment
If you are running this architecture at scale, there are a couple ways to contain your AWS bill. First you could point your application to the Aurora Read Replicas for read-only queries, to offload your primary instance and avoid vertically scaling too much.
Then you could commit to EC2 Reserved instances and pay for some of your instances cost upfront. Doing so can reduce your EC2 bill by as much as 75%. If your traffic fluctuates a lot throughout the day, you could have reserved instances running continuously and scale up with On-Demand instances during peak times.
Finally, a more sophisticated approach would be to scale using EC2 Spot instances but it is only recommended for your background workload as Spot instances can be terminated by AWS at a short notice.
18. (Optional) Deleting your stack and free resources
Once you’re done experimenting, you can wind down all the resources you created through CloudFormation with one single command. Now you can be sure you did not forget an instance or NAT gateway somewhere silently adding to your AWS bill.