Creating a heroku like deployment solution with docker

Creating a Heroku-like Deployment Solution with Docker

Learn how to build your own deployment solution similar to Heroku, without the need to use any specific cloud provider or tool apart from Docker.

Brought to you by

Semaphore

Introduction

This tutorial will show you how to create an automation tool for deploying your software in a simple way, similar to deploying to Heroku. We'll be using Docker to version control each deploy, which makes upgrades and rollbacks fairly easy. Also, we'll use Semaphore to continuously deploy our application.

Our containers can be hosted on any Docker Registry, and only a host with Docker installed is needed to run the application.

At the end of this tutorial you'll have a simple Ruby cli script, capable of deploying a sample application to a remote host, similar to Heroku’s. It will also have other commands for rolling back to previous versions, attaching longs and tracking which application version is running.

This tutorial is focused on the deploying routine, so you can use any application you want, by adjusting the environment according to your needs. We'll be using a simple Hello World built with Ruby on Rails, in a folder called blog. If you need help building such an application, please follow the guide Getting Started with Rails steps 1 to 4.

Prerequisites

  • Docker installed and running on the host and on every machine that will deploy the application,
  • Any Docker Registry account (examples with Docker Hub),
  • Any cloud provider with SSH access and Docker installed (examples with AWS EC2), and
  • Ruby 2.3 installed on every machine that will deploy the application.

Steps in the Deployment Process

This deployment process consists of the following 5 steps:

  • Build: Each application has its own container with specific build steps that can be changed anytime,
  • Upload: After building our application container, we need to send it to the Docker Registry. The first time we do this it's going to take some time, because we'll need to upload the whole container, but the next time will be faster, due to the Docker layer system, which helps us save space and bandwidth.
  • Connect: After sending our container to the Docker Registry, we need to connect to the host to run the next steps,
  • Download: After connecting to the host, it’s time to download our container, and
  • Restart: The last step consists of stopping the running application and starting the new container with the same configuration as the stopped one (ports, logs, environment variables, etc).

Now that we know what's ahead, let's get started.

Building the Container

In our example, we'll be using one container to run our application, since Ruby isn't a compiled language and we don't need a build step. This is the respective Dockerfile:

FROM ruby:2.3.1-slim

COPY Gemfile* /tmp/
WORKDIR /tmp

RUN gem install bundler && \
    apt-get update && \
    apt-get install -y build-essential libsqlite3-dev rsync nodejs && \
    bundle install --path vendor/bundle

RUN mkdir -p /app/vendor/bundle
WORKDIR /app
RUN cp -R /tmp/vendor/bundle vendor
COPY application.tar.gz /tmp

CMD cd /tmp && \
    tar -xzf application.tar.gz && \
    rsync -a blog/ /app/ && \
    cd /app && \
    RAILS_ENV=production bundle exec rake db:migrate && \
    RAILS_ENV=production bundle exec rails s -b 0.0.0.0 -p 3000

To keep our scripts organized, let's save our Dockerfile in a folder above our application. We'll have the following folder structure:

.
├── Dockerfile
├── blog
│   ├── app
│   ├── bin
... (application files and folders)

Here's what is happening in each line:

FROM ruby:2.3.1-slim

This is the base image used to build our container. As we need Ruby installed, it is easier to use a pre-installed container than to install everything on our own.

COPY Gemfile* /tmp/
WORKDIR /tmp

Here, we are copying the Gemfile and Gemfile.lock to our container /tmp dir, and moving into it, so the next commands will be executed from there.

RUN gem install bundler && \
    apt-get update && \
    apt-get install -y build-essential libsqlite3-dev rsync nodejs && \
    bundle install --path vendor/bundle

This Ruby image has an outdated bundler, so we update it to to avoid receiving a warning. Also, we need some packages ,mostly compilers, so that the bundler can install all the required gems. You may need to change this when working on an application different from the one we're working on in this tutorial. The last step installs all the gems in the Gemfile.

Docker caches every command , i.e. layer, to avid executing it again if there is no change in the command. This will save us some time. The --path flag tells the bundler to install all the gems locally, in the defined path vendor/bundle

