Push-to-Deploy with AWS CodeDeploy
AWS CodeDeploy is a tool for automating application deployments to EC2 instances and clusters. It can pull application archives from either S3 or GitHub, and then allows you to specify how to install, configure, and run the application via a configuration specification and optionally hook scripts. When setup correctly, it can provide a powerful way to automate your deployments.
I started looking into it because I wanted to try out my site on PHP 7, and do a few new things with nginx that I wasn't doing before. Additionally, I've accidently forgotten to deploy a few times in the past year after writing a blog post, and I wanted to see if I solve that situation; I'd really enjoyed the "push-to-deploy" paradigm of OpenShift and EngineYard in the past, and wanted to see if I could recreate it.
Enrico first pointed me to the service, and I was later inspired by a slide deck by Ric Harvey. The process wasn't easy, due to a number of things that are not documented or not fully documented in the AWS CodeDeploy documentation, but in the end, I was able to accomplish exactly that: push-to-deploy. This post details what I found, some recommendations on how to create your deployments, and ways to avoid some of the pitfalls I fell into.
Preparing for CodeDeploy on AWS
The first thing you need to do is setup a whole slew of profiles, roles, and policies on AWS. The AWS CodeDeploy Getting Started guide walks you through the various details of that. While it's not trivial or easy, I was able to get everything ready without any real stumbling blocks.
Create an EC2 instance
Once you've setup your IAM (Identity and Access Management) profiles, roles, and policies, you can start enabling CodeDeploy on your EC2 instances. While you can assign an IAM policy to an existing EC2 instance, I recommend using a new instance, to ensure that you can troubleshoot and debug without affecting a running application.
I went and selected an Ubuntu 16.04 AMI (specifically, ami-32b6515f), as I want to use the latest LTS, and I'm familiar with both Ubuntu and Debian systems. (This turned out to pose a few issues, which I'll detail later.)
When I created the instance, I tied it to the IAM policy I created for CodeDeploy, ensuring I'll be able to use it with that service.
Setting up the EC2 instance
If you don't install the official Amazon Linux AMI, you won't have the various tools in place needed to run the CodeDeploy agent. Among other things:
- The www-data user is setup such that it cannot use a shell, which means it cannot run scripts — which poses a problem for running deployment scripts or cronjobs as the user.
- You need to install the CodeDeploy agent on the instance, and it may need some dependencies installed depending on the AMI you use.
www-data
The www-data user exists by default. However, it has the login shell set to
/usr/sbin/nologin
. This means that if you specify:
runas: www-data
in one of your appspec.yml
hooks, it will fail; this also affects execution of
crontab entries. The solution is to update the user to have a real login shell.
Run:
$ sudo vipw
vipw
is a safer way to edit the /etc/passwd
file, and will prompt you for an
editor to use before opening it. Find the entry for www-data
, change the shell
to /bin/bash
, save, and exit.
Ruby 2.0
In order to install the code deploy agent on the server, you need to have ruby 2.0 installed; the installer for the agent will not work with any other version at this time.
If you're on Ubuntu 14.04, or if you're on the official Amazon Linux AMI, it's already installed, or can be installed from existing package repositories:
### On Ubuntu 14.04:
$ sudo apt-get install ruby2.0
### On Amazon Linux or Fedora:
$ sudo yum install ruby2.0
If, like me, you decide to use Ubuntu 16.04 (xenial), that version is unavailable (the lowest version available is 2.3), and even some well-known package repositories do not have xenial packages available (if they ever will).
So, I had to create a package, which involves downloading a 2.0 release, using a utility to create a debian package out of it, and then installing it.
To do that, I did the following:
$ sudo apt-get install checkinstall build-essential zlib1g-dev libssl-dev libreadline6-dev libyaml-dev
$ wget http://cache.ruby-lang.org/pub/ruby/2.0/ruby-2.0.0-p481.tar.gz
$ tar xzf ruby-2.0.0-p481.tar.gz
$ cd ruby-2.0.0-p481
$ sudo checkinstall .
When checkinstall
runs, it will prompt you for a few things:
The package documentation directory ./doc-pak does not exist.
Should I create a default set of package docs? [y]:
Answer "y".
It then asks for a description; I used "Ruby 2.0 interpreter".
At this point, it shows you what values the package will be built with. Time to change a few.
- Change the Name (option 2) to "ruby2.0"
- Change the Version (option 3) to "2.0.0-p481"
- Change the Requires list (option 10) to read: build-essential,zlib1g-dev,libssl-dev,libreadline6-dev,libyaml-dev
- Change the Provides list (option 11) to read: ruby-interpreter,ruby-interpreter:any,ruby-interpreter:i386,ruby2.0:any
Once done, hit ENTER.
This builds the package in the current directory as
ruby2.0_2.0.0-p481-1_amd64.deb
, which you can then install with dpkg -i
,
and later remove with dpkg -r ruby2.0
.
The package metadata values are important. Without them, the agent may or may not be able to identify that a usable ruby version is installed on the system.
CodeDeploy Agent
To install the code deploy agent, you need to know what region you're in. From there, you do the following:
$ mkdir code-deploy-agent
$ cd code-deploy-agent
$ wget https://aws-codedeploy-{region name}.s3.amazonaws.com/latest/install
$ chmod +x ./install
$ sudo ./install auto
I have my EC2 instance launched in us-east-1, so the above url became
https://aws-codedeploy-us-east-1.s3.amazonaws.com/latest/install
.
(See the documentation on installing the agent
for valid S3 bucket values for the installer, as not all regions are
represented.)
If you have installed the dependencies as listed above, all should go well. From there, check to see if it's running:
$ sudo service codedeploy-agent status
If it's not running, start it:
$ sudo service codedeploy-agent start
Creating your deployment
I found that, in the end, PHP application deployment was quite easy with
CodeDeploy, but that due to oddities in the appspec.yml
rules, as well as in
where and how event hooks scripts are executed, the documentation often failed
me. As such, this is probably the most important section of this narrative.
A basic appspec.yml
has the following structure:
version: 0.0
os: linux
files:
- source: /
destination: /var/www/example.com
permissions:
- object: /var/www/example.com
pattern: "**"
owner: www-data
group: www-data
type:
- directory
- file
hooks:
ApplicationStop:
- location: .aws/application-stop.sh
timeout: 30
runas: root
BeforeInstall:
- location: .aws/before-install.sh
timeout: 300
runas: root
AfterInstall:
- location: .aws/after-install-www-data.sh
timeout: 300
runas: www-data
- location: .aws/after-install-root.sh
timeout: 30
runas: root
ApplicationStart:
- location: .aws/application-start.sh
timeout: 30
runas: root
Let's talk about each of the sections.
Files
files
allows you to specify which files from your archive should be
installed, and their destination on the filesystem. Each entry requires a
source
, which will be a path relative to the archive, and a destination
,
which will be its destination on the server.
If you specify /
(or \\
for Windows instances), CodeDeploy will copy the
entire archive. I've found this is typically easiest, as the appspec.yml
specification does not provide wildcard functionality, nor any
whitelist/blacklist functionality. Yes, you can specify directories or files,
but once you go that route, if you have more than a handful, the specification
gets unwieldy.
One tip I read early on was to ship the deployable code within a subdirectory of the archive. This is similar to how Zend Server's ZPK format expects things as well, but it's pretty much counter to every PHP framework skeleton or application I've used or seen.
One thing to know: this does not work like Unix cp
or mv
. With those
utilities, if the source is a file and you specify a destination path that does
not exist, they will create it as a file. However, CodeDeploy does not. As an
example, consider the following:
files:
- source: .aws/crontab
destination: /var/spool/cron/crontabs/www-data
Since /var/spool/cron/crontabs
is a directory, if I were using cp
or mv
,
I'd expect this operation to create the file
/var/spool/cron/crontabs/www-data
. Instead, because the source name and
destination name do not match, CodeDeploy creates that as a directory, and
then copies the file crontab
beneath it, giving us the file
/var/spool/cron/crontabs/www-data/crontab
. Which is utterly unusable. (There
are ways around it via hook scripts, which I'll detail later.)
Another thing to keep in mind: when providing a source directory, CodeDeploy copies all files under it, recursively, to the destination. If the destination does not include the source directory name, you'll be in for a surprise:
files:
- source: bin
destination: /var/www/example.com
will copy all the files under bin/
to the directory /var/www/example.com
. It
will not create a bin/
directory under that path! As such, you likely want
to use:
files:
- source: bin
destination: /var/www/example.com/bin
For these reasons, I found it was far easier to just copy the entire archive.
Permissions
The permissions
section allows you to specify permissions for individual files
or trees on the server. These are applied during the Install event, after all
files have been deployed to their location.
The format is:
permissions:
- object: /var/www/example.com
pattern: "**"
owner: www-data
group: www-data
mode: 4755
type:
- directory
- file
You can specify individual files or directories for the object
. directories
require a pattern
following them, which allows you to provide a POSIX glob for
specifying files and directories to which to apply the permissions; **
indicates it should match everything under the tree.
Additionally, the type
parameter allows you to specify whether the permissions
apply to specifically directories or files; you can specify both at the same
time if desired.
The owner, group, and mode arguments are just as you would use for either
chown
, chgrp
, or chmod
.
The above will likely work for most cases. I broke that into two separate statements, one for applying to directories, another for files:
permissions:
- object: /var/www/example.com
pattern: "**"
owner: www-data
group: www-data
mode: 4750
type:
- directory
- object: /var/www/example.com
pattern: "**"
owner: www-data
group: www-data
mode: 640
type:
- file
There's a fair amount more you can do; read the appspec.yml permissions documentation for more details.
Hooks
Hooks allow you to specify scripts to run during each of the CodeDeploy events. There are five events you can listen to:
- ApplicationStop, which occurs at the start of a deployment operation.
- BeforeInstall, which occurs before any
files
specified in theappspec.yml
are deployed to their destinations. - AfterInstall, which occurs after files have been deployed.
- ApplicationStart, which happens after installation is complete
- ValidateService, which happens after the application has been started.
There are actually a few other events, but only the above can trigger hook scripts.
As noted in the sample appspec.yml
, the various hook sections have the
following format:
hooks:
<event name>:
- location: <path to script>
timeout: <timeout in seconds>
runas: <user to run script as>
The only required element is the location
field, which is the script to
execute. The timeout
can be used to help ensure that scripts that take too long
to execute fail the deployment, allowing you to return to the previous
deployment. The runas
is used to specify a user to execute the script under,
and defualts to root; I like to specify it explicitly, and some scripts may need
to run under different users (in particular, the www-data user).
Now come the various caveats and recommendations.
First things first: I put all the various files related to deployment on AWS in
a dedicated directory, .aws/
. This allows me to have it all in one place, and
segregate it from the rest of my application.
Second: I strongly recommend creating a script named after the event
in which it executes; e.g., after-install.sh
. This makes identifing which
script to edit and debug far simpler. If the script needs to be run as a
specific user, I include that in the script name as well:
after-install-www-data.sh
.
Third, the "deployment directory" is not the same as the "installation
directory". The deployment directory is where CodeDeploy downloads your code
(whether from GitHub or S3). During the Install event, it copies code from
that directory into the final destination (per your appspec.yml
"Files"
rules). However, Install will only copy directories that were part of the
original archive. That means any files you generate will not be part of the
installation directory.
Fourth, hook script Location
values are always relative to the deployment
directory. Not the installation directory. In fact, even fully qualified
paths are interpreted as if they were relative to the deployment directory,
which means system tools cannot be called directly! As such, you'll need to make
those calls to system tools within a hook script.
Example
hooks:
ApplicationStop:
- location: .aws/application-stop.sh
timeout: 30
runas: root
BeforeInstall:
- location: .aws/before-install.sh
timeout: 30
runas: root
AfterInstall:
- location: .aws/after-install-www-data.sh
timeout: 300
runas: www-data
- location: .aws/after-install-root.sh
timeout: 30
runas: root
ApplicationStart:
- location: .aws/application-start.sh
timeout: 30
runas: root
System dependencies
One cool thing about CodeDeploy is that, other than the requirements to allow the CodeDeploy agent to run and ensuring www-data has a login shell, you can assume that deployment will take care of the everything else for you, much as you would when using Ansible, Puppet, Chef, or Docker.
The idea is this: during the BeforeInstall event, you will check for and install system dependencies, create directories and configuration, etc.
One nice aspect about this approach is that if your system requirements change — for example, if you decide to switch between grunt and gulp for preparing your frontend assets — you can alter your hook script to add the new requirement, and it will be installed on the next deployment.
I wrote one script to handle all of this for my site:
###!/bin/bash
#########################################################################
### System dependencies
#########################################################################
### Install needed dependencies
apt-get update
apt-get install -y nginx php7.0 php7.0-bcmath php7.0-bz2 php7.0-cli php7.0-ctype php7.0-curl php7.0-dom php7.0-fileinfo php7.0-fpm php7.0-gd php7.0-iconv php7.0-intl php7.0-json php7.0-mbstring php7.0-pdo php7.0-pdo-sqlite php7.0-phar php7.0-readline php7.0-simplexml php7.0-sockets php7.0-sqlite3 php7.0-tidy php7.0-tokenizer php7.0-xml php7.0-xsl php7.0-xmlreader php7.0-xmlwriter php7.0-zip npm python3-pip
### aws cli
pip3 install awscli
### Get Composer, and install to /usr/local/bin
if [ ! -f "/usr/local/bin/composer" ];then
php -r "copy('https://getcomposer.org/installer', 'composer-setup.php');"
php -r "if (hash_file('SHA384', 'composer-setup.php') === 'e115a8dc7871f15d853148a7fbac7da27d6c0030b848d9b3dc09e2a0388afed865e6a3d6b3c0fad45c48e2b5fc1196ae') { echo 'Installer verified'; } else { echo 'Installer corrupt'; unlink('composer-setup.php'); } echo PHP_EOL;"
php composer-setup.php --install-dir=/usr/local/bin --filename=composer
php -r "unlink('composer-setup.php');"
else
/usr/local/bin/composer self-update --stable --no-ansi --no-interaction
fi
### Create a COMPOSER_HOME directory for the application
if [ ! -d "/var/cache/composer" ];then
mkdir -p /var/cache/composer
chown www-data.www-data /var/cache/composer
fi
### Get private configuration
if [ ! -d "/var/www/config" ];then
mkdir -p /var/www/config
fi
(cd /var/www/config && aws s3 sync s3://config.example.com .)
### Make a log directory for php-fpm
if [ ! -d "/var/log/php" ];then
mkdir -p /var/log/php
fi
chown -R www-data.www-data /var/log/php
chmod -R ug+rwX /var/log/php
### Install grunt globally
npm install -g grunt-cli
### Ensure we can run npm as www-data
if [ ! -d "/var/www/.npm" ];then
mkdir -p /var/www/.npm
chown www-data.www-data /var/www/.npm
chmod o-X /var/www/.npm
chmod ug+rwX /var/www/.npm
fi
As you can see, I do some conditional installation as well; if certain files
or directories exist, I can skip over them or treat them differently. In the
case of running apt-get
, pip3
, or npm
, I know that these applications will
check to see if the latest version is installed before attempting to do
anything, making these very fast operations most of the time.
Some notes on a few items in there:
- I created a
COMPOSER_HOME
directory as composer requires a place to cache the results of pulling information from Packagist, as well as packages it has downloaded. Since the www-data user doesn't have rights to create files or directories under/var/www
, we need to create a directory for it to use.- Similarly, npm caches to
$HOME/.npm
. If there's a way to specify an alternate directory, I've not found it yet. As such, I create the directory here, if it doesn't exist, and make sure the www-data user has ownership of it.- I want to be able to log my PHP errors, so I create a log directory for PHP, and, again, make sure www-data can write to it.
Essentially, BeforeInstall is when I can make sure the system is ready to run my application once installation completes.
Private configuration
One other thing to note in that script is the usage of the AWS CLI to pull some files from S3:
### Get private configuration
if [ ! -d "/var/www/config" ];then
mkdir -p /var/www/config
fi
(cd /var/www/config && aws s3 sync s3://config.example.com .)
What I've done here is stored production configuration settings and SSL certificates in a private bucket on S3. Because the AWS CLI needs appropriate credentials to access the bucket (and if you're on an EC2 instance, it inherits credentials based on the instance policy), this is a safe operation, ensuring I have that data stored securely, and not in my git repository. I currently store my application production configuration there, as well as my SSL certificates. The lines above pull them from the bucket when I'm preparing to deploy, ensuring I have the latest production-ready versions.
Application preparation
With a PHP application, we likely want to wait to do anything until after our files have been moved to their installation directory. Why?
There are two reasons: location, and install quirks.
The first is that hook scripts appear to run with a working directory of
/opt/codedeploy-agent
, and not the deployment directory. When I tested,
composer install
and npm install
both failed, due to being unable to locate
their respective configuration... because they were running under
/opt/codedeploy-agent
.
You can, of course, figure out the current working directory from within bash
with a few hurdles, and I tried that to make things work. However, that unveiled
another issue: CodeDeploy will only move directories that were originally part
of the archive. So, if you run composer install
within a BeforeInstall
script, the vendor/
directory does not get moved to the installation
directory.
As such, for most PHP projects, you'll need to use an AfterInstall script to do your work. Moreover, you'll need to have the script change the working directory to the installation directory.
So, as an example:
###!/bin/bash
#########################################################################
### Application preparation
#########################################################################
(
cd /var/www/example.com ;
# Copy in the production local configuration
cp /var/www/config/php/*.* config/autoload/ ;
# Execute a composer installation
COMPOSER_HOME=/var/cache/composer composer install --quiet --no-ansi --no-dev --no-interaction --no-progress --no-scripts --no-plugins --optimize-autoloader ;
# Execute other scripts as needed ...
# Compile CSS and JS
npm install ;
grunt ;
rm -Rf node_modules ;
)
In the above:
- I copy my production configuration that I synced from S3 into my application.
- I run Composer to install dependencies. Notice that I specify the composer
cache directory I setup in by BeforeInstall script as the
COMPOSER_HOME
! - If I have other deployment/build tasks, I can run those.
- In my case, I'm using grunt to aggregate and minimize CSS and JS assets, so I run that, and then clean-up after myself.
The big thing to note is this construct:
(
cd /var/www/example.com ;
# tasks..
)
Since this runs AfterInstall, I know the destination directory is ready, and I run my deployment operations there. The script itself, however, is still being run from the CodeDeploy agent deployment directory, which is why I need to change directories within my script.
System configuration
Now that the application has been prepared, we can update the system.
Some aspects of web applications that might change from one deployment to the next:
- Crontabs
- SSL configuration
- Web server configuration
- PHP configuration
You likely won't want to update these, however, unless everything else during deployment has succeeded, so we do this last.
Here's my system configuration script, after-install-root.sh
:
###!/bin/bash
#########################################################################
### System preparation following successful application installation.
#########################################################################
SCRIPT_PATH="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
CRONTAB_PATH=/var/spool/cron/crontabs/www-data
### Setup www-data crontab
cp ${SCRIPT_PATH}/crontab ${CRONTAB_PATH} && chown www-data.crontab ${CRONTAB_PATH} && chmod 600 ${CRONTAB_PATH}
### Bring in the SSL configuration and prep it
mv /var/www/config/ssl/*.* /etc/ssl/
(cd /etc/ssl && cat example.com.crt example.com.ca-bundle > example.com.chained.crt)
### Copy nginx configuration
cp ${SCRIPT_PATH}/mwop.net.conf /etc/nginx/sites-available/
if [ ! -e "/etc/nginx/sites-enabled/example.com.conf" ];then
(cd /etc/nginx/sites-enabled && ln -s ../sites-available/example.com.conf .)
fi
### Copy php configuration for php-fpm process
cp ${SCRIPT_PATH}/php.ini /etc/php/7.0/fpm/conf.d/example.com.ini
cp ${SCRIPT_PATH}/php-fpm.conf /etc/php/7.0/fpm/pool.d/www.conf
Again, this script runs in the context of the deployment directory, not the installation destination directory. Further, we need to copy files from it to various locations on the server, as well as from the files we downloaded via S3.
Crontabs have to be owned by the user, and the crontab group, and named after
the user; I'd have loved to have been able to do this via the appspec.yml
files
configuration, but never found a combination that worked; as such, I do
it here in the hook script.
The above example assumes you've put some configuration files in your
.aws/
directory:
php.ini
, with my production PHP settings.php-fpm.conf
, with my production PHP-FPM configuration.example.com.conf
, with my production nginx settings.I don't detail what these files contain, as that will vary quite a bit between applications.
The idea with this AfterInstall script is to ensure that we have appropriate server configuration to execute the current state of our application.
Application start and stop
Remember when I mentioned earlier that all hook script location
entries are
relative to the deployment directory? This is where that information comes in.
When we start deployment, we need to stop any services we may be updating. For a PHP application, this is likely:
- The web server.
- If you're using php-fpm, then php-fpm.
I originally tried this:
hooks:
ApplicationStop:
- location: service nginx stop
timeout: 30
runas: root
- location: service php7.0-fpm stop
timeout: 30
runas: root
However, CodeDeploy was trying to resolve those as something along the lines of
/opt/codedeploy-agent/deployment/{some-uuid}/{deployment-id}/archive/service
.
So, the trick is to do those calls within a hook script you have in your repository. For example:
###!/bin/bash
service nginx stop
service php7.0-fpm stop
Similarly, we want to bring the services back up during ApplicationStart:
###!/bin/bash
service php7.0-fpm start
service nginx start
service cron restart
(I also restart cron after installing the new crontab for www-data.)
Deployment
The first time you deploy, you'll need to do it manually. Assuming you have installed and properly configured the AWS CLI on your own machine, and have setup CodeDeploy, you can do the following:
$ aws deploy create-deployment \
> --application-name {application-name} \
> --deployment-group-name {deployment-group-name} \
> --deployment-config-name CodeDeployDefault.OneAtATime \
> --ignore-application-stop-failures \
> --github-location repository={user or org}/{repo},commitId={sha1}
Fill in all bracketed items with appropriate values.
One thing to note from the original
create-deployment
command: the--ignore--application-stop-failures
flag. This flag is necessary to ensure that deployment can continue if your ApplicationStop script fails. Why would you want this? Well, recall that we use BeforeInstall to setup our system dependencies. On our first execution, or on any execution where we add new services to start and stop, you may have services that do not yet exist. The point of the deployment is to install those! As such, use that flag!
This will give you a JSON payload like the following:
{
"deploymentId": "d-XXXXXXXXXX"
}
You can then check the status using:
$ aws deploy get-deployment --deployment-id {deploymentId} --query "deploymentInfo.[status,creator]"
You can check that periodically, or pass it to the watch
command to determine
the status. If all goes well, you'll see a "status": "Succeeded"
message.
Troubleshooting
If and/or when it fails, you have a couple places you can look.
If you go to the CodeDeploy console on AWS, you can drill down into your application and see the deployments. When a deployment fails, you'll see a link to the deployment ID, which will take you an overview showing the instances to which it attempted to deploy. Each instance has a "View Events" link, which brings you to an overview of the events, and any failed events will have a link to logs.
You can also SSH to your server, and go to
/opt/codedeploy-agent/deployment-root/{some uuid}/
. Do an ls -ltr | tail -n1
to find
the latest deployment ID, and then descend into it. In that directory, you can
then do a less logs/scripts.log
, and usually discover what the error is. (This
was how I discovered the issues with where and how the hook scripts are
executed, as well as the issues with Composer and npm that I ended up working
around.)
Automation
AWS has an official AWS CodeDeploy webhook for GitHub that can be used along with the GitHub Auto-Deployment webhook. Once you have confirmed that you can create successful deployments, you can wire these up.
The AWS blog has an excellent guide to setting up autodeployment; I have nothing I can add to that. I followed the instructions once I had a working deployment, and it all just worked.
Summary
AWS CodeDeploy is quite powerful, and, once you understand its quirks, is a solid approach to deployment; it essentially allows you to create a custom PaaS for your application with "push to deploy", and ensures that each deployment is setup based on the current production requirements.
While this post detailed using a single EC2 node, you can also setup multiple instances under the same policy; when CodeDeploy triggers a deployment, it will only succeed once all nodes have successfully deployed. As such, it even provides a path to horizontal scaling!
I'm really happy with the results, despite the amount of trial-and-error it took to get things working. Hopefully this post will help reduce the amount of time others need to make this powerful tool work for them!