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.
Table of contents
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:
- Setup the ActiveRecord database connection
- Make sure all gems are installed (if you are using rbenv or RVM) and all dependencies are properly required
- Load configuration variables
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.
Get in the loop
Join my email list to get new articles delivered straight to your inbox and discounts on my products.
No spam — guaranteed.
Got it, thanks a lot!
Please check your emails for the confirmation request I just sent you. Once you clicked the link therein, you will no longer see these signup forms.