RUN mkdir -p /app/vendor/bundle
WORKDIR /app
RUN cp -R /tmp/vendor/bundle vendor
COPY application.tar.gz /tmp

This creates the final path for bundle installation, copies all installed gems from the last build cache, and copies the compressed application into the container.

CMD cd /tmp && \
    tar -xzf build.tar.gz && \
    rsync -a blog/ /app/ && \
    cd /app && \
    RAILS_ENV=production bundle exec rake db:migrate && \
    RAILS_ENV=production bundle exec rails s -b 0.0.0.0 -p 3000

This command will be executed in the docker run command. It extracts the compressed application inside the container, runs setup steps (migrate) and starts the application.

To test if everything is working as expected with your Dockerfile, move into the root directory, where the Dockerfile is, and execute the following commands:

Note: the mydockeruser below is your registered username in the Docker Registry. We'll use that later for versioning the container.

Note 2: when running in production environment, Rails needs some environment variables, like SECRET_KEY_BASE, in config/secrets.yml. Since we are using just a sample application, you can safely overwrite them with static values, similar to the ones for development and test environments.

$ cp blog/Gemfile* .
$ tar -zcf application.tar.gz blog
$ docker build -t mydockeruser/application-container .

This should start building each step in the Dockerfile:

Sending build context to Docker daemon 4.386 MB
Step 1/9 : FROM ruby:2.3.1-slim
 ---> e523958caea8
Step 2/9 : COPY Gemfile* /tmp/
 ---> f103f7b71338
Removing intermediate container 78bc80c13a5d
Step 3/9 : WORKDIR /tmp
 ---> f268a864efbc
Removing intermediate container d0845585c84d
Step 4/9 : RUN gem install bundler &&     apt-get update &&     apt-get install -y build-essential libsqlite3-dev rsync nodejs &&     bundle install --path vendor/bundle
 ---> Running in dd634ea01c4c
Successfully installed bundler-1.14.6
1 gem installed
Get:1 http://security.debian.org jessie/updates InRelease [63.1 kB]
Get:2 http://security.debian.org jessie/updates/main amd64 Packages [453 kB]
...

If everything went well, you'll get a success message:

Successfully built 6c11944c0ee4

Notice that the hash may be different, since Docker generates it randomly each time a container is built.

To see the cache working, run the command again. It'll finish almost instantly.

$ docker build -t mydockeruser/application-container .
Sending build context to Docker daemon 4.386 MB
Step 1/9 : FROM ruby:2.3.1-slim
 ---> e523958caea8
Step 2/9 : COPY Gemfile* /tmp/
 ---> Using cache
 ---> f103f7b71338
Step 3/9 : WORKDIR /tmp
 ---> Using cache
 ---> f268a864efbc
Step 4/9 : RUN gem install bundler &&     apt-get update &&     apt-get install -y build-essential libsqlite3-dev rsync nodejs &&     bundle install --path vendor/bundle
 ---> Using cache
 ---> 7e9c77e52f81
Step 5/9 : RUN mkdir -p /app/vendor/bundle
 ---> Using cache
 ---> 1387419ca6ba
Step 6/9 : WORKDIR /app
 ---> Using cache
 ---> 9741744560e2
Step 7/9 : RUN cp -R /tmp/vendor/bundle vendor
 ---> Using cache
 ---> 5467eeb53bd2
Step 8/9 : COPY application.tar.gz /tmp
 ---> Using cache
 ---> 08d525aa0168
Step 9/9 : CMD cd /tmp &&     tar -xzf application.tar.gz &&     rsync -a blog/ /app/ &&     cd /app &&     RAILS_ENV=production bundle exec rake db:migrate &&     RAILS_ENV=production bundle exec rails s -b 0.0.0.0 -p 3000
 ---> Using cache
 ---> ce28bd7f53b6
Successfully built ce28bd7f53b6

If you receive an error message, check your Dockerfile syntax and console errors, and try again.

Now, we want to test if our container can run our application, to see if everything is working. To do that, run the command:

