Faster Rails 3 deployments to AWS Elastic Beanstalk
Web applications deployments cannot be slow. The definition of slow, of course, is relative. I like to think slow is anything that breaks the flow, that takes long enough to make me lose focus of what I am doing.
Recently I moved a Rails 3 app from Heroku to Amazon Elastic Beanstalk (EB). Deploying to Heroku was not showing the best performance results, but to EB was taking enough time to make me lose my patience.
After understanding how EB works, identifying bottlenecks, and caching things; I was able to reduce the time to deploy to near one minute.
EB: Behind the scenes
EB allows deployments in a Heroku-esque way simply executing git
aws.push
. This command is actually an alias to a ruby script located
at .git/AWSDevTools/aws.elasticbeanstalk.push
. It just pushes yours
latest commit to the EB servers.
When EB receives the latest version of your app, it packs the source files in a zip archive, uploads to a S3 bucket under your account, and deploys to the EB environment.
Inspecting the EC2 plumbing
Analyzing the file /var/log/eb-tools.log
, we can see the deployment
has hooks and scripts associated with it. The program at
/usr/bin/directoryHooksExecutor.py
executes those scripts
sequentially in alphabetical order by name.
2013-04-07 14:03:59,414 [INFO] (3056 MainThread) [directoryHooksExecutor.py-29] [root directoryHooksExecutor info] Executing directory: /opt/elasticbeanstalk/hooks/appdeploy/enact/
2013-04-07 14:03:59,511 [INFO] (3056 MainThread) [directoryHooksExecutor.py-29] [root directoryHooksExecutor info] Executing script: /opt/elasticbeanstalk/hooks/appdeploy/enact/01_flip.sh
2013-04-07 14:04:00,420 [INFO] (3056 MainThread) [directoryHooksExecutor.py-29] [root directoryHooksExecutor info] Executing script: /opt/elasticbeanstalk/hooks/appdeploy/enact/09clean.sh
2013-04-07 14:04:00,600 [INFO] (3056 MainThread) [directoryHooksExecutor.py-29] [root directoryHooksExecutor info] Executing script: /opt/elasticbeanstalk/hooks/appdeploy/enact/99_reload_app_server.sh
Given EC2 (non-EBS) does not persist data, and EB auto-scale our EC2 instances,
we cannot change these deploys scripts. However, we can customize the
EC2
with commands
and container_commands
.
Finding the bottlenecks
To customize the EC2 correctly, I first had to understand how exactly
the commands specified in my .ebextensions/*.config
files act in the
whole deployment.
I created the two commands below, and added to the top of each
/opt/elasticbeanstalk/hooks/appdeploy/**/*.sh
script an instruction to
create a file with the timestamp of its execution.
# .ebextensions/app.config
commands:
01_first_command:
command: "touch /tmp/$(date +'%T.%N').command"
container_commands:
01_first_container_command:
command: "touch /tmp/$(date +'%T.%N').container_command"
# /opt/elasticbeanstalk/hooks/appdeploy/pre/01_unzip.sh
#!/usr/bin/env bash
touch /tmp/$(date +"%T.%N").$(basename $0)
. /opt/elasticbeanstalk/support/envvars
mkdir -p $EB_CONFIG_APP_BASE && chown $EB_CONFIG_APP_USER:$EB_CONFIG_APP_USER $EB_CONFIG_APP_BASE
[ -d $EB_CONFIG_APP_ONDECK ] && rm -rf $EB_CONFIG_APP_ONDECK
su -c "/usr/bin/unzip -d $EB_CONFIG_APP_ONDECK $EB_CONFIG_SOURCE_BUNDLE" $EB_CONFIG_APP_USER
chmod 775 $EB_CONFIG_APP_ONDECK
After a deploy, I was able to see the exact sequence of the commands executed, and how long each one was taking. The whole process was taking about unconceivable 8 minutes to complete. The culprits for such slowness were, as suspected, gems installation and assets compilation.
$ ls -1 /tmp
19:56:22.524828173.command
19:56:23.188093045.01_unzip.sh
19:56:23.524226323.02_setup_envvars.sh
19:56:23.575868688.10_bundle_install.sh
19:59:32.932137842.11_asset_compilation.sh
20:04:29.588351276.12_db_migration.sh
20:04:30.618231911.container_command
20:04:38.656611666.01_flip.sh
20:04:39.683074030.09clean.sh
20:04:39.706735596.99_reload_app_server.sh
Caching gems and assets
We need to cache the gems to optimize bundle install
, and to boost
the asset compilation we install Turbo
Sprockets and
cache the compiled assets as well.
Unfortunately, we cannot use container_commands
because we need to set up our cached files between 01_unzip.sh
and
10_bundle_install.sh
. Nor can we use commands
, because
01_unzip.sh
cleans up the target directory before unpacking your
source files. We can use files
option to inject a script to set up
the cache.
Since the hook scripts are executed in alphabetical order, we need to
name the injected script correctly to be executed just after
01_unzip.sh
. We will name it 01a_bootstrap.sh
.
files:
/opt/elasticbeanstalk/hooks/appdeploy/pre/01a_bootstrap.sh:
mode: 00755
owner: root
group: root
source: http://s3.amazonaws.com/mybucket/bootstrap.sh
The cached files were previously moved to /var/app/support
. The
bootstrap script will create symbolic links to the directory being
deployed.
# /opt/elasticbeanstalk/hooks/appdeploy/pre/01a_bootstrap.sh
#!/usr/bin/env bash
mkdir /var/app/ondeck/vendor /var/app/ondeck/public /var/app/support/bundle /var/app/support/assets
ln -s /var/app/support/bundle /var/app/ondeck/vendor
ln -s /var/app/support/assets /var/app/ondeck/public
Ultimately, we could drop the deployment time near to 1 minute and 4 seconds—the time to download the packages is not counted here, but since it is in S3 in the same region, it takes no longer than 3 seconds to get 30MB. This is the kind of slowness I can withstand.
$ ls -1 /tmp
01:49:05.696544004.01_unzip.sh
01:49:06.029004848.01a_bootstrap.sh
01:49:06.055676774.02_setup_envvars.sh
01:49:06.120822495.10_bundle_install.sh
01:49:07.156151174.11_asset_compilation.sh
01:50:07.243165374.12_db_migration.sh
01:50:08.454668833.01_flip.sh
01:50:09.648283462.09clean.sh
01:50:09.889387126.99_reload_app_server.sh
Cleaning up and updating the cache
Given the deployment is no more an issue, we need to make sure our
cached files correspond to the latest version of our application. We
can use a post
hook to execute another script to update the cache.
files:
/opt/elasticbeanstalk/hooks/appdeploy/post/01_update_cache.sh:
mode: 00755
owner: root
group: root
source: http://s3.amazonaws.com/mybucket/update_cache.sh
# /opt/elasticbeanstalk/hooks/appdeploy/post/01_update_cache.sh
#!/usr/bin/env bash
. /opt/elasticbeanstalk/support/envvars
if [ "$RAILS_ENV" != "staging" ]; then
exit
fi
cd /var/app/support
tar zcf bundle.tar.gz bundle
s3put -b mybucket -p /var/app/support -g public-read bundle.tar.gz
tar zcf assets.tar.gz assets
s3put -b mybucket -p /var/app/support -g public-read assets.tar.gz
Thanks to staging-production parity, we can safely use our staging server to keep the cache updated, and leave the production server exclusively to our application.
We send the cache packages to our S3 bucket, so it will be available to
any new EC2 instance EB starts. The only thing left is to configure
the EC2 to download and unpack those packages. We can easily accomplish
this using the sources
key.
sources:
/var/app/support: http://s3.amazonaws.com/mybucket/bundle.tar.gz
/var/app/support: http://s3.amazonaws.com/mybucket/assets.tar.gz
We have made great changes to our deploy time. Nevertheless, there is
room for improvements. For example, the update_cache.sh
script could
check for changes in the cached packages and upload a new version only
when necessary.
Update Apr, 09
I had some issues with the asset compilation. The problem is that the
script
/opt/elasticbeanstalk/hooks/appdeploy/pre/11_asset_compilation.sh
executes rake
directly, therefore it was not using the bundled gems.
I set my bootstrap script to change it to bundle exec rake
.
$ sed -i 's/"rake/"bundle exec rake/' /opt/elasticbeanstalk/hooks/appdeploy/pre/11_asset_compilation.sh
Another issue happened with passenger and git backed libraries.
Thanks to these
answers, I was able to fix the
problem injecting another script right after 10_bundle_install.sh
to
pack all the gems.
files:
/opt/elasticbeanstalk/hooks/appdeploy/pre/10a_bundle_pack.sh:
mode: "00755"
owner: root
group: root
source: https://s3.amazonaws.com/mybucket/bundle_pack.sh
# /opt/elasticbeanstalk/hooks/appdeploy/pre/10a_bundle_pack.sh
#!/usr/bin/env bash
. /opt/elasticbeanstalk/support/envvars
cd /var/app/ondeck
bundle pack --all
I also added the vendor/cache
directory to the bundle.tar.gz
to
avoid any delays in the deployment.
I have put all my configuration files and scripts in this gist.