In this post, I explore how to implement asset packaging with Jammit on a Dreamhost server using Capistrano and Rake.  (Much of this will be useful to those not on Dreamhost, too.)

Background

I’ve been working on a Javascript project called Rainydays, bringing together a number of important, reusable tools I’ve written in the last few months.  I hope these tools — jQuery-based page, form, and image upload scripts — will be useful to the community at large, as well as serving my immediate need to share code between Mealstrom and our yoga website.  (I’ll be writing more about Rainydays soon).

After writing so much Javascript, I’ve developed a keen desire for asset compression: no point turning users off with many enormous script downloads when I can package them into a few minified files.  After some experimentation, I chose Jammit as our asset packager and compressor — the gem is actively maintained and supports both YUI and Google Closure to compress Javascript.  (The other system I tried, Asset Packager, uses the much less powerful JSMIN compressor.)

As you might have guessed from the title, though, I hit a hitch: our hosting service, Dreamhost, automatically kill a user process if it grows beyond certain bounds.  Alex and I have used Dreamhost pretty much since we met; for an affordable yearly price, we get unlimited domains and a wide feature set including shell access, Rails, and hosted SVN and WordPress blogs (like you’re reading now).  The servers are often slow, but we can live with that until we need better.

The question (and the subject of this blog post) thus became, how could I get asset compression working on Dreamhost?  The answer took me (and now you) on a journey through Jammit, Capistrano, and Rake, and Dreamhost’s Unix configuration.

My Solution

To use Jammit effectively, you have to use Capistrano, an awesome deployment framework.  While my company uses it at work, I’d never used it myself, so I leapt on the opportunity to learn it.  Whatever my solution was, I’d be hooking it into Capistrano’s deployment cycle.

I considered but quickly rejected building the compressed packages locally and automatically committing them before deployment.  While that would avoid process-killer on Dreamhost, it would also guarantee that unfinished code would get deployed to production.  No good.

Instead, I built a Capistrano task that runs after each update to ensure all the packages get built.  Here’s how it works:

  1. Capistrano deployment updates the code on production.
  2. The deploy script then fires a custom rake task.
  3. For each asset package, that rake task triggers the compressor, retrying as needed until it receives compressed content.  (Risks discussed later.)
  4. Compressed packages are enjoyed by one and all.

Pretty simple, right?  Let’s take a look at how it works.  Along the way, I’ll also point out a few gotchas I encountered in setting up Capistrano on Dreamhost.

Capistrano

Setting up Capistrano was simple.  I followed the instructions on capify.org and learned the basics, getting the yoga site up and running in less than an hour.  I can’t overstate how pleased I was at how powerful and simple it was.

For those who haven’t used Capistrano, the out-of-the-box cap deploy command logs into your server, pulls down a new copy of your code base from your version control, updates your app to use this new version, and restarts your server.  It can also execute commands you define at any point.  I’d always assumed at work that we had written extensive code to get such smooth deployment.  We hadn’t.  Capistrano is just that good.

Of course, I did encounter my first gotcha when I set up Capistrano on Dreamhost for the first time:

  • Don’t set up or update your domain in the Dreamhost panel until after your first deploy.  Otherwise, Dreamhost will automatically create the “current” directory, which Capistrano expects to be a symlink to the most recent code checkout.  This causes cap deploy to fail with an error like “rm: cannot remove #{your directory}/current : Is a directory“.  (If you’ve done this like I did, just delete the current directory and redeploy.)

As part of the setup, I set up SSH public key logins for Alex and me.  This neat trick (another I hadn’t seen before I joined Context) lets you authenticate to the remote server via your local user’s public key, saving you the trouble of typing a password.  Indiana University had a simple writeup, which I followed.  In the process, I encountered my second gotcha:

  • To set up public key login for the first time in Dreamhost, you must make sure your SSH directory has permission 700 (chmod 700 .ssh) and the authorized_keys file inside has permission 600 (chmod 600 authorized_keys), otherwise it won’t work.

Once that was done and I had basic deployment working, I added a task to deploy.rb to make sure my assets get packaged:

namespace :asset_packaging do
  task :ensure_packages do
    run "cd #{deploy_to}current && rake package_dreamhost_assets"
  end