docker run -p 3000:3000 -ti mydockeruser/application-container

This runs the container, mapping the host port 3000 to container port 3000. If everything workes well, you'll get the Rails startup message:

=> Booting Puma
=> Rails 5.0.2 application starting in production on http://0.0.0.0:3000
=> Run `rails server -h` for more startup options
Puma starting in single mode...
* Version 3.8.2 (ruby 2.3.1-p112), codename: Sassy Salamander
* Min threads: 5, max threads: 5
* Environment: production
* Listening on tcp://0.0.0.0:3000
Use Ctrl-C to stop

You can now access your browser localhost:3000 and see the welcome message.

Uploading the Container to the Docker Registry

The following steps will need you to log into your Docker Registry:

> docker login
Login with your Docker ID to push and pull images from Docker Hub. If you don\'t have a Docker ID, head over to https://hub.docker.com to create one.
Username: mydockeruser
Password: ########
Login Succeeded

We have a fully working container, and now we need to upload it to the Docker Registry:

$ docker push mydockeruser/application-container
The push refers to a repository [docker.io/mydockeruser/application-container]
9f5e7eecca3a: Pushing [==================================================>] 352.8 kB
08ee50f4f8a7: Preparing
33e5788c35de: Pushing  2.56 kB
c3d75a5c9ca1: Pushing [>                                                  ] 1.632 MB/285.2 MB
0f94183c9ed2: Pushing [==================================================>] 9.216 kB
b58339e538fb: Waiting
317a9fa46c5b: Waiting
a9bb4f79499d: Waiting
9c81988c760c: Preparing
c5ad82f84119: Waiting
fe4c16cbf7a4: Waiting

You'll notice that the container is uploaded in a series of layers, and some of them are huge (100mb+). This is normal, and the big layers will be uploaded just this first time. We'll be using Docker's layer system to upload only the changes we've made in our application, saving disk space and bandwidth. If you want do know more about the docker push and layers, you can read the Official Documentation.

After pushing, you'll receive a success message:

...
9f5e7eecca3a: Pushed
08ee50f4f8a7: Pushed
33e5788c35de: Pushed
c3d75a5c9ca1: Pushed
0f94183c9ed2: Pushed
b58339e538fb: Pushed
317a9fa46c5b: Pushed
a9bb4f79499d: Pushed
9c81988c760c: Pushed
c5ad82f84119: Pushed
fe4c16cbf7a4: Pushed
latest: digest: sha256:43214016a4921bdebf12ae9de7466174bee1afd44873d6a60b846d157986d7f7 size: 2627

You can check your new image in your Docker Registry console:

Alt text

If you try to push the image again, you'll notice that all layers already exist. Docker matches each layer hash to see if it already exists, in order to avoid reuploading it.

$ docker push mydockeruser/application-container
The push refers to a repository [docker.io/mydockeruser/application-container]
9f5e7eecca3a: Layer already exists
08ee50f4f8a7: Layer already exists
33e5788c35de: Layer already exists
c3d75a5c9ca1: Layer already exists
0f94183c9ed2: Layer already exists
b58339e538fb: Layer already exists
317a9fa46c5b: Layer already exists
a9bb4f79499d: Layer already exists
9c81988c760c: Layer already exists
c5ad82f84119: Layer already exists
fe4c16cbf7a4: Layer already exists
latest: digest: sha256:43214016a4921bdebf12ae9de7466174bee1afd44873d6a60b846d157986d7f7 size: 2627

Opening a Remote Connection

Now that we have uploaded our container, let's see how to download and run it on the remote server. First, we need to prepare the remote environment for running the container. You must install Docker and log into the Docker Registry, just like you did on the host machine. To open a remote connection via SSH, you can use the following command:

ssh remoteuser@35.190.185.215
# or if you need authentication
ssh -i path/to/your/key.pem remoteuser@35.190.185.215

Download

After configuring everything on the remote machine, we won't need to access its terminal again. Each command will be executed on its own. Let's download our container. Remember to include the key flag, if needed:

