Docker for development

03 Oct 2017

Using Docker for local development is different to actually building images that you expect to be deployed and run as containers elsewhere.

The aim for local development is to simplify creating the necessary environment for running your app.

So for example the main docker tutorial assumes I have Python and PIP installed locally so that I can then grab the project’s dependencies. The Dockerfile focuses on copying the project including dependencies into an image, and specifiying its startup behaviour.

For local development I’m more interested in starting a container that contains everything I need to build and run my app, whilst still being able to make changes to the project, and have that reflected in the running application (assuming you’re building in a dynamic rather than static language).

This post details some simple examples; one for Ruby development, and the other for Node.js.

Docker for Ruby

Here we demonstrate using Docker to provide an environment for developing a basic ruby Sinatra application.

Specifically we have an app with a simple root GET, which returns Hello world as we want to demonstrate a very basic app that

The project (ruby)

First create the Gemfile

source 'http://rubygems.org'

# Sinatra is a DSL for quickly creating web applications in Ruby with minimal
# effort
gem 'sinatra'

# Restarts an app when the file system changes
gem 'rerun'

Then create a file called server.js

require 'sinatra'

set :port, 4567
set :bind, '0.0.0.0' # Required to bind to all interfaces

get '/' do
  'Hello world'
end

Finally create the Dockerfile

# Sets the base image
FROM ruby:2.4.1

# Use an env var to hold what will be the main directory within the container.
# As we need to refer to it a few times it can be easier to set an env var
# and then refer to it in subsequent commands
ENV APP_HOME /app

# This will execute any commands in a new layer on top of the current image and
# commit the results. In this case we create the /app directory
RUN mkdir $APP_HOME

# Sets the working directory for any RUN, CMD, ENTRYPOINT, COPY and ADD
# instructions that follow it
WORKDIR $APP_HOME

# Copies new files or directories from <src> and adds them to the filesystem of
# the container at the path <dest>
COPY . $APP_HOME

# Here we tell docker to download our projects dependencies in another layer
# that then gets committed
RUN bundle install

# Informs Docker that the container should listen on the specified network port
# at runtime. EXPOSE does not make the ports of the container accessible to the
# host. To do that, you must use still use the `docker run` -p flag
EXPOSE 4567

# There can only be one CMD instruction in a Dockerfile. If you list more than
# one CMD then only the last CMD will take effect.

# Purpose of a CMD is to provide defaults for an executing container. In this
# case it sets the command and arguments to be executed when running the image
CMD ["rerun", "--background", "server.rb"]

If this is a bit too much hassle, feel free to make use of one I made earlier.

Build (ruby)

Now we’ve got our project, we need to create an image from it. Call

docker build -t docker-for-ruby-dev .

Don’t forget the . at the end there! You can call it something other than docker-for-ruby-dev. This is just what I have called it for this example. If we ran it on a machine clean of other Docker images, running docker images would display something like this

REPOSITORY            TAG                 IMAGE ID            CREATED             SIZE
docker-for-ruby-dev   latest              5ec4ed806769        3 minutes ago       713MB
ruby                  2.4.1               3630c02d3d1b        3 weeks ago         679MB

Run (ruby)

So we’ve built an image, now we need to create and run a container from it

docker run --rm -v "$(pwd)":/app -p 4567:4567 docker-for-ruby-dev

Breaking down this command we have

Running in this way ensures we see any output from our app. Add the -d flag if you prefer the container to run as a daemon.

Develop (ruby)

Now we have a container we want to be able to make changes. As we have mounted the project’s root folder to /app in the container we can open it in our preferred editor, make changes, and see them reflected in the app.

To prove this first run curl -XGET http://localhost:4567 from the host. The response should be Hello world.

Edit server.rb and change line 7 to be 'Hello docker world'. Running the same curl command again will result in Hello docker world.

Docker for node.js

Now we’ll demonstrate using Docker to provide an environment for developing a basic Express app.

Again we just want an application with a simple root GET, which returns Hello world, and covers the following

The project (node)

Create the package.json first

{
  "name": "docker-for-node-dev",
  "version": "0.1.0",
  "description": "Example site used to demo Docker for node development",
  "homepage": "https://github.com/Cruikshanks/docker-for-dev",
  "license": "MIT",
  "author": {
    "name": "Alan Cruikshanks",
    "url": "https://github.com/Cruikshanks"
  },
  "private": true,
  "scripts": {
    "start": "nodemon server.js"
  },
  "dependencies": {
    "express": "^4.15.4"
  },
  "devDependencies": {
    "nodemon": "^1.11.0"
  }
}

