This is the final article in a three-part series on managing LAMP environments with Chef, Vagrant, and EC2. This article covers configuring and deploying PHP web applications.
In this context, configuring an application refers to setting up an Apache virtual host, granting database privileges, writing application config files, etc. Deployment refers to checking out the latest sources from version control, building the application, and installing it on all the nodes. Chef is the perfect tool to configure a web application, and in some cases it can be appropriate for deployment as well.
Articles in this series:
- Part 1: Introduction
- Part 2: Provisioning a LAMP stack
- Part 3: Configuring and deploying a web application (this article)
In this article:
In this tutorial, we’ll configure and deploy an example application called “hello_app”. You can get the code for the hello_app cookbook at Github. All the files in this cookbook are designed to make it easy to re-use them for other applications. You can just do a search and replace on the application name “hello_app” (though you’ll almost certainly want to customize some other aspects of the configuration as well).
Configuring the application
Application configuration is the process of configuring the system to support the application, and writing config files for the application which describe the surrounding system.
Create a new cookbook
Start by creating a new cookbook for the web application.
knife cookbook create hello_app
Edit the README.
Edit cookbooks/hello_app/README.md
and add a short description.
Define metadata.
Edit cookbooks/hello_app/metadata.rb
.
If you configured knife to automatically set the maintainer and license information when setting up Chef and knife, you’ll just need to specify the cookbook dependencies. Our LAMP web application depends on having the mysql and apache2 cookbooks in our Chef repository. Specify the dependencies by adding these lines to metadata.rb:
depends "apache2"
depends "mysql"
Edit the default recipe.
Edit cookbooks/hello_app/recipes/default.rb
. This default recipe was created automatically by knife. If you configured knife properly, the default recipe will contain a nice header with maintainer, copyright, and license information. You can copy/paste this header into the other recipes you’ll create below. Edit this default recipe so that it just loads the app webserver recipe, by adding the following line to cookbooks/hello_app/recipes/default.rb
:
include_recipe "hello_app::webserver"
Define default attributes
Define defaults for the attributes we’ll use in our recipes in cookbooks/hello_app/attributes/default.rb
.
default['hello_app']['db_user'] = 'hello_app'
default['hello_app']['db_name'] = 'hello_app'
default['hello_app']['server_name'] = 'hello_app.example.com'
default['hello_app']['docroot'] = '/srv/hello_app/current'
default['hello_app']['config_dir'] = '/srv/hello_app/shared/config'
These values are fine for this tutorial, though you’ll obviously want to change them when configuring your own applications.
Create a webserver recipe for the application
Most of the cookbook’s work is done by recipes. See Recipes in the Chef documentation for details about how to write them.
The first recipe we’ll create is for configuring the hello_app application on web servers.
Create the recipe file cookbooks/hello_app/recipes/webserver.rb
. You can copy the comment header from the default.rb
recipe; only the “Recipe::” name should need changing.
#
# Cookbook Name:: hello_app
# Recipe:: webserver
#
# Copyright 2012, Jason Grimes
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
app_name = 'hello_app'
app_config = node[app_name]
app_secrets = Chef::EncryptedDataBagItem.load("secrets", app_name)
include_recipe "apache2"
include_recipe "apache2::mod_php5"
# Set up the Apache virtual host
web_app app_name do
server_name app_config['server_name']
docroot app_config['docroot']
#server_aliases [node['fqdn'], node['hostname']]
template "#{app_name}.conf.erb"
log_dir node['apache']['log_dir']
end
# Determine the master database
if node['roles'].include?('db_master')
master_db_host = 'localhost'
else
results = search(:node, "role:db_master AND chef_environment:#{node.chef_environment}")
master_db_host = results[0]['ipaddress']
end
#
# Set up the local application config.
# This part is most likely to be different for different applications.
#
directory "#{app_config['config_dir']}" do
owner "root"
group "root"
mode "0755"
action :create
recursive true
end
template "#{app_config['config_dir']}/local.config.php" do
source "local.config.php.erb"
mode 0440
owner "root"
group node['apache']['group']
variables(
'db_master' => {
'user' => app_config['db_user'],
'pass' => app_secrets[node.chef_environment]['db_pass'],
'dbname' => app_config['db_name'],
'host' => master_db_host,
}
)
end
The hello_app::webserver recipe does the following:
- Ensures the appropriate apache and php recipes are loaded using
include_recipe
. This is where you should specify the Apache modules, PHP extensions, and other packages your application requires. - Creates an Apache virtual host using the web_app resource from the apache2 cookbook.
- Determines the master database by searching the Chef server for nodes in the current environment with the db_master role. (See Search in the Chef manual for details.)
- Creates a config directory (with the directory resource) and writes a PHP config file (with the template resource) with database connection information for the application.
The recipe creates config files from templates. Chef template files use ERB, a Ruby templating system. See the Chef documentation on Templates for details.
Add a template for the Apache virtual host configuration. Create cookbooks/hello_app/templates/default/hello_app.conf.erb
with the following contents:
<VirtualHost *:80>
ServerName <%= @params[:server_name] %>
ServerAlias <% @params[:server_aliases] && @params[:server_aliases].each do |a| %><%= "#{a}" %> <% end %>
DocumentRoot <%= @params[:docroot] %>
<Directory <%= @params[:docroot] %>>
Options FollowSymLinks
AllowOverride All
Order allow,deny
Allow from all
</Directory>
LogLevel info
ErrorLog <%= @node[:apache][:log_dir] %>/<%= @params[:name] %>-error.log
CustomLog <%= @node[:apache][:log_dir] %>/<%= @params[:name] %>-access.log combined
</VirtualHost>
Add a template for local application config (database connection info, etc.). Create cookbooks/hello_app/templates/default/local.config.php.erb
:
<?php
// Database connection config
return array(
'db_master' => array(
<% @db_master.each do |key, value| -%>
'<%= key %>' => '<%= value %>',
<% end -%>
),
);
Create a database recipe for the application
Another recipe is used to configure the master database for the application. It sets up a MySQL user account for the application, and creates the application’s logical database.
Create the recipe file cookbooks/hello_app/recipes/db_master.rb
:
#
# Cookbook Name:: hello_app
# Recipe:: db_master
#
# Copyright 2012, Jason Grimes
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
app_name = 'hello_app'
app_secrets = Chef::EncryptedDataBagItem.load("secrets", app_name)
# Get mysql root password
mysql_secrets = Chef::EncryptedDataBagItem.load("secrets", "mysql")
mysql_root_pass = mysql_secrets[node.chef_environment]['root']
# Create application database
ruby_block "create_#{app_name}_db" do
block do
%x[mysql -uroot -p#{mysql_root_pass} -e "CREATE DATABASE #{node[app_name]['db_name']};"]
end
not_if "mysql -uroot -p#{mysql_root_pass} -e \"SHOW DATABASES LIKE '#{node[app_name]['db_name']}'\" | grep #{node[app_name]['db_name']}";
action :create
end
# Get a list of web servers
webservers = node['roles'].include?('webserver') ? [{'ipaddress' => 'localhost'}] : search(:node, "role:webserver AND chef_environment:#{node.chef_environment}")
# Grant mysql privileges for each web server
webservers.each do |webserver|
ip = webserver['ipaddress']
ruby_block "add_#{ip}_#{app_name}_permissions" do
block do
%x[mysql -u root -p#{mysql_root_pass} -e "GRANT SELECT,INSERT,UPDATE,DELETE \
ON #{node[app_name]['db_name']}.* TO '#{node[app_name][:db_user]}'@'#{ip}' IDENTIFIED BY '#{app_secrets[node.chef_environment]['db_pass']}';"]
end
not_if "mysql -u root -p#{mysql_root_pass} -e \"SELECT user, host FROM mysql.user\" | \
grep #{node[app_name]['db_user']} | grep #{ip}"
action :create
end
end
Create an encrypted data bag with the database passwords for the application:
APP=hello_app
knife data bag create --secret-file ~/.chef/encrypted_data_bag_secret secrets $APP
{
"id": "hello_app",
"prod": {
"db_pass": "awesome-mysql-password-for-hello_app-user"
},
"dev": {
"db_pass": "hello-dev-db-password"
}
}
Store the encrypted data bag in version control.
cd $CHEF_REPO
mkdir -p data_bags/secrets
knife data bag show secrets $APP -Fj > data_bags/secrets/$APP.json
git add data_bags
git commit -m "Add encrypted data bag for $APP secrets."
Commit and upload the application cookbook
Commit to Git:
git add cookbooks
git commit -m 'Add cookbook for hello world LAMP application.'
Upload the cookbook to the Chef server:
knife cookbook upload hello_app
Customize attributes in the dev environment
There will be slight differences in the application’s config on our development VM. This can be handled by specifying “override attributes” in the dev environment, which will be used in place of the default attributes.
Edit environments/dev.rb
and append the following:
override_attributes ({
"hello_app" => {
"server_name" => "hello_app.dev",
"docroot" => "/home/vagrant/apps/hello_app"
}
})
The reason for overriding the web server document root is that we want to store the application source files in our development working directory. In part 2 of this tutorial, we configured /home/vagrant/apps
as a shared directory in our VM, which maps to ~/dev/lamp-vm
on our host development workstation. This lets us edit the source files in our IDE and view the changes immediately on the VM web server, without having to deploy them.
Commit the environment changes to version control:
git add environments
git commit -m 'Override hello_app attributes in dev environment.'
Upload the environment definition to the Chef server:
knife environment from file dev.rb
Add to Chef roles
Edit roles/webserver.rb
and add the hello_app::webserver
recipe to the webserver role. If you want it to run in all environments, add the recipe to the all_env
array we created when setting up roles in Part 2 of this tutorial:
all_env = [
# ...,
"recipe[hello_app::webserver]",
]
If you only want it to run in the dev environment, merge it with the all_env
array directly in the env_run_list
command, like this:
env_run_lists(
"_default" => all_env,
"prod" => all_env,
"dev" => all_env + ["recipe[hello_app::webserver]"],
)
Edit roles/db_master.rb
and add the hello_app::db_master
recipe to the db_master
role (either in the all_env
array, or directly in the env_run_list
arguments, just like the webserver recipe):
all_env = [
"role[base]",
"recipe[mysql::server]",
"recipe[hello_app::db_master]"
]
Commit roles to Git:
git add roles
git commit -m 'Add hello_app recipes to webserver and db_master roles.'
Upload roles to Chef server:
knife role from file webserver.rb
knife role from file db_master.rb
Provision and test
SSH into both the Vagrant and EC2 instances and run the following command to configure the new application:
sudo chef-client
Then add the dev and production hostnames to /etc/hosts
:
localhost hello_app.dev
1.2.3.4 hello_app.example.com # Use your public EC2 IP address instead of 1.2.3.4
When you’re ready to release it publicly, you’ll need to add a DNS record for your production hostname instead of using your hosts file. You should also make sure you’ve manually allocated an elastic IP, instead of using the temporary public IP that is automatically assigned to new instances.
To test it out, copy a test file to your document root and then load it in your browser. You can use the test-config.php file from my sample hello_app repository.
To get the test file into your development VM:
mkdir -p /home/vagrant/apps/hello_app
cd /home/vagrant/apps/hello_app
ln -s /srv/hello_app/shared/config ./config # Or, on a Windows host, copy the directory over instead of symlinking.
wget https://raw.github.com/jasongrimes/hello_app/master/test-config.php
Then load http://hello_app.dev:8080/test-config.php in your browser. You should see an OK message.
In your EC2 instance:
sudo mkdir /srv/hello_app/test
sudo ln -s /srv/hello_app/test /srv/hello_app/current
cd /srv/hello_app/current
sudo ln -s /srv/hello_app/shared/config /srv/hello_app/current/config
cd /srv/hello_app/current
sudo wget https://raw.github.com/jasongrimes/hello_app/master/test-config.php
…and load http://hello_app.example.com/test-config.php in your browser.
Deploying the application
Deployment is the process of getting the latest version of an application installed on all the nodes.
Should you use Chef to deploy your web application?
Application deployment is a different animal from application configuration. To configure a LAMP application, you have to know something about the surrounding systems–what’s the database password, where’s the memcache server, what’s the hostname. This is the sort of thing that a configuration management tool like Chef is perfect for.
But in my view, web application deployment belongs more in the realm of day-to-day development. While configuration changes infrequently, deployment is the sort of thing a developer should be able to do as often as possible.
There are also cases in which deployment should not be entirely automatic–for example, if a database schema change will lock tables for a long time, it may need to be done manually, taking systems offline one at a time, to avoid prolonged downtime.
In larger or more complex projects, I think a separate deployment toolchain is appropriate (ex. Capistrano, Fabric, or hand-rolled scripts run through a build tool like Phing).
But in simple cases, or when just getting started with a project, it can be easiest to just deploy the application with Chef. The rest of this tutorial shows one way to do that.
Overview of Chef’s deploy resource
Chef’s deploy resource can be used to deploy web applications. It’s a port of the popular Ruby deployment tool Capistrano.
The deployment process happens in four phases:
- checkout: Check out the source code from version control into a versioned directory.
- migrate: Run database “migrations” (i.e. schema changes). This step just executes whatever migration tool you use in your build process (ex. dbdeploy or Doctrine migrations). Setting up a migration tool is beyond the scope of this tutorial, so we’ll skip the migration step for now and assume you update the database schema separately before deploying the application.
- symlink: Symlink the checked out sources as the “current” version, and add symlinks to any shared configuration files. (Using symlinks in this way makes it quick to switch to the new version, and to roll back if needed.)
- restart: Restart any necessary services (like Apache).
Hooks are available to execute custom code as callbacks in between each phase: before_migrate
, before_symlink
, before_restart
, and after_restart
. You also specify custom migrate and restart commands.
See the deploy discussion in the Chef documentation for details about the deployment process.
Create a deploy recipe
We’ll create a dedicated recipe for deploying the hello_app application. Create the following file in cookbooks/hello_app/recipes/deploy.rb
:
#
# Cookbook Name:: hello_app
# Recipe:: deploy
#
# Copyright 2012, Jason Grimes
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
include_recipe "hello_app::webserver"
deploy_revision node['hello_app']['deploy_dir'] do
scm_provider Chef::Provider::Git
repo node['hello_app']['deploy_repo']
revision node['hello_app']['deploy_branch']
enable_submodules true
shallow_clone false
symlink_before_migrate({}) # Symlinks to add before running db migrations
purge_before_symlink [] # Directories to delete before adding symlinks
create_dirs_before_symlink ["config"] # Directories to create before adding symlinks
symlinks({"config/local.config.php" => "config/local.config.php"})
# migrate true
# migration_command "php app/console doctrine:migrations:migrate"
action :deploy
restart_command do
service "apache2" do action :restart; end
end
end
Add attributes for deployment to cookbooks/hello_app/attributes/default.rb
:
default['hello_app']['deploy_repo'] = 'git://github.com/jasongrimes/hello_app'
default['hello_app']['deploy_branch'] = 'HEAD'
default['hello_app']['deploy_dir'] = '/srv/hello_app'
This will cause Chef to check out the master branch from my public hello_app repository at Github into a directory under /srv/hello_app/releases/{id}
, and then create a /srv/hello_app/current
symlink pointing to it.
You can go ahead and use my hello_app repo for this tutorial, or you can point this to one of your own Github repositories.
If you don’t want to deploy from the master branch, another approach is to set “deploy_branch” to something like “production”, so only code that is merged into the production branch will be deployed.
Note that this deploys from a public Github repository. To deploy from a private repository instead, read the next section. If your application is stored in a public repo, you can skip ahead to commit and upload the deploy recipe.
Detour: deploying from a private Github repository
Deploying from a private repository is a little more complicated. You’ll need to set up an SSH keypair, and access Git over SSH.
Create an SSH keypair for use as a Git “deploy key”:
cd ~/.ssh
KEYNAME=hello_app_deploy_key
ssh-keygen -f"$KEYNAME" -N ''
Go to Github and add a deploy key to your repository. Use the public key from hello_app_deploy_key.pub
.
Next we need to add the private key to the encrypted data bag for the application secrets. Because data bags are in JSON format, the private key must be stored with all newlines replaced by the string “\n”.
On Mac OS X, you can use the following command to translate newlines from the private key and copy it to the clipboard. (If you’re not using a Mac, omit the |pbcopy
part, and manually copy/paste the key.)
tr "\n" "#" < $KEYNAME | sed 's/#/\\n/g' | pbcopy
Edit the encrypted data bag for the application secrets:
APP=hello_app
knife data bag edit secrets $APP --secret-file ~/.chef/encrypted_data_bag_secret
Add a deploy_key
property at the end, with the newline-translated private key
{
"id": "hello_app",
"prod": {
"db_pass": "..."
},
"dev": {
"db_pass": "..."
},
"deploy_key": "-----BEGIN RSA PRIVATE KEY-----\nMIIEowIBAAKCAQEAsv1rrIIvTz5Msv6uwTo....PdLSnWyoxTvjbyEdPizfY\n-----END RSA PRIVATE KEY-----\n"
}
Store the encrypted data bag item in version control:
knife data bag show secrets $APP -Fj > data_bags/secrets/$APP.json
git add data_bags
git commit -m "Add deploy_key to $APP encrypted data bag secrets."
Add a Git SSH wrapper template: cookbooks/hello_app/templates/default/git-ssh-wrapper.erb
:
#!/usr/bin/env bash
#
# SSH wrapper for deploying from private Github repository
#
# Rendered by Chef - local changes will be replaced
/usr/bin/env ssh -o "StrictHostKeyChecking=no" -i "<%= @deploy_dir %>/id_deploy" $1 $2
Modify the deploy recipe to access Git via SSH using the deploy key. It needs to get the private key into a protected file, and set up the SSH wrapper for Git. You also need to specify the git_ssh_wrapper
in the deploy block. The revised cookbooks/hello_app/recipes/deploy.rb
is shown below:
#
# Cookbook Name:: hello_app
# Recipe:: deploy
#
# Copyright 2012, Jason Grimes
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
include_recipe "hello_app::webserver"
# Handle ssh key for git private repo
secrets = Chef::EncryptedDataBagItem.load("secrets", "hello_app")
if secrets["deploy_key"]
ruby_block "write_key" do
block do
f = ::File.open("#{node['hello_app']['deploy_dir']}/id_deploy", "w")
f.print(secrets["deploy_key"])
f.close
end
not_if do ::File.exists?("#{node['hello_app']['deploy_dir']}/id_deploy"); end
end
file "#{node['hello_app']['deploy_dir']}/id_deploy" do
mode '0600'
end
template "#{node['hello_app']['deploy_dir']}/git-ssh-wrapper" do
source "git-ssh-wrapper.erb"
mode "0755"
variables("deploy_dir" => node['hello_app']['deploy_dir'])
end
end
deploy_revision node['hello_app']['deploy_dir'] do
scm_provider Chef::Provider::Git
repo node['hello_app']['deploy_repo']
revision node['hello_app']['deploy_branch']
if secrets["deploy_key"]
git_ssh_wrapper "#{node['hello_app']['deploy_dir']}/git-ssh-wrapper" # For private Git repos
end
enable_submodules true
shallow_clone false
symlink_before_migrate({}) # Symlinks to add before running db migrations
purge_before_symlink [] # Directories to delete before adding symlinks
create_dirs_before_symlink ["config"] # Directories to create before adding symlinks
symlinks({"config/local.config.php" => "config/local.config.php"})
# migrate true
# migration_command "php app/console doctrine:migrations:migrate"
action :deploy
restart_command do
service "apache2" do action :restart; end
end
end
Edit cookbooks/hello_app/attributes/default.rb
and set the deploy_repo
to the SSH path format:
default['hello_app']['deploy_repo'] = 'git@github.com:jasongrimes/hello_app' # Format for private repos
default['hello_app']['deploy_branch'] = 'HEAD'
default['hello_app']['deploy_dir'] = '/srv/hello_app'
Commit and upload the deploy recipe
Commit the deploy recipe to version control, and upload the cookbook to the Chef server.
APP=hello_app
git add cookbooks/$APP
git commit -m "Add deploy recipe to $APP cookbook."
knife cookbook upload $APP
Add the deploy recipe to the webserver role
Edit roles/webserver.rb
. Add the hello_app::deploy recipe to the webserver role, in the same way that you added the hello_app::webserver recipe to the webserver role.
I don’t usually run the deploy recipe on my development VM, because the application is running directly from my dev working directory. So I add the recipe only in the prod environment in roles/webserver.rb
:
env_run_lists(
"_default" => all_env,
"prod" => all_env + ["recipe[hello_app::deploy]"],
"dev" => all_env,
)
Note that this requires the db_master role to be specified before the webserver role when setting up the node, because the deploy recipe relies on database credentials set up by the db_master role. This should have been handled during provisioning, as described in part 2 of this tutorial.
Commit the role to version control:
git add roles
git commit -m 'Add hello_app::deploy recipe to webserver role.'
Upload the role to the Chef server:
knife role from file webserver.rb
Test the deployment
SSH into your EC2 instance and run sudo chef-client
. This should cause the application to be deployed. Watch for any errors as chef-client runs.
- Load the site in your browser. If you left the repository set to my hello_app repo at Github, the index page should say “Hello application!” if everything is working.
- If you didn’t use your own repository already, edit
cookbooks/hello_app/attributes/default.rb
and change the repository to one of your own. - Commit a test file to your repository.
- SSH into a node and run
sudo chef-client
- Load the test file in your web browser.
Closing remarks
You should now have a basic LAMP stack and a sample web application provisioned in a development VM and on a shared server in EC2. And more importantly, you should be able to deploy new configurations and provision new environments pretty easily.
We had to front-load a lot of work to get to this point, but now that it’s done it will hopefully save you a lot of time in the future. From here on out, chances are you’ll mostly be adding new applications, and tweaking the configuration of your existing environments.
If you got this far, I’d love to hear about how it works out for you. Any feedback about how this tutorial could be improved is always appreciated. Thanks!