$ ssh remoteuser@35.190.185.215 docker pull mydockeruser/application-container
Using default tag: latest
latest: Pulling from mydockeruser/application-container
386a066cd84a: Pulling fs layer
ec2a19adcb60: Pulling fs layer
b37dcb8e3fe1: Pulling fs layer
e635357d42cf: Pulling fs layer
382aff325dec: Pulling fs layer
f1fe764fd274: Pulling fs layer
a03a7c7d0abc: Pulling fs layer
fbbadaebd745: Pulling fs layer
63ef7f8f1d60: Pulling fs layer
3b9d4dda739b: Pulling fs layer
17e2d6aad6ec: Pulling fs layer
...
3b9d4dda739b: Pull complete
17e2d6aad6ec: Pull complete
Digest: sha256:c030e4f2b05191a4827bb7a811600e351aa7318abd3d7b1f169f2e4339a44b20
Status: Downloaded newer image for mydockeruser/application-container:latest

Restart

Since we are running the container for the first time, we won't need to stop other containers. You can run the container using the same command as the local host:

$ ssh remoteuser@35.190.185.215 docker run -p 3000:3000 -d mydockeruser/application-container
f86afaa7c9cc4730e9ff55b1472c5b30b0e02055914f1673fbd4a8ceb3419e23

The only output you'll get is the container hash. This is because the flag -d (instead of -ti). This means that we're running the container in detached mode, instead of gluing its output to the terminal.

You can access the remote host address in your browser (35.190.185.21:3000) to check the running application.

Wrapping Up

Now we have everything we need to build our automated deploy. The following code is the finished script, you can save it in the root folder with the name deployer.rb. Let's take a look at each line in order to understand what is happening.

# deployer.rb
class Deployer
  APPLICATION_HOST = '54.173.63.18'.freeze
  HOST_USER = 'remoteuser'.freeze
  APPLICATION_CONTAINER = 'mydockeruser/application-container'.freeze
  APPLICATION_FILE = 'application.tar.gz'.freeze
  ALLOWED_ACTIONS = %w(deploy).freeze
  APPLICATION_PATH = 'blog'.freeze

  def initialize(action)
    @action = action
    abort('Invalid action.') unless ALLOWED_ACTIONS.include? @action
  end

  def execute!
    public_send(@action)
  end

  def deploy
    check_changed_files
    copy_gemfile
    compress_application
    build_application_container
    push_container
    remote_deploy
  end

  private

  def check_changed_files
    return unless `git -C #{APPLICATION_PATH} status --short | wc -l`
                  .to_i.positive?
    abort('Files changed, please commit before deploying.')
  end

  def copy_gemfile
    system("cp #{APPLICATION_PATH}/Gemfile* .")
  end

  def compress_application
    system("tar -zcf #{APPLICATION_FILE} #{APPLICATION_PATH}")
  end

  def build_application_container
    system("docker build -t #{APPLICATION_CONTAINER}:#{current_git_rev} .")
  end

  def push_container
    system("docker push #{APPLICATION_CONTAINER}:#{current_git_rev}")
  end

  def remote_deploy
    system("#{ssh_command} docker pull "\
           "#{APPLICATION_CONTAINER}:#{current_git_rev}")
    system("#{ssh_command} 'docker stop \$(docker ps -q)'")
    system("#{ssh_command} docker run "\
             "--name #{deploy_user} "\
             "#{APPLICATION_CONTAINER}:#{current_git_rev}")
  end

  def current_git_rev
    `git -C #{APPLICATION_PATH} rev-parse --short HEAD`.strip
  end

  def ssh_command
    "ssh #{HOST_USER}@#{APPLICATION_HOST}"
  end

  def git_user
    `git config user.email`.split('@').first
  end

  def deploy_user
    user = git_user
    timestamp = Time.now.utc.strftime('%d.%m.%y_%H.%M.%S')
    "#{user}-#{timestamp}"
  end
end

if ARGV.empty?
  abort("Please inform action: \n\s- deploy")
end
application = Deployer.new(ARGV[0])

begin
  application.execute!
rescue Interrupt
  puts "\nDeploy aborted."
