Michael Trojanek (relativkreativ) — Bootstrapper and creator of things

This article was published on September 22nd 2015 and takes about 4 minutes to read.

Use it with caution — it is probably still valid, but it has not been updated for over a year.

A way to trigger a Rails action from a cron job you may not have thought about

The need to run a Rails action periodically can require drastic modifications to your hosting machine but sometimes it pays to try out rather unusual approaches. Read about a quick way to trigger a Rails action from outside of your application.

Context

My main backend is a Rails application which connects my websites (this one as well as efficientrailsdevops.com) with various third party services to (among other things) handle visitors who join my email list. In order to add them without any delays, they are stored in the application's database for instant feedback and then synced to Mailchimp every 15 minutes using their API.

Up until recently, I used a plain Ruby script triggered by a cron job to do the synchronization but this became more and more of a pain.

The problem

In terms of code reuse, it may at first seem like a great idea to wrap your existing Rails models in a Ruby script and use their functionality for a periodic job. But (depending on how you build your Rails models) you will have to prepare an environment in your script in order to use them:

This is cumbersome, repetitive and error-prone, as Rails already does all these things for you.

On the other hand, spinning up a Rails application for one method call is too expensive to do it every few minutes.

A radically different approach

As your application is already humming along, how about triggering the desired action via a kind-of API call?

In order to do that, we first have to define a route in our routes.rb file. I will be using the synchronize-action of my CustomersController:

post 'customers/synchronize' => 'customers#synchronize'

Then we have to switch Rails' protect_from_forgery method from :exception to :null_session as the Rails documentation recommends for API requests:

class CustomersController < ApplicationController
  protect_from_forgery :null_session, only: :synchronize

  def synchronize
    …
  end
end

We use the HTTP-method POST so that this action will not be triggered when a crawler or search engine visits our application.

Needless to say, this action is a regular Rails action so we can use every object and method just like we would if this action was triggered from a person visiting the URL in a browser.

To trigger this action from our server, we can use any command-line tool that allows us to issue HTTP requests. Take curl for example (assuming that our application runs on the www.example.com domain):

curl -X POST http://www.example.com/customers/synchronize

That's basically all there is to it. We just have to edit our crontab (crontab -e) and add this command to trigger our action every 15 minutes. To be on the safe side we use the full path to the curl command (which you can find out by running which curl):

*/15 * * * * /usr/bin/curl -X POST http://www.example.com/customers/synchronize

A word on security

Right now, our action can be triggered by every client capable of issuing POST requests (which is every browser used by someone with very basic JavaScript skills).

The first step in securing our action might be to require a secret token as a request parameter:

def synchronize
  head :unauthorized and return if params[:token] != 'abc'
  …
end

Now our action only runs if we supply the secret token when calling its URL. That's why we have to adjust the crontab entry:

*/15 * * * * /usr/bin/curl -X POST -d "token=abc" http://www.example.com/customers/synchronize

Of course this only makes sense if our action uses SSL. Otherwise it would not be hard to sniff the secret token.

Another security measure would be to restrict the calling client to specific IP addresses, maybe even just our server itself. If we want to compare the request's IP address to the list of IPs of our server, we can use Ruby's Socket class:

def synchronize
  head :unauthorized and return if params[:token] != 'abc' or !Socket.ip_address_list.select(&:ipv4?).map(&:ip_address).include?(request.remote_ip)
  …
end

There are other security measures we can apply (client certificates come to mind) but for the purpose of this example, this should be enough.

After all, as long as our action does not use any parameters except the secret token, the worst thing that can happen is some bad request triggering the action outside of its 15-minute-cycle.

When going down this road, note that triggering the synchronize action blocks the application just as a regular request would do, so this approach works best when your periodic action does not take too long to execute or (even better) if it is handled by a backend-application which does not bother with regular web requests.

Expand your DevOps skills!

Join hundreds of Rails developers and operators on my email list and get my ebook Build Your Own Rails Server as a free welcome gift.

No spam — guaranteed. You can leave at any time.