Next create the app in server.js

const express = require('express')
const app = express()

app.get('/', function (req, res) {
  res.send('Hello world')
})

app.listen(3000, function () {
  console.log('Example app listening on port 3000')
})

Finally we add our Dockerfile. Take note of the change we’ve had to make to allow for where npm wants to install the dependencies.

# Sets the base image
FROM node:8.4.0

# Use an env var to hold what will be the main directory within the container.
# As we need to refer to it a few times it can be easier to set an env var
# and then refer to it in subsequent commands
ENV APP_HOME /app

# This will execute any commands in a new layer on top of the current image and
# commit the results. In this case we create the /app directory
RUN mkdir $APP_HOME

# Sets the working directory for any RUN, CMD, ENTRYPOINT, COPY and ADD
# instructions that follow it
WORKDIR $APP_HOME

# Copies new files or directories from <src> and adds them to the filesystem of
# the container at the path <dest>
COPY ./package.json $APP_HOME

# Here we tell docker to download our projects dependencies, then move that
# folder to the root (why explained below) in another layer that then gets
# committed
RUN npm install \
    && mv $APP_HOME/node_modules /node_modules

# ##############################################################################
# Npm unlike Bundler defaults to installing dependencies within the local
# project. That's fine when developing on the host, but as we want to install
# them as part of the image and have them present in the container when we start
# developing we face a problem.
#
# To use the container for development we mount the project folder to /app when
# calling `docker run`. If the container's app folder still held `node_modules`,
# the bind would cause it to become hidden as it doesn't exist on the host. We
# therefore need to move the `node_modules` somewhere else, in this case the
# root.
#
# In doing so we take advantage of
# https://nodejs.org/api/modules.html#modules_loading_from_node_modules_folders
# and the way Node.js traverses the directory tree to locate dependencies.
# ##############################################################################

# Informs Docker that the container should listen on the specified network port
# at runtime. EXPOSE does not make the ports of the container accessible to the
# host. To do that, you must use still use the `docker run` -p flag
EXPOSE 3000

# There can only be one CMD instruction in a Dockerfile. If you list more than
# one CMD then only the last CMD will take effect.

# Purpose of a CMD is to provide defaults for an executing container. In this
# case it sets the command and arguments to be executed when running the image
CMD ["/node_modules/.bin/nodemon", "server.js"]

Or just use one I made earlier.

Build (node)

Next step; create the image.

docker build -t docker-dev-node .

This time if we run docker images we’ll see something like

REPOSITORY            TAG                 IMAGE ID            CREATED             SIZE
docker-for-node-dev   latest              c7c5150e2d55        2 minutes ago       677MB
node                  8.4.0               5e553613f1d8        17 hours ago        669MB

Run (node)

With the image built, we now need a container

docker run --rm -v "$(pwd)":/app -w /app -p 3000:3000 docker-for-node-dev

The breakdown is exactly the same as the ruby app, we’ve just changed the port and name we want to use.

Develop (node)

And development works in the same way as the ruby app. Open the code on the host in your preferred IDE, make changes, and see them reflected in the app.

You can test it by running curl -XGET http://localhost:3000 from the host. Edit server.js and change line 5 to be res.send('Hello docker world'). Running the same curl command again should result in Hello docker world.

All done

Using Docker for development will help you and your team avoid it works on my machine issues, and help get new members up and running quickly. The Dockerfile is also great at documenting what you actually need to run the app.

The examples here are very basic though, and its likely you will also depend on other services and tools. But pretty much every other example of using Docker for development immediately launches into using Compose, so I wanted to provide a stripped back example. I hope it helps those like me who just wanted to get to grips with what you need to conisder just getting ‘your’ app up and running.

Finally you will have to get used to calling Docker to start your apps, rather than your typical ruby server.js or npm start. And as you’ve seen the command can be complex so bear in mind how you can keep repeating it.

Creative Commons Licence
Docker for development by Alan Cruikshanks is licensed under a Creative Commons Attribution 4.0 International License.
Based on the work at http://cruikshanks.co.uk/blog/docker-for-development/.