end

Now, let's examine it step by step:

  APPLICATION_HOST = '54.173.63.18'.freeze
  HOST_USER = 'remoteuser'.freeze
  APPLICATION_CONTAINER = 'mydockeruser/application-container'.freeze
  APPLICATION_FILE = 'application.tar.gz'.freeze
  ALLOWED_ACTIONS = %w(deploy).freeze
  APPLICATION_PATH = 'blog'.freeze

Here we are defining some constants to avoid code duplication. APPLICATION_HOST stands for the remote IP of the running server, HOST_USER is the remove server user, APPLICATION_CONTAINER is the name of the container used to wrap the application. You can use any name you want. APPLICATION_FILE is the compressed application filename, ALLOWED_ACTIONS is an array of allowed actions, so you can easily define which actions are available to run. Lastly, APPLICATION_PATH is the path of your application. In our example, it's blog.

  def initialize(action)
    @action = action
    abort('Invalid action.') unless ALLOWED_ACTIONS.include? @action
  end

  def execute!
    public_send(@action)
  end

This is a wrapper for validating and calling each available method (in ALLOWED_ACTIONS). With this, you can easily add new callable methods, without the need of a code refactor.

  def deploy
    check_changed_files
    copy_gemfile
    compress_application
    build_application_container
    push_container
    remote_deploy
  end

These are our deploy steps. These methods do almost the same as our examples above, with just a few changes. Let's take a look at each step:

  def check_changed_files
    return unless `git -C #{APPLICATION_PATH} status --short | wc -l`
                  .to_i.positive?
    abort('Files changed, please commit before deploying.')
  end

Since we are using our local code to deploy the application, it is a good practice to check if there are any file changes present, and aborting the deploy if true. This step uses git status --short to check if there are any new or changed files, and the -C flag defines where git should check (blog, in our example). You can remove this step if you want, but it's not recommended.

  def copy_gemfile
    system("cp #{APPLICATION_PATH}/Gemfile* .")
  end

This copies the Gemfile and Gemfile from the blog to the root location each time a deploy is made. This ensures that all gems are installed before the deploy is finished.

  def compress_application
    system("tar -zcf #{APPLICATION_FILE} #{APPLICATION_PATH}")
  end

As the name says, this steps compresses the whole application in a single file, that will be included in the container later.

  def build_application_container
    system("docker build -t #{APPLICATION_CONTAINER}:#{current_git_rev} .")
  end

This method runs the container build step, which installs all dependencies and gems. Every time the Gemfile is changed, Docker detects it and installs, so you don't need to worry about updating dependencies. This will take some time every time dependencies change. If there is no change, Docker will use its cache, and the step will run almost instantly.

  def push_container
    system("docker push #{APPLICATION_CONTAINER}:#{current_git_rev}")
  end

This uploads the new container to the Docker Registry. Notice the current_git_rev, this method uses git to retrieve the last commit hash, and we use it here to identify each deploy. You can also check all uploaded containers in DockerHub console:

Alt text

  def remote_deploy
    system("#{ssh_command} docker pull "\
           "#{APPLICATION_CONTAINER}:#{current_git_rev}")
    system("#{ssh_command} 'docker stop \$(docker ps -q)'")
    system("#{ssh_command} docker run "\
             "--name #{deploy_user} "\
             "#{APPLICATION_CONTAINER}:#{current_git_rev}")
  end

Three things happen here:

  • docker pull — pulls the container we just uploaded to the remote server. Notice the ssh_command method call, this is just a wrapper to avoid duplicating code each time we need to send a remote command.
  • docker stop $(docker ps -q) — this stops all running containers, so that we won't get any port conflicts when we run the new container.
  • docker run — starts the new container with the correct tag, and names it according to the current git user and timestamp. This is helpful us when we need to know who deployed the current running application. You can check this by entering the command docker ps on the remote server:
