Fly Machine Workers

This application uses Fly Machines as a scale-to-zero back-end for Active Jobs. When a background job gets kicked off, Fly provisions a Fly Machine that's a copy of this Rails app, runs the job, and powers down the machine forever. That means you're not paying for servers that run sidekiq and mostly sit around idle until they're given a background job. Those cost savings also mean your background works can scale up to a ridiculously large number, then scale back to 0 when nothing happens. Huzah!

Security

🚨🚨 DANGER! WARNING! 🚨🚨

Fly needs the fly auth token of an account to provision and launch machines. If an adversary gained access to this token, they could take full control of your account and start mining cryptocurrency, delete everything, and do other mean things.

To mitigate this risk, you will need to create a separate Fly account to run Machines. If this account gets compromised, you should be OK with the damage an adversary could inflict. It still wouldn't be pretty, but at least they can't destroy all the resources of the primary account.

Be careful with this approach!

Step-by-step

Now that we understand the security implications of this lil' plan, lets get going!

Get the name of the running Rails app

This application runs under Fly's first generation of hosting infrastructure with the application name rails-machine-workers.

  def self.env_app_name
    ENV["FLY_APP_NAME"]
  end

We grab this from the FLY_APP_NAME environment variable that's injected into the container by Fly when it's launched. If you're running this from a development environment, you'll need to set this to the name of a provisioned Fly app.

Get the image name of the running app

Here's the image reference for the currently running application: registry.fly.io/rails-machine-workers@sha256:602a10257d8720b33ec32d6a8233e4326c3430f4ef0eb0934ff65320f2aec595.

    # Doocker image of the currently running application
    def image_ref
      machine_image_ref || query_image_ref
    end

    private

    # Newer containers are injecting this variable, which
    # means we can skip the lookup.
    def machine_image_ref
      ENV["FLY_IMAGE_REF"]
    end

    # Some environments don't have the `FLY_IMAGE_REF` variable
    # so we have to look it up via an internal, undocumented, GraphQL
    # that if you try using it, it will one day break.
    def query_image_ref
      api.graphql(%{
        query {
          app(name: "#{name}") {
            currentRelease {
              imageRef
            }
          }
        }
      }).dig("data", "app", "currentRelease", "imageRef")
    end
  end

We're going to use this to launch machines, but before we do that we have to create an Application for workers to run.

Create a Machines Fly app to run the Machines

Machines that are deployed to Fly need to belong to an application, so create one by running:

$ fly apps create rails-machine-workers-workers --machines

We'll use this to boot Machines for our Rails jobs!

Run a Machine

Now we have everything in place needed to run a Machine. Let's try it from the console.

$ Fly.app.machine.fork init: { cmd: %w[sleep 30] }

This command "forks" your existing application by booting a machine with the image_ref and ENV vars.

module Fly
  class Machine

    def initialize(app:)
      @api = app.api
      @app = app
    end

    def image_ref
      @app.image_ref
    end

    # If we're running the application from a Nomad, we need to create a
    # new Fly application that works with machines.
    def worker_app_name
      "#{@app.name}-workers"
    end

    # Runs a Fly machine. When the machine is done running, that's it! You
    # don't need to worry about deleting it later.
    def run(name: nil, region: nil, config: {})
      @api.post "/v1/apps/#{worker_app_name}/machines", {
        name: name,
        region: region,
        config: config
      }
    end

    def fork(**config)
      # Merge the currently running environment into the configuration of the
      # machine that we're going to launch. This includes secrets, so make sure
      # you're running your code in the machine and you trust the image. If you
      # don't trust the runtime, say for a CI environment, you'd leave the `ENV`
      # vars out, so you should probably use the `run` method above.
      config.fetch(:env, {}).merge! ENV
      # We're going to boot the image of the currently running application.
      config[:image] = image_ref
      run config: config
    end
  end
end

Now we can see a list of currently running machines.

rails-machine-workers [main] → fly m ls -a rails-machine-workers-workers
e148e446bd6989  wandering-water-7858    started sjc     rails-machine-workers:  fdaa:0:a177:a7b:b2e2:c8a6:3277:2          2022-09-21T21:51:50Z  2022-09-21T21:51:50Z
06e82956fe2e87  green-breeze-2572       started sjc     rails-machine-workers:  fdaa:0:a177:a7b:a160:c3d3:cbca:2          2022-09-21T20:45:29Z  2022-09-21T20:45:46Z
9e784935c6d383  dawn-silence-5439       started sjc     rails-machine-workers:  fdaa:0:a177:a7b:a15f:9ef5:232a:2          2022-09-21T20:45:27Z  2022-09-21T20:45:30Z
d5683004be048e  long-sun-7558           started sjc     rails-machine-workers:  fdaa:0:a177:a7b:2295:8b8f:c55a:2          2022-09-21T20:50:12Z  2022-09-21T20:50:15Z
e148e444c26489  wispy-moon-9832         started sjc     rails-machine-workers:  fdaa:0:a177:a7b:b385:41c3:238d:2          2022-09-21T20:44:56Z  2022-09-21T20:44:58Z
9e784996fd7183  ancient-breeze-5519     started sjc     rails-machine-workers:  fdaa:0:a177:a7b:b2e2:4ba5:afc6:2          2022-09-21T20:50:25Z  2022-09-21T20:50:28Z
06e82997b6d987  lively-water-3206       started sjc     rails-machine-workers:  fdaa:0:a177:a7b:b389:6b3:a716:2           2022-09-21T20:46:25Z  2022-09-21T20:46:27Z

Don't forget to include the app name of the workers, not the currently running application.

ActiveJob Adapter

Well that was fun kicking off a task from the Rails console! Since we love monolith and ActiveJob, let's set this up so that when we enqueue a job, a machine is forked and the job is performed.

# frozen_string_literal: true

module ActiveJob
  module QueueAdapters
    # == Fly Machine adapter for Active Job
    #
    # Boot VMs in 500ms, run a Rails background job, and shuts it down when it's all done.
    #
    #   Rails.application.config.active_job.queue_adapter = :fly_machine
    class FlyMachineAdapter
      def enqueue(job) # :nodoc:
        Fly.app.machine.fork init: {
          cmd: [ "/app/bin/rails", "runner", "ActiveJob::Base.deserialize(#{job.serialize}).run"]
        }
      end

      def enqueue_at(*) # :nodoc:
        raise NotImplementedError, "Does not yet support queueing background jobs in the future"
      end
    end
  end
end

It's a pretty simple adapter. As you can see all it does it deserializes the job and sets up a `rails runner` command that deserializes it and runs it.

Additional resources

Links to documentation that can help better explain some of the APIs and tools that get this working.