Deploying your PHP application to an Amazon Auto Scaling Group with Deployer
Deployer is a great tool for deploying PHP applications. It has a solid PHP-based API, and recipes for tons of frameworks and hosting solutions. In this article I'll walk you through using Deployer to deploy your application to an Amazon Auto Scaling Group.
What’s an AWS Auto Scaling Group?
An Auto Scaling Group acts as a pool of server instances. This allows you to manage the group, without having to manage each server individually. You can configure health checks, scaling policies, et cetera, allowing your servers to respond to traffic automatically and always meet the uptime requirements for your web application.
What challenges do we face when deploying to an Auto Scaling Group?
Firstly, the number of servers in the cluster is not constant. The group might have scaled up or down based on traffic demands.
Secondly, you cannot predict the IP addresses of the server instances and therefore the hosts you normally configure in Deployer have to be dynamically configured.
Reading your hostnames from the AWS API
Fortunately both of these challenges are solved by the same routine. We will use two AWS APIs to read the current hostnames of the servers in the group:
- First, we’ll use
aws autoscaling describe-auto-scaling-groups
to return all scaling groups for your account. - Next, we’ll use
aws ec2 describe-instances
to get all running instances. We’ll match these with the correct group ID we get from the first call.
I’ve created a little PHP class that uses the AWS PHP SDK to wrap these API calls. I’m specifically using the AutoScalingClient
and EC2Client
classes from the SDK. These classes provide an interface to the aforementioned APIs.
Note, this class uses Laravel Collections to help with traversing the data.
Also note, you should setup your AWS credentials so that you’re actually allowed to read the data from the API.
Edit 9 July 2024
👉 Watch out! At the time of writing there’s an incompatibility between the AWS SDK and Deployer. If you get this fatal error:
Declaration of RingCentral\Psr7\Request::getRequestTarget()
must be compatible with Psr\Http\Message\RequestInterface::getRequestTarget()
Make sure to require a previous version of psr/http-message
:
composer require "psr/http-message" "^1.1"
This will ensure the AWS SDK and Deployer can work together. More on this in this GitHub issue.
On with the show:
namespace App\Actions;
use Aws\AutoScaling\AutoScalingClient;
use Aws\Ec2\Ec2Client;
use Illuminate\Support\Collection;
final class ListAsgIpAddresses
{
/**
* @return Collection<int,string>
*/
public function handle(): Collection
{
$asgClient = new AutoScalingClient([
'region' => 'eu-west-1',
'version' => 'latest',
]);
$ec2Client = new Ec2Client([
'region' => 'eu-west-1',
'version' => 'latest',
]);
/**
* @var Collection<string,mixed> $autoscalingGroups
*/
$autoscalingGroups = $asgClient
->describeAutoScalingGroups([
'AutoScalingGroupNames' => ['my-auto-scaling-group-name'],
])
->toArray()['AutoScalingGroups'];
$instanceIds = collect($autoscalingGroups)
->pluck('Instances')
->flatten(1)
->filter(
fn($instance) => $instance['LifecycleState'] === 'InService' &&
$instance['HealthStatus'] === 'Healthy'
)
->pluck('InstanceId');
/**
* @var array<string,mixed> $instances
*/
$instances = $ec2Client
->describeInstances([
'InstanceIds' => $instanceIds->toArray(),
])
->toArray()['Reservations'];
$ipAddresses = collect($instances)
->pluck('Instances')
->flatten(1)
->pluck('PrivateIpAddress');
return $ipAddresses;
}
}
Let’s break it down into chunks:
$asgClient = new AutoScalingClient([
'region' => 'eu-west-1',
'version' => 'latest',
]);
$ec2Client = new Ec2Client([
'region' => 'eu-west-1',
'version' => 'latest',
]);
- This creates the two required clients used to talk to the AWS API.
$autoscalingGroups = $asgClient
->describeAutoScalingGroups([
'AutoScalingGroupNames' => ['my-auto-scaling-group-name'],
])
->toArray()['AutoScalingGroups'];
- We then pull in all ASG groups, filtering them by name:
my-auto-scaling-group-name
.
$instanceIds = collect($autoscalingGroups)
->pluck('Instances')
->flatten(1)
->filter(
fn($instance) => $instance['LifecycleState'] === 'InService' &&
$instance['HealthStatus'] === 'Healthy'
)
->pluck('InstanceId');
- From these groups, we pull all instance IDs that are marked “in service” and “healthy”, meaning they are currently up and running.
$instances = $ec2Client
->describeInstances([
'InstanceIds' => $instanceIds->toArray(),
])
->toArray()['Reservations'];
$ipAddresses = collect($instances)
->pluck('Instances')
->flatten(1)
->pluck('PublicIpAddress');
- Lastly, we pull in all EC2 instances matching the instance IDs that we just retrieved.
From these instances we pull the public IP address.
💡 Note: it might be that you need PrivateIpAddress
, for instance when your servers are hidden behind a VPN.
How to configure Deployer for dynamic hosts
Alright, so now we can retrieve an array of IP addresses from the AWS API. Let’s configure Deployer to actually use them.
In your deploy.php
file, ensure you can autoload classes in your application by including autoload.php
:
require __DIR__ . '/vendor/autoload.php';
You can then use our class to retrieve the hosts:
$listAsgIpAddresses = new \App\Actions\ListAsgIpAddresses();
$ipAddresses = $listAsgIpAddresses->handle();
// Define this host with multiple IP addresses.
host(
...$ipAddresses
)
->set('labels', ['stage' => 'production'])
->setRemoteUser('my-user')
->set('deploy_path', '~/html/my-app');
You will recognize the host()
call, but in this case we’re not defining a single host, as usual, but pass multiple arguments, one for every IP address.
This groups the IP addresses together for this host. The “stage” label is very important as it allows us to deploy to all of these hosts simultaneously as we’ll see in the next chapter.
How to handle multiple environments on the same instance?
We sometimes work with clients where the staging and production environments are on the same instance. Defining the host
as above will not work in that case, because you will create duplicates for staging and production.
However, in that case you can suffix the hostname with a fixed identifier to make the host definition unique:
// Define staging, using the same instance IP addresses:
host(
...$ipAddresses->map(
fn(
string $ipAddress
) => "$ipAddress/staging"
)
)
->set('labels', ['stage' => 'staging'])
->setRemoteUser('my-user')
->set('deploy_path', '~/html/staging/my-app');
// Define production, using the same instance IP addresses:
host(
...$ipAddresses->map(
fn(
string $ipAddress
) => "$ipAddress/production"
)
)
->set('labels', ['stage' => 'production'])
->setRemoteUser('my-user')
->set('deploy_path', '~/html/production/my-app');
Just to be clear what’s happening here: instead of adding the argument 1.2.3.4
, we add an argument formatted like 1.2.3.4/staging
.
This way we can have two hosts, one for staging and one for production, both referring to the same instances.
Calling Deployer to deploy to all instances simultaneously
Lastly, in order to deploy to all instances simultaneously, we call deployer using the stage label:
./vendor/deployer.phar deploy stage=production
You will see in the output of this command that tasks are being run on multiple servers per environment.
Tasks that should only be called once
Even though you’re working with multiple hosts, some deploy tasks might only have to be called once.
For instance, when all your instances are sharing a single database server, you should only run your database migrations once.
Deployer allows for this by simply tacking ->once()
onto your tasks. This ensures the task is run only on the first instance:
task('migrate-database', function () {
// run the migrations
})->once();
Known hosts
When making an ssh connection to a certain host for the first time, your shell will prompt you to confirm that you trust the host. This message looks something like this:
The authenticity of host '123.456.789' can't be established.
ED25519 key fingerprint is SHA256:....
This key is not known by any other names.
Are you sure you want to continue connecting (yes/no/[fingerprint])?
In a normal situation, you would type yes
and the host would be added to your known hosts file. However, when working with dynamic hosts, this is not possible. You can’t predict the IP addresses of the hosts you’re connecting to, so you can’t add them to your known hosts file. Because the ssh
command will prompt you for confirmation, your deploy script will hang indefinitely.
We can solve this by populating the known_hosts
file programmatically. Add the following to your deploy.php
file:
desc('Add selected hosts to known_hosts before deploying');
before('deploy:prepare', function() {
$host = explode('/', currentHost())[0];
runLocally("ssh-keyscan -H $host >> ~/.ssh/known_hosts");
});
This will add the right information to your known_hosts
file before deploying, avoiding the prompt.
In conclusion
As is often the case, we use very powerful tools, that are great to work with in most cases, but get a little difficult to work with when your situation is a little unusual. In this case Deployer offers you all the handles you need to make this happen, but the documentation can be a little bit sparse and figuring out the correct way to glue everything together becomes a challenge.
Luckily, that’s what tech blogs are for. 👋