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.

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',
]);
  1. This creates the two required clients used to talk to the AWS API.
$autoscalingGroups = $asgClient
    ->describeAutoScalingGroups([
        'AutoScalingGroupNames' => ['my-auto-scaling-group-name'],
    ])
    ->toArray()['AutoScalingGroups'];
  1. 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');
  1. 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');
  1. 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();

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. 👋