There's a ton of cool ways out there to deploy your modern web application to production. dpkg, the software used to build and install packages for Debian-based GNU/Linux distributions, is not one I've had the pleasure of directly working with in a professional capacity. It's been around since 1994, so it's had some time to mature and grow into a very useful tool. A companion suite of scripts called debhelper that popped up in 1997 really makes the process of creating installable binaries a cinch. Best of all, it comes right out of the box with Debian-based Operating Systems, Ubuntu included. This makes my brain happy.
I also really like the idea of installing my application just like any other part of my machine's software ecosystem. Other approaches tend to make me feel like my app is something that needs to be quarantined.
In this article, we'll start with the very basics, then work our way up to
building a Rails application as a .deb
file. I pushed up
a repository
following the steps outlined for the Rails portion of things if you'd
like to skip ahead.
dpkg
installs software packaged in .deb
files.
If you go to a website and download a .deb
file, there's
nothing stopping you from installing that piece of software if you have
root/sudo access.
These .deb
files contain the application itself (sort of like
a tar
file) as well as instructions for installing that
application on your computer. The .deb
file's
responsibilities include (but are not limited to):
.deb
file to directories on
your machine
This is quite convenient. Not only can you bundle up all of your code in a single file that can be easily sent to another machine, you can also ship all of the instructions necessary to get your application running. You get this for free just by virtue of running your operating system.
Let's start with nothing. Create a directory that would hold your
application. If you want to follow along step-by-step, do
mkdir -p foo/{DEBIAN,usr/bin}
. Inside of the
foo/DEBIAN
directory, create a file named
control
. At a minimum, it should look something like this:
Package: foo Description: FOO! Version: 0.0.0 Maintainer: Ryan Johnston Architecture: all
You can read more details about what these things do in the
Debian Docs.
Now create a file foo/DEBIAN/compat
that simply looks like
this:
10
Once you have this file, create your deb by running
dpkg-deb --build foo
in the parent directory of
foo
. This will create a foo.deb
file in your
current directory. You can then install this by doing
sudo dpkg -i foo.deb
. You can remove this package by doing
sudo dpkg -r foo
. It's that easy.
Ok, so we can install that package, but it doesn't actually do anything...
yet. Let's create a file that we'll install. Put the following into
foo/usr/bin/foo.sh
:
#!/bin/bash echo "YO"
Make this file executable with chmod +x foo/usr/bin/foo.sh
.
When we install this, foo.sh
will automatically get dumped
into your system's /usr/bin
directory.
Now build again with dpkg-deb --build foo
. You can then just
re-install this package by doing sudo dpkg -i foo.deb
. You
should be able to run foo.sh
from anywhere now. That's
because by mirroring your system's directory structure in our app's
directory, the .deb
file knew to put the executable in
/usr/bin
which is available in your $PATH
.
This isn't bad if your project's file structure directly mimics how it will be installed on your system. Usually I want to leave my app's directory structure as is for my development environments and just specify where each of the files should be installed.
You may notice we named the directory DEBIAN
with all capital
letters. Generally,
it's recommended not to do this.
Naming the directory debian
instead will allow us to specify
where each individual file is supposed to be installed on the production
environment without changing our development environment's directory
structure.
Rename the directory foo/DEBIAN
(all capitals) to
foo/debian
. Now move foo/usr/bin/foo.sh
into
foo/foo.sh
. Put the following in a file named
foo/debian/install
:
foo.sh usr/bin
Change your foo/debian/control
file so that it looks
something like this:
Source: foo Standards-Version: 4.0.0 Maintainer: Ryan Johnston Package: foo Architecture: all Description: FOO!
Make a file named foo/debian/rules
that looks like this:
#!/usr/bin/make -f # -*- makefile -*- # Uncomment this to turn on verbose mode. #export DH_VERBOSE=1 %: dh $@
Note that it must be an actual tab
(not spaces!) before
dh
. Make it executable with
chmod +x foo/debian/rules
.
Finally, we also need to make a file named
foo/debian/changefile
. One can be generated easily enough by
doing dch --create
. Your foo/debian/changefile
should look something like this:
foo (0.0.0) UNRELEASED; urgency=medium * Initial release. -- ryjo Mon, 10 Dec 2018 20:01:33 -0500
The build commands are a little different than before. Navigate
inside of your foo
directory and run
debuild --no-tgz-check
.
This creates a file in foo
's parent directory called
foo_0.0.0_all.deb
, as well as one or two other files. You can
install this package using our familiar
sudo dpkg -i foo_0.0.0_all.deb
.
You'll see lots of warnings in the console after you run
debuild --no-tgz-check
, but it shouldn't stop it from
building. Feel free to look them up and tweak your files here and there to
make the warnings go away.
The first time you run dpkg -i
with a .deb
file,
you'll "install" it. For all subsequent times, you'll "upgrade" it. It may
be wise to first "remove" the package or even "purge" it with
dpkg -r
or dpkg -P
respectively if you ever find
yourself in an odd state. This way, you'll be "installing" the package
fresh every
time.
I've worked quite a bit with Rails, so I'll use this framework to try
building an application that's a bit more complicated. I build a new rails
app by running rails new rails_new
. I know that running this
app can be accomplished by running rails server
. Easy.
In my opinion, it makes sense to already have ruby
,
bundler
and node
installed system-wide
(executable by all users of the system) on the server I will deploy my
application to. I can make them dependencies of my package by creating
a rails_new/debian/control
file that looks like this:
Source: rails-new Standards-Version: 4.0.0 Maintainer: Ryan Johnston Package: rails-new Architecture: all Depends: ${misc:Depends}, ruby, bundler, nodejs, zlib1g-dev, libsqlite3-dev Description: New Rails Project
Depends
is optionally used to prevent installation if those
packages are not installed and tracked by dpkg
. I added the
${misc:Depends}
part to remove a warning message during
build. zlib1g-dev
and libsqlite3-dev
are needed
for the nokogiri
and sqlite3
gems respectively.
Create a rails_new/debian/changelog
file either manually or
with dch --create
that looks something like:
rails-new (0.0.0) UNRELEASED; urgency=medium * Initial release. -- ryjo Mon, 10 Dec 2018 20:01:33 -0500
...a filed named rails_new/debian/compat
that looks like:
10
...as well as a rails_new/debian/rules
file that looks like
this:
#!/usr/bin/make -f # -*- makefile -*- # Uncomment this to turn on verbose mode. #export DH_VERBOSE=1 %: dh $@
Make that file executable by running
chmod +x rails_new/debian/rules
.
You should now be able to run debuild --no-tgz-check
followed
by a sudo dpkg -i rails-new_0.0.0_all.deb
. Of course, this
doesn't actually do anything yet.
The debian/rules
file has some very powerful capabilities.
Among other things, it lets us override what happens during certain steps
of the build process.
One of the steps debhelper
runs is called
dh_auto_test
. This automatically attempts to detect and run
our testing suite. It doesn't have the capability to detect a Rails suite
automatically, so we'll specify exactly what needs to happen. Update the
rails_new/debian/rules
file so that it looks like this:
#!/usr/bin/make -f # -*- makefile -*- # Uncomment this to turn on verbose mode. #export DH_VERBOSE=1 %: dh $@ override_dh_auto_test: bin/rails test
This will run our test suite before we build our package and cancel the build if it fails. Afterall, we wouldn't want to deploy a binary built upon source code with failing tests now would we?
We'll create a very trivial and uninteresting test just to showcase this
capability. Create a file rails_new/test/foo_test.rb
that
looks like this:
require "test_helper" class FooTest < ActionDispatch::IntegrationTest test "foo!" do assert true end end
Right now, our test suite will always suceed when we run
debuild --no-tgz-check
since our one and only test does
assert true
. This means that it'll exit with code
0
, and the build will continue past this step. If we change
this to assert false
, the testing suite will fail, exiting
with code 1
. This causes the entire build process to fail.
This effectively prevents us from building a .deb
file with
code that fails our testing suite. Very nice!
There are lots of steps during the build process that we can
override_*
. Take a look at the
dh source code
comments for some really nice examples.
The directories in a unix-like filesystem are meant to store different
types of files. For example, /usr
is meant to store programs
installed by the user.
The Linux Documentation Project
has excellent write-ups about the Filesystem Hierarchy.
This one
and
this one
are great starting points. They basically say that user programs go here.
Specifically, this is where we'll put the files that should not change
while our application is running. A few of these in Rails are
app
, config
and lib
.
In order to split up where my files will go, we will use the
rails_new/debian/install
file:
app usr/lib/rails-new bin/rails-production usr/bin config usr/lib/rails-new config.ru usr/lib/rails-new Gemfile* usr/lib/rails-new lib usr/lib/rails-new package.json usr/lib/rails-new public usr/lib/rails-new Rakefile usr/lib/rails-new
You'll notice that we install a file called rails-production
.
This binary will live in /usr/bin
while the rest of the rails
app will live in /usr/lib
. The normal rails
executable that comes out of the box with rails uses relative paths.
Luckily this file isn't too large, so it's easy to change to suit our
needs. Create a rails_new/bin/rails-production
file that
looks like this:
#!/usr/bin/env ruby APP_PATH = File.expand_path('/usr/lib/rails-new/config/application', __dir__) require_relative '/usr/lib/rails-new/config/boot' require 'rails/commands'
Make it executable with
chmod +x rails_new/bin/rails-production
.
You'll also notice we left out a few directories: tmp
,
log
and db
(which will hold our sqlite database
files). All of these directories will be written to during execution of
the application. According to the
Linux Filesystem Hierarchy,
/var
is where files that are written to
during the execution of your program should go.
This presents a bit of a problem: it's difficult to configure in Rails
where these other directories are located. For example, there is no
configuration option to tell it where the tmp
directory
should go; it just assumes it'll be located at the root of your app.
We'll just create some symlinks. Luckily, we can easily do this by
creating a rails_new/debian/links
file that looks like this:
var/lib/rails-new/db usr/lib/rails-new/db var/lib/rails-new/tmp usr/lib/rails-new/tmp var/log/rails-new usr/lib/rails-new/log
This will create symlinks inside of /usr/lib/rails-new
that
link to directories in /var/{lib,log}/rails-new
. Of course,
we'll need to create those directories. dirs
to the rescue!
Create a file rails_new/debian/dirs
with the following in it:
var/lib/rails-new/db var/lib/rails-new/tmp var/log/rails-new
Now that we've set up our log directory, let's make use of it. We'll
create a rake task that logs text to our environment's log file in
/var/log/rails-new
. We'll schedule it to run every minute
with cron
.
Create a file named
rails_new/lib/tasks/foo.rake
that looks like this:
desc "Log 'foo!'" task :foo do Rails.logger = ActiveSupport::Logger.new(Rails.root.join('log', "#{Rails.env}.log")) Rails.logger.info 'foo!' end
We can install this as a cron job in a file named
/etc/cron.d/rails-new
by creating a file
rails_new/debian/rails-new.cron.d
that looks like this:
* * * * * rails-new cd /usr/lib/rails-new; /usr/bin/rails-production foo
We'll want to run our application as a daemon. This gives us a pretty
powerful toolset through systemd
. We'll be able to run things
like sudo systemctl restart rails-new.service
to restart the
process. Similarly, start
, stop
and
status
will also be available for our use. This is as easy as
creating a rails_new/debian/rails-new.service
file with the
following contents:
[Unit] Description=A new Rails Service After=network.target [Service] User=rails-new ExecStart=/usr/bin/rails-production server ExecStop=/bin/kill -9 $(cat /var/lib/rails-new/tmp/pids/server.pid) [Install] WantedBy=multi-user.target
By default, our daemon will be enabled and running after we install this package.
You may notice we told the cron job and service to run as the user
rails-new
. It's generally considered good practice to run
daemons as a non-root user. We'll also run all of our cronjobs as the user
that runs our server. Unfortunately, there's not a dedicated file like
with dirs
or links
that will let us create
a user or set permissions on installed files or directories.
We'll have to create a "maintainer script." These scripts are used when
tasks fall outside of the normal flow of available dh_*
scripts. Create a rails_new/debian/postinst
file that looks
like this:
#!/bin/bash set -e case "$1" in configure) adduser --disabled-password --quiet --system \ --home /var/run/rails-new --no-create-home \ --gecos "Rails New daemon" --group rails-new chown rails-new:rails-new \ /var/lib/rails-new/db \ /var/lib/rails-new/tmp \ /var/log/rails-new cd /usr/lib/rails-new && bundle install --deployment ;; abort-upgrade|abort-remove|abort-deconfigure) ;; *) echo "Oops." exit 1 ;; esac #DEBHELPER# exit 0
As the name implies, this script will run after installation. It generally
seems like a good idea to avoid putting too much in maintainer scripts
since there are so many dh_*
utilities to make use of. Plus,
the more you use dh_*
utilities, the more that
dpkg
is able to track for your package.
Adding that bundle install
line after the chown
illustrates this point. We're not bundling the third party gems specified
in the Gemfile
in the .deb
file. Instead, we've
chosen to download these gems at install time. There is no
dh_*
equivalent for installing gems, so, like when we added a
user
(explanation of command)
and ran chown
on the directories Rails would need to write
to, we put this in the maintainer script
rails_new/debian/postinst
.
Running a dpkg -r
command would normally remove the files
that were copied from debian/install
, the directories created
from debian/dirs
and so on. If we were to
sudo dpkg -r rails-new
, however, we would see that the
/usr/lib/rails-new
directory is not deleted. We ran a
bundle install
, and that created
/usr/lib/rails-new/{.bundle,vendor}
directories that are not
tracked by dpkg
.
Even if we didn't use bundle install
at install time (perhaps
we decide to install vendor gems at the time we build the
.deb
file), we still have /var/lib/rails-new/tmp
directory. This directory has contents not tracked by dpkg
as
they were added during runtime of the Rails application.
To deal with these issues, we'll create a second maintainer script called
rails_new/debian/prerm
with the following in it to do some
extra cleanup:
#!/bin/bash set -e case "$1" in upgrade|deconfigure) ;; remove) rm -r /usr/lib/rails-new rm -r /var/lib/rails-new rm -r /var/log/rails-new ;; failed-upgrade) ;; *) echo "prerm called with unknown argument $1" >&2 exit 1 ;; esac #DEBHELPER# exit 0
A keen observer may note that we do not remove the user and group we created. Generally, packages shouldn't remove users and groups. Cleanup of this is left as an exercise to the system administrator.
Additionally, you may notice the #DEBHELPER#
at the end of
these maintainer scripts. This is a placeholder. When you build the
package, debhelper
will put any extra code that it generated
for these files in this location. It won't put the code directly in this
file, mind you. Instead, it'll put it in the
rails_new/debian/rails-new/DEBIAN/postinst
file that is
generated when we run debuild --no-tgz-check
. In our case, it
puts some extra stuff there because we're installing a
systemd
unit.
We made it. It's over. All that's left to do is run a
debuild --no-tgz-check
and subsequent
sudo dpkg -i rails-new_0.0.0_all.deb
, and our rails
application is installed and running. You should be able to get to
http://0.0.0.0:3000
and be greeted by the default Rails welcome page. Additionally, if you
tail -f /var/log/rails-new/development.log
, you'll see "foo!"
being logged from your cron job.
If you want to learn more about this process, do yourself a favor and read the many many many (you get it) resources out there. It may take a bit of piecing together to get exactly the results you want, but hopefully this article and the many search results will give you a good starting point.
Also, take a peek at the files in /var/lib/dpkg/info/*
on an
Ubuntu system. You can see actual files from packages already installed
like the avahi
and wpasupplicant
daemons.
Finally, take a look inside of the generated
rails_new/debian/rails-new/DEBIAN
directory. Lots of
learnings to be had!
I really went down a rabbit hole with that one. I appreciate you sticking through until the end of this article. Hopefully this will provide some encouragement to you and other readers to use the tools provided out of the box with your operating system.
Simplify your life, my friend.
- ryjo