Cost-Effective Queue Workers With Fly.io Machines

A balloon is sleeping and dreaming about pdf's. It represents a Fly.io machine because those can go to sleep in between jobs.
Image by Annie Ruygt

We’re going to save you money today by running a queue worker in a Fly.io machine. Read on to see how exactly this saves money and how I set it up. With Fly.io, you can get your Laravel app running globally in minutes!

Fly.io machines have been announced and have been made the default way new apps will be run on Fly.io’s platform. They have many advantages, and one of them is their super-fast boot times.

In this article, I’ll show you how to use machines to run both the web app and a queue worker in the same app. I’ll also save you money in the process, so let’s dive in!

Setting up the web app and worker

First things first: I will be talking about apps and machines here.

By apps I mean applications on Fly.io, they are created by running fly launch and they have a fly.toml file that holds their configuration. An app contains one or more machines.

Machines are VMs that belong to a specific app, and they have some special Fly.io sauce on top: The machines API. This is a REST api that allows you to manipulate these machines in all kinds of ways: update, scale, destroy, restart, clone,… You name it!

The cool thing about machines in an app is that they can run different commands. So I can create one app that contains two machines, one for the web app and one for the queue worker. This is set up in the fly.toml using process groups. The app process group is the default, and will be set up already if you ran fly launch. I added a worker process group by adding it to the processes tag:

[processes]
app = ""
worker = "php artisan queue:work --stop-when-empty"

The app will run whatever is in the Docker entrypoint, and the worker will run the php artisan queue:work --stop-when-empty command. This will keep the worker running as long as it’s fed with new jobs to process. When it’s idle, the machine will stop gracefully and stop slowly draining your wallet. Good stuff!

How do we get the machine running again though? That’s where the machines API comes in: If we know the ID of the worker machine, we can just run a CURL command to start the machine after we added in a job. That’s nice, but it could be even more convenient! I created an artisan command to start a machine, and I’ll run it whenever I dispatch a job.

If you need an example of a job that would be great fit to test our shiny new machines, check out my article on invoice PDF generation with Spatie’s Browsershot package!

Starting a machine with an artisan command

Here’s the machines API endpoint I’ll use to start a machine:

curl -i -X POST \
-H "Authorization: Bearer ${FLY_API_TOKEN}" -H "Content-Type: application/json" \
"http://${FLY_API_HOSTNAME}/v1/apps/${FLY_APP_NAME}/machines/${FLY_MACHINE_ID}/start"

There’s 4 variables that need to be filled in:

  • FLY_API_TOKEN: the api token to authenticate on fly.io. Run fly auth token to get it.
  • FLY_API_HOSTNAME: the hostname of Fly.io’s machines API. _api.internal:4280 when connected to Fly.io’s internal network (in apps running on Fly.io for example) or 127.0.0.1:4280 if you’re using flyctl proxy.
  • FLY_APP_NAME: The name of your app on Fly.io.
  • FLY_MACHINE_ID: The ID of the worker machine.

I’d suggest putting the last three in the [env] section of fly.toml, since they will never change much. The FLY_API_TOKEN won’t change as well, but that’s quite sensitive so I’d use secrets for that.

I wanted my artisan command to be generic, so I set the machine ID as input on the command. It’ll be pulled from the env variables anyway, but this leaves room for multiple workers that handle different queues and/or connections. Here’s how it looks:

    protected $signature = 'machine:start {id : the ID of the machine to be started.}';

    protected $description = "This command starts a Fly.io machine. It needs the machine's ID as input.";

    /**
     * Execute the console command.
     *
     * @return int
     */
    public function handle()
    {
        $flyApiHostname = "http://_api.internal:4280";
        $flyAuthToken = env("FLY_AUTH_TOKEN");
        $flyAppName = env("FLY_APP_NAME");

        $machineId = $this->argument('id');
        $response = \Http::withHeaders([
            'Authorization' => "Bearer $flyAuthToken",
            'Content-Type' => 'application/json'
        ])->post("$flyApiHostname/v1/apps/$flyAppName/machines/$machineId/start");

        if ($response->failed())
        {
            $this->error($response);
            return Command::FAILURE;
        }

        $this->info($response);
        return Command::SUCCESS;
    }

Fly.io ❤️ Laravel

Run your queue workers on Fly.io and use the machines API to start, stop, scale, update and destroy them. Deploy globally on Fly in minutes!

Deploy your Laravel app!

Running the artisan command whenever a job is dispatched

If I can get that machine:start <ID> command to run every time this job is dispatched, then the worker machine will boot up every time a job is added to the queue. Great stuff! I did this by extending the dispatch() method on the Dispatchable trait that my job uses.

  // in the Job class
-  use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
+  use Dispatchable {dispatch as traitDispatch;}
+  use InteractsWithQueue, Queueable, SerializesModels;

  // other stuff here...

  + public static function dispatch(...$arguments)
  +     {
  +         Artisan::call('machine:start', ['id' => env('FLY_WORKER_ID')]);
  +         self::traitDispatch(...$arguments);
  +     }

Notice that I’m not overriding the dispatch() method, I’m extending it: I do what I need to do and then I call self::traitDispatch(...$arguments) which runs the original dispatch() method. This way, the Artisan call: command is executed every time right before the job is actually dispatched. Nice!

That’s a wrap!

By now, I’ve talked about Fly.io’s machines a lot: What they are, what makes them special and how to use the machines API. Since I want to make things as easy as possible, I’ve also laid out how to start a machine whenever needed using an Artisan command.

This all comes together to set up a machine that only runs when it needs to. This saves on computing resources which is good for the environment but more importantly: it saves you money. Now go pitch using Fly.io machines to your boss and get that raise you know you deserve!