CONTAINER ID        IMAGE                                        COMMAND                  CREATED             STATUS              PORTS                    NAMES
01d777ef8d9a        mydockeruser/application-container:aa2da7a   "/bin/sh -c 'cd /t..."   10 minutes ago      Up 10 minutes       0.0.0.0:3000->3000/tcp   mygituser-29.03.17_01.09.43
if ARGV.empty?
  abort("Please inform action: \n\s- deploy")
end
application = Deployer.new(ARGV[0])

begin
  application.execute!
rescue Interrupt
  puts "\nDeploy aborted."
end

This receives arguments from CLI, and runs the deploy application. If you cancel the deploy with CTRL-C the rescue block returns a better message.

Deploying the Application

You should now have the following folder structure:

.
├── blog
│   ├── app
│   ├── bin
... (application files and folders)
├── deployer.rb
├── Dockerfile

Next, let's run and deploy your application:

$ ruby deployer.rb deploy

You'll see the output of every command that is executed. ALl outputs will be very similar to the ones we run manually, in the first example:

Sending build context to Docker daemon 4.846 MB
Step 1/9 : FROM ruby:2.3.1-slim
 ---> e523958caea8
Step 2/9 : COPY Gemfile* /tmp/
 ---> Using cache
 ---> f103f7b71338
Step 3/9 : WORKDIR /tmp
 ---> Using cache
 ---> f268a864efbc
Step 4/9 : RUN gem install bundler &&     apt-get update &&     apt-get install -y build-essential libsqlite3-dev rsync nodejs &&     bundle install --path vendor/bundle
 ---> Using cache
 ---> 7e9c77e52f81
Step 5/9 : RUN mkdir -p /app/vendor/bundle
 ---> Using cache
 ---> 1387419ca6ba
Step 6/9 : WORKDIR /app
 ---> Using cache
 ---> 9741744560e2
Step 7/9 : RUN cp -R /tmp/vendor/bundle vendor
 ---> Using cache
 ---> 5467eeb53bd2
Step 8/9 : COPY application.tar.gz /tmp
 ---> b2d26619a73c
Removing intermediate container 9835c63b601b
Step 9/9 : CMD cd /tmp &&     tar -xzf application.tar.gz &&     rsync -a blog/ /app/ &&     cd /app &&     RAILS_ENV=production bundle exec rake db:migrate &&     RAILS_ENV=production bundle exec rails s -b 0.0.0.0 -p 3000
 ---> Running in 8fafe2f238f1
 ---> c0617746e751
Removing intermediate container 8fafe2f238f1
Successfully built c0617746e751
The push refers to a repository [docker.io/mydockeruser/application-container]
e529b1dc4234: Pushed
08ee50f4f8a7: Layer already exists
33e5788c35de: Layer already exists
c3d75a5c9ca1: Layer already exists
0f94183c9ed2: Layer already exists
b58339e538fb: Layer already exists
317a9fa46c5b: Layer already exists
a9bb4f79499d: Layer already exists
9c81988c760c: Layer already exists
c5ad82f84119: Layer already exists
fe4c16cbf7a4: Layer already exists
aa2da7a: digest: sha256:a9a8f9ebefcaa6d0e0c2aae257500eae5d681d7ea1496a556a32fc1a819f5623 size: 2627
aa2da7a: Pulling from mydockeruser/application-container
1fad42e8a0d9: Already exists
5eb735ae5425: Already exists
b37dcb8e3fe1: Already exists
50b76574ab33: Already exists
c87fdbefd3da: Already exists
f1fe764fd274: Already exists
6c419839fcb6: Already exists
4abc761a27e6: Already exists
267a4512fe4a: Already exists
18d5fb7b0056: Already exists
219eee0abfef: Pulling fs layer
219eee0abfef: Verifying Checksum
219eee0abfef: Download complete
219eee0abfef: Pull complete
Digest: sha256:a9a8f9ebefcaa6d0e0c2aae257500eae5d681d7ea1496a556a32fc1a819f5623
Status: Downloaded newer image for mydockeruser/application-container:aa2da7a
01d777ef8d9a
c3ecfc9a06701551f31641e4ece78156d4d90fcdaeb6141bf6367b3428a2c46f

Your output may change due to different hashes and Docker cache. In the end, you'll get two hashes, as seen above:

