On-Demand Compute

Machines directing other machines tasks
Image by Annie Ruygt

Fly takes a Docker image, converts it to a VM, and runs that VM anywhere around the world. Run a Laravel app in minutes!

We're going to learn how to use Fly Machines to run short-lived tasks efficiently.

This boils down to having a process to run (an artisan command, in our case), and a VM ready to run that command. The VM will start, run the command, and stop when the command exits.

What's Up With Machines?

The fly command helps you launch and run apps. Fly assumes your app is always running, 24/7.

However, Fly Machines offer more control. They are a low-level building block that allows you to run "stuff" in interesting ways.

Here are a few fun details about Machines:

  1. They can be managed by API
  2. They turn off automatically when a program exits
  3. Stopped machines can start in milliseconds
  4. Restarted machines are a blank slate - they are ephemeral
  5. Machines can be started manually, but can also wake on network access
  6. You can run multiple machines within an application

If you squint just a little, it looks a bit like serverless. This is useful!

Let's write some code to run on-demand.

The Artisan Command

Here's an artisan command that does some work. In our case, we'll talk to the GitHub API to get the latest release for a given repository.

Here is file app/Console/Commands/GetLatestReleaseCommand.php:

<?php

namespace App\Console\Commands;

use Illuminate\Console\Command;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;

class GetLatestReleaseCommand extends Command
{
    protected $signature = 'get-release {repo}';

    protected $description = 'Get the latest release version';

    public function handle()
    {
        // 1️⃣ Build the URL
        $url = sprintf(
          "https://api.github.com/repos/%s/releases/latest", 
          $this->argument('repo')
        );

        // 2️⃣ Make the API call
        $response = Http::get($url);

        // 3️⃣ Test the results of the API call
        if ($response->successful()) {
            // We'll assume the tag_name is the release version
            $tag = $response->json('tag_name');

            // Do something extremely useful with $tag
            Log::info("The latest tag is $tag");
        } else {
            Log::error("Could not retrieve repository release", [
                'url' => $url,
                'repo' => $this->argument('repo'),
                'status' => $response->status(),
                'body' =>  $response->body(),
            ]);
        }

        // Return success so the Machine VM
        // doesn't attempt to restart the process
        return Command::SUCCESS;
    }
}

The command retrieves the the latest release from a public GitHub repository. We can run it like this: php artisan get-release vessel-app/vessel-cli.

With that out of the way, let's get to the interesting part! We'll next create a Machine and run that command.

Create a Machine App

We're going to setup an app in Fly.

cd path/to/laravel/project
fly launch

# Don't deploy the app!

You can, of course, deploy and run this app if you want! However, for now we just want the fly launch command to generate a Dockerfile for us. From that, we can easily create a Docker image. This image will be used to run our code on-demand!

Let me be extra clear here. The above command will create a regular old app within your Fly account. I'm not going to use that app. We're just using fly launch as a convenient way to setup a Dockerfile.

Similar to "regular" apps, Fly Machines are housed within a "Machine App". A machine app can have any number of machines running within it. Here's how to create one:

# Create a new machine app (with no running VM's)
fly apps create --machines --name on-demand

That makes an app named on-demand.

Remember how I mentioned that Machines have an API? The API allows for more fine-grained controls over how you run things. We won't need it for this demonstration, but if you want to see what it looks like to create a machine app via API, it looks like this:

# Get your access token from ~/.fly/config.yml
# Or via `fly auth token`
export FLY_API_TOKEN="$(fly auth token)"

curl -X POST \
    -H "Authorization: Bearer ${FLY_API_TOKEN}" \
    -H "Content-Type: application/json" \
    "https://api.machines.dev/v1/apps" \
    -d '{
      "app_name": "on-demand",
      "org_slug": "personal"
}'

The app is basically just an empty shell - it has no running VMs. We can run some machines within the app.

Create a Machine VM

To run a machine, we need to supply it a Docker image (just like any Fly app). Fly machines, and regular apps, can pull Docker images either from a public repository or from their own private registry.