end
after 'deploy:restart', 'asset_packaging:ensure_packages'
This code brings up a third gotcha:
  • You may note the odd syntax for the rake call: cd [directory] && [command].  This is apparently common in Capistrano scripts, though it’s not  in their documentation.  Capistrano doesn’t run commands from the root of your Rails application, so location-specific commands like rake require you to execute a directory change as part of the each command sequence.  Otherwise it fails with output like “rake aborted! No Rakefile found“.  Just one of those things you learn, apparently.

Rake

Once I got my deploy script figured out, I had to write a rake task to actually compress my assets.  This was my first time using rake (lots of learning in this blog post); I was once again pleasantly surprised at how smoothly it went.

I considered simply calling the command-line jammit utility over and over again until all files in the directory were compressed (when compression is killed, it leaves a characteristic 0-sized file).  Unfortunately, that would be inefficient — on average, I was seeing one package’s process — not always the same one – killed each time Jammit ran.  Rerunning the entire thing until no processes failed could take a while.  (Running Jammit is, however, the right approach for anyone with a less finicky server; just execute the “cd #{directory} && jammit” from your after-deploy hook.)

Instead, I built a custom script to do the following:

  1. Create a Jammit packager, which automatically loads the assets from a configuration file.
  2. Get the list of packages as configured in assets.yml.
  3. For each CSS and each Javascript package, run Jammit’s (public) packaging routine.
  4. If there no content was returned, we know Dreamhost killed the process, so we simply retry the operation until it succeeds.

The code:

require 'action_controller' # gotcha
require 'jammit'

desc "Uses Jammit to rebuild packages, ensuring they get built despite Dreamhost's automated process killing."
task :package_dreamhost_assets do
  j = Jammit::Packager.new
  outputdir = File.join(Jammit::PUBLIC_ROOT, Jammit.package_path)
  packages = j.instance_variable_get(:@packages) # risk 1

  packages.each_key do |genera|
    puts "Packaging #{packages[genera].keys.length} #{genera.to_s} packages."

    packages[genera].each_key do |group| # note
      puts "Working on #{group.to_s}..."
      content = ""
      while content.length == 0 do
        begin
          content = (genera == :js ? j.pack_javascripts(group) : j.pack_stylesheets(group))
        rescue Exception => err
          puts "Retrying past error: #{err.message}"
          retry # risk 2
        end
      end
      puts "content length: #{content.length}"
      j.cache(group, genera.to_s, content, outputdir)
      puts "\tdone with #{genera.to_s}"
    end
    puts "Done with #{genera.to_s}"
  end
end

The first line, as you saw, presents one final gotcha:

  • Rake seems to load only part of the Rails environment.  Since Jammit extends ActionController, I have to load that manually to prevent an error.

Implications and Risks

The way I’ve implemented my solution has several implications, none of them positive.  I’ll discuss each one and why I’m living with it.

  • To get the list of packages, I’m forced to read a private instance variable from the Jammit::Packager class.  This introduces a risk because I have to read a private instance variable from the packager.  If the implementation changes, this will break (and I will update this post).  I hope a future version of Jammit will expose the package list.  (Along a similar vein, I manually reconstruct the default output directory, which I also hope will be a public method someday.)
  • I reconstruct every package with every deploy, even if no code has changed.  I see no way around it — Capistrano does a complete code checkout with each deployment, so the timestamp method Jammit uses to determine changes doesn’t work.  It’s not a risk, but this effect slows down each deployment by a significant factor.  If you know of a way to avoid it, I would be glad to learn about it (and will happily give credit).
  • Retrying compression without count introduces another risk — if the compressor’s process consistently exceeds the server’s limits, or another bug (in Jammit, the compressor gem, or other code) causes an error to be consistently thrown, this will loop forever.

    I prefer to live with this risk rather than add a retry limit for two reasons.  For one, you should always monitor a deployment in case something goes wrong.  For another, allowing the task to time out risks leaving empty asset files on your server, thereby breaking your site; even if you remove the empty files, your users will then have to wait for a request-time compression attempt that might fail too.  (For what it’s worth, it hasn’t been a problem thus far.)
Wrapping It Up (into a nice little package)

I’ve been using this system for the last week, and it’s been working smoothly.  The compressed assets are being generated and served properly, making our users (Alex’s yoga teacher training class is pretty interested) happy.  I learned Capistrano and rake, and life is good.

As always, should anything change, errors get discovered, etc., I’ll update this post.  In the meanwhile, feel free to comment; I’ll be glad for any thoughts or questions.

Cheers,

Alex K.

Resources: