In this blog post, I will explain how Fastly works and how to set up Fastly in a Rails application. I will also give some troubleshooting tips based on my experiences implementing Fastly in a Rails application.
Understanding CDNs and cache invalidation
Fastly is a content delivery network (CDN). A CDN is a system of distributed servers that deliver web content to a user based on geographic location.
Giant Robots (this blog) uses Fastly. If you are reading this post from Australia, the content you are reading was likely retrieved from a different web server than the content that my coworkers here in San Francisco are reading.
You and my coworkers are reading the same content, just delivered from different locations to accelerate load time. But what if the blog post has a typo and I push up a new version? How will these distributed servers know that the content they hold has changed?
The answer to these questions is: cache invalidation. But what does that really mean?
Caching, most generally, refers to any way a computer stores something so that it can be quickly accessed later on. Browsers, for example, cache web pages by storing a copy of the pages you visit and then use that copy when you re-visit rather than retrieving all of the data from the web server again.
Cache invalidation is the process by which you tell the services that cache your content that they are no longer up to date and need to retrieve updated content rather than deliver the outdated copy that they hold. Cache invalidation is important because without it we could not cache dynamic web content.
Understanding Fastly caching
The simplest way to do cache invalidation is to “purge” the entire cache each time a change is made in your application. This means that you tell service that is caching your site - in this case, Fastly - to retrieve all new content because the cache is no longer a valid representation of your site. This can work nicely for a relatively static site, such as a blog, that is only updated once a day. But what if your blog has comments and gets a new comment every minute?
This is where Fastly really shines. As opposed to requiring you to purge the entire cache for every change to your web application, Fastly uses what they call “surrogate keys” to dynamically determine which pages of your application need cache invalidation and which remain the same.
The Surrogate-Key
HTTP header values tell Fastly which pieces of dynamic
content are on a particular page. Continuing with the blog example, you might
have a Surrogate-Key
value of posts
on the home page and then a value of
posts/1
on the page that just shows your first post. When you update the first
post, you will want Fastly to purge the home page (because it shows the first
post) and the page that shows just the first post, but not the rest of the
blog post pages, because those were unchanged.
Think of the Surrogate-Key
header as a map that tells Fastly where each item
in your system appears. When that item changes, Fastly goes to each location on
the map and refreshes the content so that it is up to date.
While you can purge the entire cache for a site that uses Fastly, page-specific cache invalidation means that you are not purging the cache for pages that remain the same. Preserving the cache for most pages means that Fastly only needs to hit your web servers when necessary.
A note about edge caching
Confusingly, Fastly’s documentation uses the terms “CDN” and “edge caching” interchangeably. Edge caching is a specialized form of edge computing, an umbrella term that refers to pushing “applications, data and computing power (services) away from centralized points to the logical extremes of a network”.
Edge caching is a form of caching that uses de-centralized servers “to move content ‘closer’ to the end-users that view it to avoid the latency that occurs as packets traverse longer distances across the network.” Fastly provides an edge caching service through its CDN.
Now that you have a basic understanding of how Fastly works, let’s dive into implementing Fastly in a Rails application.
Getting started with Fastly
DNS Setup
After you’ve signed up for Fastly, you’ll need to do a little configuration to get it going:
- Set the host.
In my case, I am using Heroku, so my address is a Heroku hostname. The “Address” field is cut off, but it is the same value as the “Name” field.
- Set the domain.
Set the domain to whatever you want your site’s domain to be. In my case, it
was www.jessieayoung.com
Set the CNAME for your site to the Fastly endpoint. In my case it was
www.jessieayoung.com.global.prod.fastly.net
. I did this via DNSimple, which is what I use to manage my domains.Confirm that your DNS setup is working.
Once the DNS changes propagate, you should see some new headers when you
curl
the site you are caching.
Compare the headers of the apex domain, which I have not set up to use Fastly
and my www
subdomain, which is pointing to the Fastly URL above:
% curl --silent --verbose --output dev/null jessieayoung.com
< HTTP/1.1 200 OK
...
< X-Rack-Cache: miss
vs:
% curl --silent --verbose --output dev/null www.jessieayoung.com
< HTTP/1.1 200 OK
...
< X-Served-By: cache-lax1426-LAX
< X-Cache: MISS
< X-Cache-Hits: 0
< X-Timer: S1415387356.979543,VS0,VE1212
These new headers are being set by Fastly. I am getting a MISS
, which means
that curl
is hitting my web server rather than the cached version of
the site. This is because I haven’t added Fastly to my Rails app. Next step:
setting up the fastly-rails
gem.
Rails setup
Install the fastly-rails
gem:
# Gemfile
gem "fastly-rails"
% bundle
Add the Fastly environment variables to your .env
file:
# .env
FASTLY_API_KEY=replace_me
FASTLY_SERVICE_ID=replace_me
And add the Fastly configuration file:
# config/initializers/fastly.rb
FastlyRails.configure do |config|
config.api_key = ENV.fetch("FASTLY_API_KEY")
thirty_days_in_seconds = 2592000
config.max_age = thirty_days_in_seconds
config.service_id = ENV.fetch("FASTLY_SERVICE_ID")
end
Make sure you set these environment variables in your application’s staging and/or production environments. Fastly’s documentation contains detailed information on where to find your Fastly API key and Service ID.
Setting the correct headers
Now that you have your environment variables for Fastly, you are ready to set
the Surrogate-Key
header on the pages of your Rails app that you want to cache
and purge. As the section on understanding Fastly caching above explains, Fastly
relies on the values of the Surrogate-Key
header to determine which pages to
purge when data in your application changes.
The fastly-rails
gem provides a convenience method of
set_cache_control_headers
that sets the Surrogate-Key
header. Set a before_action
of
set_cache_control_headers
on every GET controller action you would like to
cache.
# app/controllers/posts_controller.rb
class PostsController < ApplicationController
before_action :set_cache_control_headers, only: [:index, :show]
Now, in the controller methods, you can use the set_surrogate_key_header
method to tell Fastly what the values of Surrogate-Key
should be for that
page:
# app/controllers/posts_controller.rb
...
def index
@posts = Post.all
set_surrogate_key_header Post.table_key, @posts.map(&:record_key)
end
def show
@post = Post.find(params[:id])
set_surrogate_key_header @post.record_key
end
The fastly-rails
gem adds the record_key
method to any instance of an
ActiveRecord::Base
class. For a post
with an id
of 1, post.record_key
would return posts/1
. It also adds the table_key
method to every
ActiveRecord::Base
class, which returns the name of the model name. In the
case of Post
, it returns posts
.
Passing an array of surrogate keys to set_surrogate_key_header
tells Fastly
which objects are included on that page so that when it comes time to purge,
Fastly will know which pages display your object and thus need to be purged.
Selectively purging records
Setting the surrogate keys, like you did in the previous section, tells Fastly which objects are included on each page. Now, you need to tell Fastly when to purge the pages for each object. This can be done in the controller or via callbacks.
Including these purges in the controller will look like this:
# app/controllers/posts_controller.rb
def create
@post = Post.new(post_params)
if @post.save
@post.purge_all
render @post
end
end
def update
@post = Post.find(params[:id])
if @post.update(params)
@post.purge
render @post
end
end
def delete
@post = Post.find(params[:id])
if @post.destroy
@post.purge
@post.purge_all
end
end
end
Including these purges in the controller is ideal. If your application data is not hitting a controller during creation / deletion / update (eg: you are updating via Rails console or using an engine like RailsAdmin), you can instead include these methods as callbacks:
# lib/active_record_extension.rb
if Rails.env.staging? || Rails.env.production?
module ActiveRecordExtension
extend ActiveSupport::Concern
included do
after_create :purge_all
after_save :purge
after_destroy :purge, :purge_all
end
ActiveRecord::Base.send(:include, ActiveRecordExtension)
end
end
Creating a Rake task to purge all
While the setup above takes care of purging individual records, you will also want to purge your entire Fastly cache after each deploy of your application. This is easy to do via the Fastly web UI, but it’s even easier to do with this small Rake task:
# lib/tasks/purge_fastly_cache.rake
namespace :fastly do
desc "Purge Fastly cache (takes about 5s)"
task :purge do
require Rails.root.join("config/initializers/fastly")
FastlyRails.client.get_service(ENV.fetch("FASTLY_SERVICE_ID")).purge_all
puts "Cache purged"
end
end
Debugging tip: Fastly does not cache HTTP responses with cookies
As the fastly-rails
README explains,
“By default, Fastly will not cache any response containing a Set-Cookie header. In general, this is beneficial because caching responses that contain sensitive data is typically not done on shared caches.”
The set_surrogate_key_header
method
removes the Set-Cookie
header. In some cases, you may curl
and find that the
header is still there. After a thorough investigation, I found that my
authentication library was adding Set-Cookie
back in after it was removed by
fastly-rails.
Because Fastly does not cache responses with a Set-Cookie
header, this meant that none of my pages were being cached.
To debug this behavior, I ran the handy rake middleware
task, which outputs
the middleware stack for the application. In the output, I saw the name of my
authentication library’s middleware. To test whether this middleware was the
culprit, I temporarily added the following:
# config/application.rb
config.middleware.delete "ExampleMiddleware"
and ran curl
on my site again. When I confirmed that the Set-Cookie
header
was no longer present, I knew which piece of middleware to override. To
re-delete the Set-Cookie
header, I placed the fastly-rails
middleware
directly before my authentication middleware:
config.middleware.insert_before(
ExampleMiddleware,
"FastlyRails::Rack::RemoveSetCookieHeader"
)
With that, Set-Cookie
was no longer being set and I had Fastly caching up and
running.
Confirming that Fastly is working
To confirm that Fastly is working, run curl
on your site a few times and confirm
that the X-Cache
header is returning a HIT
. Once it is, you’re good to go!
Credits
Big thank you to the Fastly team for all of their help in getting my project set
up and working properly. And a second big thank you to
Harlow for helping me solve the Set-Cookie
issue and
being an-all around awesome dude.