Normally the fly launch and fly deploy commands build the Docker image for you. For Machines, we can do the same with a handy command:

fly m run -a on-demand \
    --env "APP_ENV=production" \
    --env "LOG_CHANNEL=stderr" \
    --env "LOG_LEVEL=info" \
    . \
    "php" "artisan" "get-release" "vessel-app/vessel-cli"

The important part is the use of a period . for the image name: this forces the fly m run... command to build the Docker image used to create the VM. It uses the Dockerfile in the current directory (or path provided by --dockerfile) to build the image.

Using any other image name results in an attempt to download a pre-built image from a public registry or Fly.io's registry.

If instead we wanted to run a machine via the API, we have to build a Docker image ahead of time and push it up to Fly's registry so it's available for use.

There's a good write up on using Fly's registry here.

Here's what it looks like to build and push a Docker image to Fly:

# Build the image locally. The image name must match
# app name we used when creating the machine app
docker build -t registry.fly.io/on-demand:latest

# Authenticate against Fly's registry
fly auth docker

# Push our newly tagged image
docker push registry.fly.io/on-demand:latest

Then we can run a machine using the API:

FLY_API_TOKEN="$(fly auth token)"
FLY_APP_NAME="on-demand"

# https://fly.io/docs/machines/working-with-machines/#create-a-machine
curl -X POST \
    -H "Authorization: Bearer ${FLY_API_TOKEN}" \
    -H "Content-Type: application/json" \
    "https://api.machines.dev/v1/apps/${FLY_APP_NAME}/machines" \
    -d '{
  "config": {
    "image": "registry.fly.io/on-demand:latest",
    "processes": [
      {
        "name": "get-release",
        "cmd": ["php", "artisan", "get-release", "vessel-app/vessel-cli"],
        "env": {
          "APP_ENV": "production",
          "LOG_CHANNEL": "stderr",
          "LOG_LEVEL": "info"
        }
      }
    ]
  }
}'

No matter which way you do it, creating a machine WILL also run the machine immediately! Expect whatever command you define to get run.

Run On-Demand

Now that the machine exists, we can run it anytime we want our php artisan command to run! Here's how:

# First, fine our machine ID by listing
# machines within our app
fly m list -a on-demand

# Start our machine by ID
fly m start -a on-demand <machine-id-here>

We used the fly m start... command to start and run our Machine, which in turn runs the defined artisan command.

If we want to programmatically start the machine, we can use the API.

The HTTP request we would make from our application is the equivalent of this curl command:

FLY_API_TOKEN="$(fly auth token)"
APP="on-demand"
MACHINE="xyz133"

curl -X POST \
    -H "Authorization: Bearer ${FLY_API_TOKEN}" \
    -H "Content-Type: application/json" \
    "https://api.machines.dev/v1/apps/${APP}/machines/${MACHINE}/start"

In Laravel, that would look something like this:

$token = "foo";
$app = "on-demand";
$machine = "xyz133";
$url = "https://api.machines.dev/v1/apps/${app}/machines/${machine}/start"

$result = Http::asJson()
  ->withToken($token)
  ->post($url); 

And that's really all there is to it!

Once a machine exists, it can be started within milliseconds. Our example here will just run an artisan command whenever we'd like! Once the command finishes, it will exit and the VM will stop until called again. It's a bit like serverless that way!

Details

There's a few details we didn't cover here!

If you need to run multiple concurrent calls to the artisan command, you will need more machines. Starting a single machine multiple times won't create multiple instances. Instead, you will need to create additional machines.

We also haven't discussed managing a machine's lifecycle. In theory, you could create machines all day long (but we don't recommend it). In reality, it's better to manage a pool of machines.

A good time to destroy and recreate a machine might be when you update the Docker image it was created from (although you can also update the machine itself to achieve the same result).

Keep in mind that creating a machine is the slowest part of the process because Fly needs to convert the Docker image to a disk image before running a virtual machine.