01d777ef8d9a
c3ecfc9a06701551f31641e4ece78156d4d90fcdaeb6141bf6367b3428a2c46f

The first, small one, is the hash of the stopped container, and the last (big) one, is the new running container.

Yu can now access your remote server IP address and see your new application running.

Continuous Delivery with Semaphore

It is possible to use our script to automatically deploy our application with Semaphore. Let's see how.

First of all, in your Project Settings, set your platform to one with Docker support: Docker Support

On your Semaphore project page, click on "Set Up Deployment". SemaphoreCI Project Console

Select "Generic Deployment": Generic Deployment

Select "Automatic": Automatic Deployment

Select the branch (usually master): Branch selection

Since we just want to deploy our application, we can run the deploy script the same way we would run it on a local machine:

rbenv global 2.3.1
docker-cache restore
ruby deployer.rb deploy
docker-cache snapshot

Notice both docker-cache commands. They are responsible for retrieving your built images, so you don't need to build them from zero. Same as running locally, the first time will take a little more time, but the next ones will be faster. For more info, check the official documentation.

Also, take a note of the rbenv global 2.3.1 command. This sets the current ruby version, required to run our script. If you use another language, you must configure the required environment.

The next steps consist of uploading the SSH key for accessing your remote server (if needed), and naming your new server. After this, every time you push code to master branch, this script will be executed, deploying your application to your defined remote server.

Other Commands That Can Be Automated

In the following section, we'll take a look at several other helpful commands that we can automate.

Current version

We can track which application version is running by using container Tags as a description.

To retrieve the current running version, we need the following code:

def current
  remote_revision = `#{ssh_command} docker ps | grep -v CONTAINER | awk '{print $2}' | rev | cut -d: -f1 | rev`.strip

  abort('No running application.') if remote_revision == ''

  current_rev = `git show --ignore-missing --pretty=format:'%C(yellow)%h \
%C(blue)<<%an>> %C(green)%ad%C(yellow)%d%Creset %s %Creset' \
  #{running_revision} | head -1`.strip
  if current_rev.empty?
    puts 'Local revision not found, please update your master branch.'
  else
    puts current_rev
  end
  deploy_by = `#{ssh_command} docker ps --format={{.Names}}`
  puts "Deploy by: #{deploy_by}"
end

Here's what's happening in each line:

remote_revision = `#{ssh_command} docker ps | grep -v CONTAINER | awk '{print $2}' | rev | cut -d: -f1 | rev`.strip

This command takes care of the following:

  • Gets the output of the remote container status with docker ps,
  • Removes the headers of the output with grep -v CONTAINER,
  • Retrieves the second column (image name:tag) with awk '{print $2}',
  • Using the remaining command, cuts the image name in the : (colon), returning the last part, the commit hash, and
  • The .strip removes the linebreak at the end of the returned string.
abort('No running application.') if remote_revision == ''

If no container is running or no commit has been found, this aborts the command.

current_rev = `git show --ignore-missing --pretty=format:'%C(yellow)%h \
%C(blue)<<%an>> %C(green)%ad%C(yellow)%d%Creset %s %Creset' \
  #{running_revision} | head -1`.strip

This command finds the matching git log to the container hash, and prettifies the formatting.

if current_rev.empty?
   puts 'Local revision not found, please update your master branch.'
else
  puts current_rev
end

If the current git history does not have this commit, this asks the user to update the repository. This may happen due to new commits that have not yet been rebased from the local copy. If the commit is found, this prints the log info.

deploy_by = `#{ssh_command} docker ps --format={{.Names}}`

This command returns the running container name, which contains the user and the timestamp.

puts "Deploy by: #{deploy_by}"

The command above prints the deploy author and timestamp.

Logs

Most applications have logs, and sometimes we must take a look at them. We can use Docker’s built-in log system with a simple ssh connection to access our application logs more easily.

To output logs from the application, we can input the following:

def logs
  puts 'Connecting to remote host'
  system("#{ssh_command} 'docker logs -f --tail 100 \$(docker ps -q)'")
