Hosting Rails apps on a Mac OS X server
Posted (updated )
There are many guides for setting up Rails development environments on various platforms including Mac OS and Ubuntu. I thought I'd mix it up a little with my complete guide on setting up a production Mac OS server.
Update 2011-02-12: Added a note to the backups section on excluding large changing files (such as databases) from Time Machine backups.
Update 2011-03-26 All launchd configuration files for services should be placed in LaunchDaemons not LaunchAgents to run at startup as the correct user account. LaunchAgents are for interactive processes ran under as the logged in console user.
Update 2012-01-04 Added load average and file system monitoring to the example Monit configuration.
Contents
- Xcode
- Homebrew
- Sysadmin tweaks
- Ruby 1.9.2 via system-wide RVM
- Apache & Passenger
- User accounts
- PostgreSQL
- Memcached
- ImageMagick
- Tomcat
- Git Hosting
- SSH on an alternate port
- Monit
- Backups
- Applications
Xcode
Download and install Xcode if you haven't already. It provides useful tools like a C compiler. Pretty much nothing is going to work without one.
Homebrew
Homebrew is an awesome package manager for Mac OS. It's superior to MacPorts in many ways. If you're setting up a new machine, there's no decision to be made. If you're already running MacPorts it's still seriously worth switching over.
You can follow the Homebrew installation instructions on the Homebrew wiki or run the steps below to use my formulas:
ruby -e "$(curl -fsSL https://gist.github.com/raw/323731/install_homebrew.rb)"brew install gitgit clone http://github.com/jasoncodes/homebrew.git /tmp/homebrew # substitute your preferred forkmv /tmp/homebrew/.git /usr/local/rm -rf /tmp/homebrewcd /usr/local/git remote add mxcl http://github.com/mxcl/homebrew.gitgit fetch --all
Sysadmin tweaks
GNU command-line utilities
Installing a few GNU utilities makes Mac OS's BSD userland feel more like home. You can skip this step if you're happy with the BSD variants and your apps don't need GNU flavour.
brew install coreutils gnu-sed gawk findutils --default-names
Note: Installing these tools with --default-names will make the GNU variants the default and could possibly cause issues with any scripts that expect BSD versions. The GNU versions generally accept all the options as the BSD versions but there are a few differences. For example: BSD sed uses -E for extended mode and GNU sed uses -r and --regexp-extended. For compatibility with the BSD version of sed I use /usr/bin/sed in this guide.
dot files
I have customized my environment quite a bit and it can be frustrating to use a machine without my settings. As such, I like to install on every machine I use. Everything in this post should work on a bare config (and hopefully under your config as well). Here's what I run to setup my shell config:
curl -sL http://github.com/jasoncodes/dotfiles/raw/master/install.sh | bashexec bash -i # reload the shell
htop
htop is a top alternative which makes interactive use much easier. It primarily targets Linux but the basic functions work fine on Mac OS. It's far nicer to use than the version of top which comes with Mac OS.
brew install htop
You'll need to sudo htop when running htop in order to see all process information. I also like to enable the Highlight program "basename" setting.
Ruby 1.9.2 via system-wide RVM
Install RVM
sudo bash < <(curl -s https://rvm.beginrescueend.com/install/rvm)echo -e "[[ -s '/usr/local/rvm/scripts/rvm' ]] && source '/usr/local/rvm/scripts/rvm'\n" | sudo bash -c 'cat - /etc/bashrc > /etc/bashrc.new && mv /etc/bashrc{.new,}' # add RVM to global shell configsource '/usr/local/rvm/scripts/rvm' # load RVM in current session
We prepend the RVM loader to /etc/bashrc so it runs on non-interactive shells such as cron. This in combination with /bin/bash -l -c (which is automatically provided by the whenever gem), we can have the RVM provided Ruby 1.9.2 available in cron jobs.
Install Ruby 1.9.2 and set it as default
sudo rvm pkg install readlinebrew install libyamlsudo rvm install 1.9.2 --with-readline-dir=$rvm_path/usr --with-libyaml-dir=/usr/localsudo rvm --default 1.9.2rvm default
Update 2011-04-29: These instructions originally installed libyaml for the Psych YAML parser on Ruby 1.9.2.
Unfortunately Ruby 1.9.2 up to and including p180 has an issue with Psych where by it fails with merge keys.
This can cause problems with certain versions of Bundler and RubyGems, as well as DRY database.yml files.
The issue is fixed in Ruby HEAD
and there's a now somewhat stale ticket open to backport to 1.9.2.
Hopefully we see a fix in the next Ruby 1.9.2 patch release.
Update 2011-07-16: Ruby 1.9.2 p290 was released today and includes the Psych fix for YAML merge keys.
As a result, I have re-added libyaml to the Ruby installation instructions.
If you're upgrading you'll need to update RVM with rvm get head && rvm reload first.
To migrate a gemset over to the latest Ruby, run rvm gemset copy 1.9.2-p{180,290}@example && rvm 1.9.2-p180 gemset delete example.
Lockdown RVM installation
By default RVM will give all users access to modify RVM rubies, gemsets, et al.
This is problem as the RVM install is system wide and users should not be able to mess with the environment of others.
Luckily, all we need to do is empty out the rvm group which will leave root as the only user allowed to administer RVM:
sudo dscl . delete /Groups/rvm GroupMembership
Fix Homebrew permissions broken by installing RVM system-wide
After installing RVM system-wide you may find /usr/local/lib and /usr/local/bin to be locked down. We can liberate them again without reinstalling Homebrew by coping the owner and permissions from another directory (such as /usr/local/share/man) which is unaffected by the installation of RVM.
sudo chmod -R --reference=/usr/local/lib /usr/local/bin /usr/local/share/mansudo chown -R --reference=/usr/local/lib /usr/local/bin /usr/local/share/man
Install Bundler
We'll be using Bundler's deployment mode via Capistrano to install and manage gems. This keeps our system gems clean and isolates apps from each other.
sudo gem install bundler
Apache & Passenger
Apache 2 comes standard with Mac OS 10.6. Sprinkle Phusion Passenger on top and we have an app server for Rack apps (including Rails).
sudo gem install passengerrvmsudo passenger-install-apache2-module
Copy the 3 configuration lines emitted after installation into /etc/apache2/other/passenger.conf:
LoadModule passenger_module /usr/local/rvm/gems/ruby-1.9.2-p136/gems/passenger-3.0.2/ext/apache2/mod_passenger.soPassengerRoot /usr/local/rvm/gems/ruby-1.9.2-p136/gems/passenger-3.0.2PassengerRuby /usr/local/rvm/wrappers/ruby-1.9.2-p136/ruby
There are a number of configuration options you can set in passenger.conf to control how it manages worker instances. You can read about these in the Passenger users guide. Here's the settings I'm using:
RailsSpawnMethod smart-lv2RailsFrameworkSpawnerIdleTime 0RailsAppSpawnerIdleTime 0PassengerUseGlobalQueue onPassengerFriendlyErrorPages off# recycle instances every so often to keep any leaks under controlPassengerMaxRequests 1000# default 6PassengerMaxPoolSize 16# default 0PassengerMaxInstancesPerApp 5# keep at least one instance around per app, let the others time out after 2 minutesPassengerMinInstances 1PassengerPoolIdleTime 120
For the configuration changes to take affect you need to restart Apache by running the following:
sudo launchctl unload -w /System/Library/LaunchDaemons/org.apache.httpd.plistsudo launchctl load -w /System/Library/LaunchDaemons/org.apache.httpd.plist
Once restarted you should see Passenger in the server signature:
$ curl -sI localhost | grep ^ServerServer: Apache/2.2.15 (Unix) mod_ssl/2.2.15 OpenSSL/0.9.8l DAV/2 Phusion_Passenger/3.0.2
HTTP Compression
mod_deflate is loaded by default but it's not configured to compress any responses automatically. Save the following as /etc/apache2/other/deflate.conf to enable HTTP compression for HTML, CSS, Javascript and fonts:
AddOutputFilterByType DEFLATE text/html text/plain text/xml font/ttf font/otf application/vnd.ms-fontobject text/css application/javascript application/atom+xml
Virtual Hosts
It's useful to have a default virtual host to catch any hits that goto any undefined hostnames or direct IP requests.
Here's a basic vhost which will act as the default. The key thing to note here is the zeros in the filename which causes it to sort before the other vhost files and Apache's Include to load it first.
/etc/apache2/other/vhosts-000default.conf:
NameVirtualHost *:80<VirtualHost *:80>ServerName _default_<Directory /Library/WebServer/Documents>AllowOverride All</Directory></VirtualHost>
Let's use something a bit better than "It works!" as our default page.
/Library/WebServer/Documents/index.html:
<!DOCTYPE html><html><head><title></title><meta http-equiv="Content-Type" content="text/html; charset=utf-8" /></head><body><p>Move along, nothing to see here…</p></body></html>
We don't want it to be cacheable just in case we screw up and end up serving it instead of a vhost.
/Library/WebServer/Documents/.htaccess:
# Expire default page immediatelyExpiresActive OnExpiresByType text/html "access"
And finally here's my template for all vhosts which we'll be using a bit later:
/etc/apache2/other/vhosts-example.conf.template:
<VirtualHost *:80>ServerName example.comServerAlias www.example.com# no-wwwRewriteEngine OnRewriteCond %{HTTP_HOST} ^www\.(.*)$ [NC]RewriteRule ^(.*)$ http://%1$1 [R=301,L]ServerAdmin webmaster@example.comDocumentRoot /Users/example/apps/example/production/current/public/LogLevel warnCustomLog /var/log/apache2/example-production-access.log "%h %l %u %t \"%r\" %>s %b \"%{Referer}i\" \"%{User-agent}i\" \"%{Host}i\" %D"ErrorLog /var/log/apache2/example-production-error.log<Directory />AllowOverride FileInfo Indexes OptionsOptions -Indexes FollowSymLinks -MultiViewsOrder allow,denyAllow from all</Directory></VirtualHost>
To check the configuration syntax and then reload Apache so new vhosts are available, run the following:
sudo apachectl configtest && sudo apachectl graceful
apache2ctl errors
If apachectl outputs an error like /usr/sbin/apachectl: line 82: ulimit: open files: cannot modify limit: Invalid argument, you've ran into a Mac OS 10.6.6 regression. You can patch apachectl by running the following:
sudo /usr/bin/sed -E -i bak 's/^(ULIMIT_MAX_FILES=".*)`ulimit -H -n`(")$/\11024\2/' /usr/sbin/apachectl
Passenger Preference Pane
A quick note on Passenger Preference Pane: I don't recommend you install it. It can be handy in development environments with Passenger for quickly spinning up a new vhost for an app. It does not however allow you to customise vhost settings like logging nor setup a default vhost.
It's a much better idea to create a template and then script the deployment of new applications in production environments. There's more to deploying an app than just creating a new vhost. We also need to create user accounts, databases, configure logging, etc.
Log rotation
Mac OS uses newsyslog to rotate the main log files such as system.log and mail.log. It does not however automatically rotate anything in /var/log/apache2.
We could point newsyslog at each log file we want to rotate but logrotate lets us use wildcards.
brew install logrotatesudo mkdir -p /etc/logrotate.dsudo bash -c 'cat > /etc/logrotate.conf' <<EOFcompresscmd $(which gzip)tabooext + templateinclude /etc/logrotate.dEOF
Set your Apache log rotate settings. I prefer to rotate my logs weekly and keep 520 rotations (10 years). Disk space is cheap and old logs might be useful. Save the following config as /etc/logrotate.d/apache.conf:
/var/log/apache2/access_log /var/log/apache2/error_log /var/log/apache2/*.log {weeklymissingokrotate 520compressdelaycompressnotifemptycreate 640 root wheelsharedscriptspostrotateapachectl gracefulendscript}
Since we're going to be reloading Apache after rotating the logs, we can use this opportunity to rotate production.log from our Rails apps. These logs are larger so I only keep 10 rotations. Save the following template as /etc/logrotate.d/vhosts-example.conf.template:
/Users/example/apps/example/production/shared/log/production.log {weeklymissingokrotate 10compressdelaycompressnotifemptycreate 640 example staffsharedscripts}
Setup logrotate to run automatically via launchd. Debian runs logrotate daily at 06:25 which sounds fine by me. See man 5 launchd.plist for details on the StartCalendarInterval option. Save the following as /Library/LaunchDaemons/logrotate.plist:
<?xml version="1.0" encoding="UTF-8"?><!DOCTYPE plist PUBLIC "-//Apple Computer//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"><plist version="1.0"><dict><key>Label</key><string>logrotate</string><key>ProgramArguments</key><array><string>/usr/local/sbin/logrotate</string><string>/etc/logrotate.conf</string></array><key>Disabled</key><false/><key>RunAtLoad</key><false/><key>StartCalendarInterval</key><dict><key>Hour</key><integer>6</integer><key>Minute</key><integer>25</integer></dict></dict></plist>
And finally run the following to force an initial test rotation and then activate the launchd schedule:
sudo /usr/local/sbin/logrotate -f /etc/logrotate.confsudo launchctl load -w /Library/LaunchDaemons/logrotate.plist
User accounts
We want to isolate our services (PostgreSQL, Memcached, etc) and applications in their own user accounts.
Unfortunately Mac OS doesn't provide a nice and simple adduser like command but we can make our own. Save the following as /usr/local/bin/adduser and run chmod +x /usr/local/bin/adduser to make it executable:
#!/bin/bash -eNEW_USERNAME="$1"if [ -z "$NEW_USERNAME" ]thenecho "Usage: $(basename "$0") [username]" >&2exit 1fiif id "$NEW_USERNAME" 2> /dev/nullthenecho "$(basename "$0"): User \"$NEW_USERNAME\" already exists." >&2exit 1fiNEW_UID=$(( $(dscl . -list /Users UniqueID | awk '{print $2}' | sort -n | tail -1) + 1 ))if ! [ $NEW_UID -gt 500 ]thenecho "$(basename "$0"): Could not determine new UID." >&2exit 1fidscl . create "/Users/$NEW_USERNAME"dscl . create "/Users/$NEW_USERNAME" UniqueID $NEW_UIDdscl . create "/Users/$NEW_USERNAME" PrimaryGroupID 20dscl . delete "/Users/$NEW_USERNAME" AuthenticationAuthoritydscl . create "/Users/$NEW_USERNAME" Password '*'dscl . create "/Users/$NEW_USERNAME" UserShell /bin/bashdscl . create "/Users/$NEW_USERNAME" NFSHomeDirectory "/Users/$NEW_USERNAME"createhomedir -c -u "$NEW_USERNAME"
Now we can simply run sudo adduser foo to create a new account which will not show on the login screen.
Removing a user account later is much easier than creating one. Just run the following:
sudo dscl . delete /Users/foosudo rm -rf /Users/foo
Sometimes cron jobs fail. Wouldn't it be nice to hear about it? It's fairly easy to setup postfix to send mail via an external server. You could use Gmail, SendGrid or even your own mail server.
Configure postfix to forward mail to smtp.example.com with SSL and authentication. Replace both instances of smtp.example.com with your SMTP server's hostname and username:password with the username and password for your SMTP account.
sudo bash -c 'umask 0077 > /dev/null && echo "smtp.example.com username:password" >> /etc/postfix/smtp_auth'sudo postmap hash:/etc/postfix/smtp_authsudo postconf -e relayhost=smtp.example.com:submission smtp_use_tls=yes smtp_sasl_auth_enable=yes smtp_sasl_password_maps=hash:/etc/postfix/smtp_auth tls_random_source=dev:/dev/urandom smtp_sasl_security_options=noanonymous
Forward root's mail to an external account. Replace me@example.com with your email address.
sudo cp -ai /etc/postfix/aliases{,.bak} # backup the original aliases filesudo /usr/bin/sed -i '' 's/^#root.*/root: me@example.com/' /etc/postfix/aliasesgrep ^root /etc/aliases # check the replacement workedsudo postalias /etc/aliases
Set postfix to listen on localhost only. There's no need to give spam zombies the time of day.
sudo postconf -e inet_interfaces=localhost
By default postfix will only start when there's mail in the local queue (typically from calls to sendmail). We'll use our own launchd config which will run postfix all the time. Save the following as /Library/LaunchDaemons/org.postfix.master.plist:
<?xml version="1.0" encoding="UTF-8"?><!DOCTYPE plist PUBLIC "-//Apple Computer//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"><plist version="1.0"><dict><key>Label</key><string>org.postfix.master</string><key>Program</key><string>/usr/libexec/postfix/master</string><key>ProgramArguments</key><array><string>master</string></array><key>AbandonProcessGroup</key><true/><key>RunAtLoad</key><true /><key>KeepAlive</key><true /></dict></plist>
Start the postfix daemon and check port 25 is now open:
nc -4zv localhost 25 # should errorsudo launchctl unload -w /System/Library/LaunchDaemons/org.postfix.master.plist # stop built in on demand daemonsudo launchctl load -w /Library/LaunchDaemons/org.postfix.master.plist # load our always running daemonnc -4zv localhost 25 # should succeed
Forward your email to root (which will forward to your designated email address):
echo root > ~/.forward
Send yourself a test email:
date | mail -s "Test from $(hostname -s)" $USER
PostgreSQL
brew install postgresql
The instructions included in the caveats blurb from Homebrew (which after you run brew install postgresql) are great for setting up a single user development install. However, we want to run a system-wide instance to be used by all our applications. Run the following instead to setup PostgreSQL with ident authentication under the postgres account.
# create user accountsudo adduser postgres# initialize the database clustersudo -u postgres initdb -A ident /usr/local/var/postgres# set PostgreSQL to run at startupsudo cp /usr/local/Cellar/postgresql/9.0.1/org.postgresql.postgres.plist /Library/LaunchDaemons/sudo defaults write /Library/LaunchDaemons/org.postgresql.postgres UserName postgressudo plutil -convert xml1 /Library/LaunchDaemons/org.postgresql.postgres.plistsudo chmod 644 /Library/LaunchDaemons/org.postgresql.postgres.plist# start the serversudo launchctl load -w /Library/LaunchDaemons/org.postgresql.postgres.plist
Add yourself as a superuser on the cluster so you can manage it without sudo -u postgres:
sudo -u postgres createuser -s $USERcreatedb
The default PostgreSQL memory settings are very conservative. On a production machine you'll want to adjust these to suit your workload. Run sudo cp -ai /usr/local/var/postgres/postgresql.conf{,.org} before editing the config so you have a pristine copy of the config file to reference later.
Below are my current settings for my server which will give you an idea of what settings to play with. See Resource Consumption in the PostgreSQL docs for what each setting means. I recommend you start small and nothing beats trial, error and lots of testing. Don't forget to benchmark with production queries against production data.
shared_buffers = 256MBwork_mem = 64MBmaintenance_work_mem = 128MBeffective_cache_size = 512MBmax_connections = 50
If you're adjusting the shared_buffers setting you will probably run into the following error (in /var/log/messages):
FATAL: could not create shared memory segment: Invalid argumentDETAIL: Failed system call was shmget(key=5432001, size=276275200, 03600).HINT: This error usually means that PostgreSQL's request for a shared memory segment exceeded your kernel's SHMMAX parameter. You can either reduce the request size or reconfigure the kernel with larger SHMMAX. To reduce the request size (currently 276275200 bytes), reduce PostgreSQL's shared_buffers parameter (currently 32768) and/or its max_connections parameter (currently 24).If the request size is already small, it's possible that it is less than your kernel's SHMMIN parameter, in which case raising the request size or reconfiguring SHMMIN is called for.The PostgreSQL documentation contains more information about shared memory configuration.
The fix is easy. First make sure the quoted request size sounds sane (263 MB in the above error) and then update SHMMAX to be at least as large. I generally to round up to the next power of 2. SHMALL should generally be set to ceil(SHMMAX/PAGE_SIZE) where PAGE_SIZE is 4096 bytes. I'm setting SHMMAX to 512 MB:
sudo sysctl -w kern.sysv.shmmax=$((1048576 * 512))sudo sysctl -w kern.sysv.shmall=$((1048576 / 4096 * 512))sysctl -a | egrep '^kern.sysv.shm(max|all)' | /usr/bin/sed 's/: /=/' | sudo tee -a /etc/sysctl.conf
To restart the server run the following:
sudo launchctl unload -w /Library/LaunchDaemons/org.postgresql.postgres.plistsudo launchctl load -w /Library/LaunchDaemons/org.postgresql.postgres.plist
Finally type psql and you should drop straight into a PostgreSQL prompt.
Granting permissions
Sometimes you may want to give full privileges on a database to a non-superuser account which is not the database owner. For example: you may want to share a database between two apps or let developers play around with data on a staging instance.
To grant full access to a database to a non-superuser you can use the following in a superuser psql prompt:
GRANT ALL ON DATABASE $database TO $user;\c $databaseALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON TABLES TO $user;GRANT ALL ON ALL TABLES IN SCHEMA public TO $user;
Memcached
# install memcachedbrew install memcached# configure to run at startupsudo adduser memcachesudo cp /usr/local/Cellar/memcached/1.4.5/com.danga.memcached.plist /Library/LaunchDaemons/sudo defaults write /Library/LaunchDaemons/com.danga.memcached UserName memcachesudo plutil -convert xml1 /Library/LaunchDaemons/com.danga.memcached.plistsudo chmod 644 /Library/LaunchDaemons/com.danga.memcached.plist# start the servicesudo launchctl load -w /Library/LaunchDaemons/com.danga.memcached.plist
Memcached defaults to a maximum cache size of 64 MB. You can increase this if needed with by adding -m, 128 to ProgramArguments in the launchd plist and restarting the service.
Running echo stats | nc localhost 11211 should give you memcached stats.
Sharing the cache with multiple applications & security issues
If you're configuring multiple applications to use it, make sure you namespace your keys with something like:
config.cache_store = :mem_cache_store, { :namespace => Rails.application.config.database_configuration[Rails.env]['database'] }
If you have untrusted users/applications, you'll probably want to setup multiple instances with SASL authentication.
ImageMagick
If your applications resize images with RMagick, you're going to need the ImageMagick libraries. Installing with --disable-openmp fixes some random crashing issues I was having.
brew install imagemagick --disable-openmp
Tomcat
To run Solr one needs a servlet container. Tomcat is a safe bet here. I recommend the excellent Sunspot library for using Solr in Rails.
Installing
Install Tomcat via Homebrew and then unlink it. Tomcat comes with a number of scripts which have generic names (startup.sh, version.sh, etc). We don't want those in our PATH.
brew install tomcatbrew unlink tomcat
Setup Tomcat to run in its own path (/usr/local/tomcat) under its own username (tomcat):
sudo adduser tomcatsudo mkdir /usr/local/tomcatsudo chown tomcat /usr/local/tomcatsudo -u tomcat ln -s $(brew --prefix tomcat)/libexec /usr/local/tomcat/sudo -u tomcat ln -s libexec/{bin,lib} /usr/local/tomcatsudo -u tomcat mkdir /usr/local/tomcat/{logs,temp,webapps,work}sudo rsync --archive --no-perms --chmod='ugo=rwX' /usr/local/tomcat/{libexec/conf/,conf}sudo find /usr/local/tomcat/conf -exec chown tomcat:staff {} \;sudo -u tomcat bash -c 'echo "org.apache.solr.level = WARNING" >> /usr/local/tomcat/conf/logging.properties'
Configure connectors
I recommend editing /usr/local/tomcat/conf/server.xml and replacing all <Connector /> entries with a single HTTP connector for localhost:8080:
<Connector address="127.0.0.1" port="8080" protocol="HTTP/1.1" connectionTimeout="20000" />
Run at startup
Save the following as /Library/LaunchDaemons/org.apache.tomcat.plist to have launchd start Tomcat automatically:
Note: Adjust -Xmx2048M to control how much memory Tomcat can use.
<?xml version="1.0" encoding="UTF-8"?><!DOCTYPE plist PUBLIC "-//Apple Computer//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"><plist version="1.0"><dict><key>Label</key><string>org.apache.tomcat</string><key>ProgramArguments</key><array><string>/usr/local/tomcat/bin/catalina.sh</string><string>run</string></array><key>UserName</key><string>tomcat</string><key>EnvironmentVariables</key><dict><key>CATALINA_HOME</key><string>/usr/local/tomcat</string><key>JAVA_OPTS</key><string>-Djava.awt.headless=true -Xmx2048M</string></dict><key>Disabled</key><false/><key>RunAtLoad</key><true/><key>HopefullyExitsFirst</key><true/><key>ExitTimeOut</key><integer>60</integer></dict></plist>
Finally, start Tomcat with the following:
sudo launchctl load -w /Library/LaunchDaemons/org.apache.tomcat.plist
To upgrade Tomcat to a newer version in the future, see my Upgrading Tomcat with Homebrew post.
Git Hosting
Run the following as your admin user on the server to install gitolite:
# create git user accountsudo adduser git# copy our admin public key over to the git accountsudo cp ~/.ssh/id_rsa.pub ~git/$USER.pubsudo chown git ~git/$USER.pub# switch to git user and configure shellsudo -u git -icurl -sL http://github.com/jasoncodes/dotfiles/raw/master/install.sh | bashexec bash -i # reload the shell# clone gitolite source codegit clone git://github.com/sitaramc/gitolite gitolite-source# install gitolitecd gitolite-sourcemkdir -p ~/bin ~/share/gitolite/conf ~/share/gitolite/hookssrc/gl-system-install ~/bin ~/share/gitolite/conf ~/share/gitolite/hookscd# configure gitolite with ourselves as the admingl-setup $SUDO_USER.pub# cleanuprm $SUDO_USER.pubexit
From your workstation you can then clone the config repo by running:
git clone git@$SERVER:gitolite-admin.git $SERVER-gitolite-admin
The documentation should contain everything you need. If you're new you'll want to read gitolite.conf for permission config and migrate if you're moving from Gitosis.
SSH on an alternate port
I want SSH to be available on both IPv4 and IPv6 on an alternate secondary port. I could use ipfw add 01000 fwd 127.0.0.1,22 tcp from any to me 4242 for IPv4 but ip6fw doesn't support forwarding. We can however just have launchd listen for SSH connections on an alternate port by running the following:
Note: Replace 4242 with your desired alternate port number.
sudo cp /System/Library/LaunchDaemons/ssh.plist /Library/LaunchDaemons/ssh-alt.plistsudo defaults write /Library/LaunchDaemons/ssh-alt Label com.openssh.sshd-altsudo defaults write /Library/LaunchDaemons/ssh-alt Sockets '{ Listeners = { SockServiceName = 4242; }; }'sudo plutil -convert xml1 /Library/LaunchDaemons/ssh-alt.plistsudo chmod 644 /Library/LaunchDaemons/ssh-alt.plistsudo launchctl load -w /Library/LaunchDaemons/ssh-alt.plist
Now with this port opened on my firewall I can use the following in my ~/.ssh/config to easily connect to my server with ssh server:
Host serverHostName server.example.comPort 4242
Monit
Monit is a great tool that lets you monitor processes and make sure they're still serving requests. launchd handles restarting of failed services for us automatically but processes could still hang. This is where monit comes into the picture.
Check out the documentation for what can be monitored. Resources you can monitor include load average, system memory usage, disk space, process memory usage and connectivity.
First thing is to install monit. You'll need at least 5.2.3 as earlier versions are prone to crash on Mac OS. If you're using my Homebrew fork, you're all good to go.
brew install monit
Create /etc/monitrc:
set daemon 30 with start delay 60set mail-format { from: root@server.example.com }set alert root@server.example.comset mailserver localhostset httpd port 2812 allow localhostcheck system serverif loadavg (1min) > 8 then alertif loadavg (5min) > 6 then alertcheck filesystem rootfs with path /if space usage > 90 % then alertcheck process apache2 with pidfile /var/run/httpd.pidif failed URL http://localhost:80/ with timeout 5 seconds then alertcheck host postgresql with address 127.0.0.1if failed port 5432 with timeout 5 seconds then alertcheck host tomcat with address 127.0.0.1if failed port 8080send "HEAD / HTTP/1.0\r\n\r\n"expect "HTTP/1.1"with timeout 5 secondsthen alertcheck host memcached with address 127.0.0.1if failed port 11211send "stats\n"expect "STAT pid"with timeout 5 secondsthen alert
Save the following as /Library/LaunchDaemons/monit.plist:
<?xml version="1.0" encoding="UTF-8"?><!DOCTYPE plist PUBLIC "-//Apple Computer//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"><plist version="1.0"><dict><key>UserName</key><string>root</string><key>Label</key><string>monit</string><key>OnDemand</key><false/><key>RunAtLoad</key><true/><key>ProgramArguments</key><array><string>/usr/local/bin/monit</string><string>-c</string><string>/etc/monitrc</string><string>-I</string><string>-l</string><string>/var/log/monit.log</string></array></dict></plist>
Secure the config file, check the syntax, and then start monit:
sudo chmod 600 /etc/monitrcsudo monit -t /etc/monitrcsudo launchctl load -w /Library/LaunchDaemons/monit.plist
If you kill -STOP or otherwise break a service and you should get an email letting you know. You can view the current status with sudo monit status.
Restarting failed services
A great feature of Monit is that it can run tasks for you when something bad happens. In the case of Tomcat, I have created a script which kills Tomcat and then relaunches it. See my Restarting Tomcat automatically on Mac OS with Monit post for details.
Backups
My current local backup solution consists of both Time Machine backups to a Time Capsule and a weekly startup disk image with Carbon Copy Cloner. A problem with both of these solutions though is that they can't quiesce database writes to allow atomic snapshots. Hopefully Apple's working on their own ZFS/brtfs alternative for Mac OS 10.7 Lion which will allow cheap copy-on-write snapshots.
Until Time Machine can take atomic snapshots and efficiently backup large changing files, we need to make database dumps which can be picked up by our backup system. This applies to both PostgreSQL databases and and other large and changing files such as our Solr indexes.
Update: To prevent Time Machine from backing up these files you can mark them as excluded either in the Time Machine preference pane or with xattr:
sudo xattr -w com.apple.metadata:com_apple_backup_excludeItem com.apple.backupd /usr/local/var/postgres # [...]
If you don't exclude these directories from Time Machine, you'll find that your backup increments will be large and you'll quickly lose your history as Time Machine starts pruning old backups to make room. The worst bit is copying those large files every hour will put a noticeable resource strain on your machine.
If you want to audit what Time Machine is backing up (highly recommended if you suspect your backups are larger than they should be), I've found the easiest way is TimeTracker from CharlesSoft (the makers of the handy Pacifist tool). I found I had to run it under sudo with the Time Machine backup mounted to avoid errors.
I have a backup script which I run daily. It archives PostgreSQL databases and Solr indexes locally and then rsyncs them along with my Git repos to an offsite VPS. Since we're archiving locally, we might as well send these same backups offsite.
If your databases are large you might want to look into continuous archiving to create incremental backups. Be sure to perform full base backups regularly with any incremental setup (I recommend weekly).
The script makes use of my lib_exclusive_lock functions to prevent concurrent executions. With sufficiently large databases and a slow enough upstream this becomes a problem.
PostgreSQL databases are detected by querying the pg_database catalog. Solr instances and their paths are detected by looking for the solr/home environment variable in the Tomcat contexts. You'll need to brew install xmlstarlet for this to work.
Note: unlike the rest of this guide, this backup script assumes GNU tools are installed as the default.
To use the script, save it as ~root/bin/backup and add a crontab entry for root (sudo crontab -e) like 15 0 * * * ~/bin/backup. Adjust the configuration variables at the top to suit.
#!/bin/bash -eset -o pipefailexport PATH=/usr/local/bin:/usr/local/sbin:/usr/bin:/usr/sbin:/binBWLIMIT=30ARCHIVE_COUNT=5REMOTE_HOST=backup@example.net# grab lockcd "`dirname "$0"`"source "lib_exclusive_lock.sh"exclusive_lock_require# prepare backup directoryumask 0077mkdir -p ~/backupscd ~/backups# compact git reposfind ~git/repositories -maxdepth 1 -type d -name '*.git' | while read REPOdo(cd $REPOgit gc --auto --quiet)done# backup PostgreSQL databasesmkdir -p postgressu - postgres -lc "psql -q -A -t" <<SQL |SELECT datnameFROM pg_databaseWHERE datname NOT LIKE 'template%';SQLwhile read DB_NAMEdomkdir -p "postgres/${DB_NAME}"FILENAME="postgres/${DB_NAME}/${DB_NAME}_`date +%Y%m%d`.sql.bz2"nice su - postgres -lc "pg_dump --format=p $DB_NAME" | nice bzip2 > "${FILENAME}.new"mv "${FILENAME}"{.new,}find "postgres/${DB_NAME}" -maxdepth 1 -type f -name "${DB_NAME}_*.sql.*" | sort -r | tail -n +$((ARCHIVE_COUNT + 1)) | xargs -r rmdone# backup Solr indexesxml sel -t -v "Context/@path " -o " " -v "Context/Environment[@name='solr/home']/@value" /usr/local/tomcat/conf/Catalina/localhost/*.xml | while read CONTEXT_PATH SOLR_HOMEdoif [ -n "$CONTEXT_PATH" -a -n "$SOLR_HOME" ]thenDB_NAME="$(basename "$CONTEXT_PATH" | sed -e 's/-production-solr$//')"mkdir -p ~/"backups/solr/${DB_NAME}"FILENAME=~/"backups/solr/${DB_NAME}/${DB_NAME}_$(date +%Y%m%d).tar.bz2"rm -rf "$SOLR_HOME".bakcp -lr "$SOLR_HOME"{,.bak}(cd "${SOLR_HOME}.bak" && nice tar c .) | nice bzip2 > "${FILENAME}.new"rm -rf "$SOLR_HOME".bakmv "${FILENAME}"{.new,}find "solr/${DB_NAME}" -maxdepth 1 -type f -name "${DB_NAME}_*.tar.bz2" | sort -r | tail -n +$((ARCHIVE_COUNT + 1)) | xargs -r rmfidone# rsync with a retry. sometimes there's intermittent connectivity issues.function do_rsync(){if ! rsync --archive --delete-after --partial-dir=.partial --bwlimit $BWLIMIT "$@"thenecho rsync failed: "$@"sleep 5mecho retrying...rsync --archive --delete-after --partial-dir=.partial --bwlimit $BWLIMIT "$@"echo retried.fi}# copy backups offsitedo_rsync ~git/repositories/ $REMOTE_HOST:~/backups/git/find ~/backups/postgres/* -maxdepth 0 -type d | while read DIRdodo_rsync "$DIR" "$REMOTE_HOST:~/backups/postgres/"donefind ~/backups/solr/* -maxdepth 0 -type d | while read DIRdodo_rsync "$DIR" "$REMOTE_HOST:~/backups/solr/"done
Applications
Automating application deployment with Capistrano
The Capistrano Wiki covers the basics on how to setup Capistrano. There are a few gotchas to watch out for however.
The documentation for RVM and Bundler cover setting up Capistrano support pretty well. Other than requiring the right files, the only other thing you should need to do is disable sudo with set :use_sudo, false.
For the remote cache to work (set :deploy_via, :remote_cache), you'll need to enable SSH agent forwarding with ssh_options[:forward_agent] = true. This allows the app user on the server to temporarily use your SSH key to authenticate to your Git repository when deploying.
Here's a complete Capistrano recipe (config/deploy.rb):
require 'bundler/capistrano'$:.unshift(File.expand_path('./lib', ENV['rvm_path']))require 'rvm/capistrano'set :application, 'example' # change meset :server_name, 'server' # change meset :user, 'example' # change meset(:deploy_to) { "~/apps/#{application}/#{stage}" }set :keep_releases, 5set(:releases) { capture("ls -x #{releases_path}").split.sort }set :scm, :gitset :repository, "git@#{server_name}:#{application}.git"set :branch, "master"set :deploy_via, :remote_cachessh_options[:forward_agent] = trueset :use_sudo, falserole :web, server_namerole :app, server_namerole :db, server_name, :primary => truenamespace :deploy dotask :start doendtask :stop doendtask :restart dorun "touch #{current_path}/tmp/restart.txt"endendbefore "deploy:symlink", "deploy:migrate"after "deploy:update", "deploy:cleanup"
Setting up the application environment
With your application configured to deploy via Capistrano, you can prepare the new application environment (user account, database, vhost) with the following script. Save a copy as /usr/local/bin/createapp and chmod +x it:
#!/bin/bash -eAPPNAME=${1:?Specify application name}DOMAIN=${2:-${APPNAME}.com}# create user accountsudo adduser $APPNAME# copy over admin public keysudo -u $APPNAME -i bash -c 'umask 0077 > /dev/null && mkdir -p ~/.ssh/ && cat >> ~/.ssh/authorized_keys' < ~/.ssh/authorized_keys# seed SSH known hosts with git server detailssudo -u $APPNAME -i bash -c 'umask 0077 > /dev/null && mkdir -p ~/.ssh/ && echo "$(hostname -s) $(cat /etc/ssh_host_rsa_key.pub)" >> ~/.ssh/known_hosts'# install dotfilessudo -u $APPNAME -i bash < <( curl -sL http://github.com/jasoncodes/dotfiles/raw/master/install.sh )# forward mail to rootecho root | sudo -u $APPNAME -i bash -c 'cat > ~/.forward'# create PostgreSQL databasecreateuser -SDR $APPNAMEcreatedb -O $APPNAME ${APPNAME}_production# setup vhostsudo cp /etc/apache2/other/vhosts-example.conf.template /etc/apache2/other/vhosts-${APPNAME}-production.confsudo /usr/bin/sed -i '' -e s/example\.com/${DOMAIN}/g -e s/example/${APPNAME}/g /etc/apache2/other/vhosts-${APPNAME}-production.confsudo -e /etc/apache2/other/vhosts-${APPNAME}-production.conf # check config, make any custom tweakssudo apachectl configtest && sudo apachectl graceful# setup log rotationsudo cp /etc/logrotate.d/vhosts-example.conf.template /etc/logrotate.d/vhosts-${APPNAME}-production.confsudo /usr/bin/sed -i '' s/example/${APPNAME}/g /etc/logrotate.d/vhosts-${APPNAME}-production.conf
Deploying your application
From within the app directory on your workstation you can then run the following:
# prepare application directories for deploymentcap deploy:setup# deploy the applicationcap deploy