end

The docker logs command outputs all logs generated by the application. We use the follow flag -f to stay connected, reading all logs as a stream. The --tail flag limits the amount of old logs to print. The last part $(docker ps -q) returns the ID of every container running in the remote host. Since we're running just our application, there is no problem with retrieving all containers.

Note* that our example application does not return any logs to Docker, because it writes every log to a file. You can change this behavior by setting the environment variable RAILS_LOG_TO_STDOUT=true when starting the application.

Docker Installation and Login

For new hosts, it’s a good idea to have a “setup” command to install and configure all requirements.

We can accomplish this in two steps: installation and login.

def docker_setup
  puts 'Installing Docker on remote host'
  system("#{ssh_command} -t 'wget -qO- https://get.docker.com/ | sh'")

  puts 'Adding the remote user to Docker group'
  system("#{ssh_command} 'sudo usermod -aG docker #{HOST_USER}'")

  puts 'Adding the remote user to Docker group'
  system("#{ssh_command} -t 'docker login}'")
end

Let's explain each command:

system("#{ssh_command} -t 'wget -qO- https://get.docker.com/ | sh'")

This command runs a Docker installation script. It will ask for the remote user password, so we need the flag -t so we can enter it when asked.

system("#{ssh_command} 'sudo usermod -aG docker #{HOST_USER}'")

This command adds the remote user to the Docker group. This is needed so we can run docker commands without sudo.

system("#{ssh_command} -t 'docker login'")

This is needed since we have to be logged in so we can download our updated application. The -t flag allows typing.

Rolling back

If anything went wrong with the new running application, it is important to be able to roll back to a previous version. Using this container approach, every deployed version stays saved on the host, and can be started instantly.

Take a look at the following code snippet:

def rollback
  puts 'Fetching last revision from remote server.'
  previous_revision = `#{ssh_command} docker images | grep -v 'none\|latest\|REPOSITORY' | awk '{print $2}' | sed -n 2p`.strip
  abort('No previous revision found.') if previous_revision == ''
  puts "Previous revision found: #{previous_revision}"
  puts "Restarting application!"
  system("#{ssh_command} 'docker stop \$(docker ps -q)'")
  system("#{ssh_command} docker run --name #{deploy_user} #{APPLICATION_CONTAINER}:#{previous_revision}")
end

Let's see what is happening in each step:

  puts 'Fetching last revision from remote server.'
  previous_revision = `#{ssh_command} docker images | grep -v 'none\|latest\|REPOSITORY' | awk '{print $2}' | sed -n 2p`.strip
  abort('No previous revision found.') if previous_revision == ''

This command greps the previous container tag from all Docker images present on the remote host. This tag is the git commit short hash, that will be used as reference for rolling back our application. If no previous image is found, the rollback is aborted.

  system("#{ssh_command} 'docker stop \$(docker ps -q)'")

This shuts down all running containers, so we can start the previous one.

  system("#{ssh_command} docker run --name #{deploy_user} #{APPLICATION_CONTAINER}:#{previous_revision}")

This command starts the application with the tag we found in the previous step. We can use the same naming convention that is used in the deploy method (deploy_user).

Conclusion

If you went through every step of the tutorial, you should now have a fully functioning automation tool for deploying your software. This can be helpful when dealing with applications that must be easily deployed, but can't be on hosts like Heroku or other automated environments.

if you found this tool to be helpful, feel free to share this tutorial with your friends. Also, you're more than welcome to leave any comments or questions you might have.

Happy shipping.

P.S. Want to continuously deliver your applications made with Docker? Check out Semaphore’s Docker platform with full layer caching for tagged Docker images.

C456c99bd027622154db5f9e695afa7c
Pedro Cavalheiro

Back End Software Developer who loves the web and innovation. Pedro believes in the power of exchanging knowledge with other people, and loves high quality code, development best practices, and automation. Find him at */pecavalheiro.

on this tutorial so far.
User deleted author {{comment.createdAt}}

Edited on {{comment.updatedAt}}

Cancel

Sign In You must be logged in to comment.