Packaging images

So far, we have been quite happily downloading prebuilt images from the Docker Hub to test with. Next up, we are going to be looking at creating our own images. Before we dive into creating images using third-party tools, we should have a quick look at how to go about building them in Docker.

An application

Before we start building our own images, we should really have an application to "bake" into it. I suspect you are probably getting bored of doing the same WordPress installation over and over again. We are going to be looking at something completely different.

So, we are going to build an image that has Moby Counter installed. Moby counter is an application written by Kai Davenport, who describes it as follows:

"A small app to demonstrate keeping state inside a docker-compose application."

The application runs in a browser and will add a Docker logo to the page wherever you click, the idea being that it uses a Redis or Postgres backend to store the number of Docker logos and their positions, which demonstrates how data can persist on volumes such as the ones we looked at in Chapter 3, Volume Plugins. You can find the GitHub repository for the application at https://github.com/binocarlos/moby-counter/.

The Docker way

Now that we know a little about the application we are going to be launching, let's take a look at how the image would be built using Docker itself.

The code for this part of the chapter is available from the GitHub repository that accompanies this book; you can find it at https://github.com/russmckendrick/extending-docker/tree/master/chapter06/images/docker.

The Dockerfile for the basic build is quite simple:

FROM russmckendrick/nodejs
ADD . /srv/app
WORKDIR /srv/app
RUN npm install
EXPOSE 80
ENTRYPOINT ["node", "index.js"]

When we run the build, it will download the russmckendrick/nodejs image from the Docker Hub; this, as you may have guessed, has NodeJS installed.

Once that image has been downloaded, Docker will launch the container and add the content of the current working directory, which contains the Moby Counter code. It will then change the working directory to where the the code was uploaded to /srv/app.

It will then install the prerequisites required to run the application by issuing the npm install command; as we have set the working directory, all of the commands will be run from that location, meaning that the package.json file will be used.

Accompanying the Dockerfile is a Docker Compose file, this kicks off the build of the Moby Counter image, downloads the official Redis image, and then launches the two containers, linking them together.

Before we do that, we need to bring up a machine to run the build on; to do this, run the following command to launch a local VirtualBox-based Docker host:

docker-machine create --driver "VirtualBox" chapter06

Now that the Docker host has been launched, run the following to configure your local Docker client to talk directly to it:

eval $(docker-machine env chapter06)

Now that you have the host ready and client configured, run the following to build the image and launch the application:

docker-compose up -d

When you run the command, you should see something like the following output in your terminal:

The Docker way

Now that the application has been launched, you should be able to open your browser by running this:

open http://$(docker-machine ip chapter06)/

You will see a page that says Click to add logos, if you were to click around the page, Docker logos would start appearing. If you were to click on refresh, the logos you added would remain as the number of the logos, their position being stored in the Redis database.

The Docker way

To stop the containers and remove them, run the following commands:

docker-compose stop
docker-compose rm

Before we look into the pros and cons of using the Docker approach to building container images, let's look at a third-party alternative.

Building with Packer

Packer is written by Mitchell Hashimoto from Hashicorp, the same author as Vagrant's. Because of this, there are quite a lot of similarities in the terms we will be using.

The Packer website has probably the best description of the tool:

"Packer is an open source tool for creating identical machine images for multiple platforms from a single source configuration. Packer is lightweight, runs on every major operating system, and is highly performant, creating machine images for multiple platforms in parallel. Packer does not replace configuration management like Chef or Puppet. In fact, when building images, Packer is able to use tools like Chef or Puppet to install software onto the image."

I have been using Packer since its first release to build images for both Vagrant and public clouds.

You can download Packer from https://www.packer.io/downloads.html or, if you installed Homebrew, you can run the following command:

brew install packer

Now that you have Packer installed, let's take a look at a configuration file. Packer configuration files are all defined in JSON.

Note

JavaScript Object Notation (JSON) is a lightweight data-interchange format. It is easy for humans to read and write and for machines to parse and generate.

The following file does almost exactly what our Dockerfile did:

{
  "builders":[{
    "type": "docker",
    "image": "russmckendrick/nodejs",
    "export_path": "mobycounter.tar"
  }],
  "provisioners":[
    {
      "type": "file",
      "source": "app",
      "destination": "/srv"
    }, 
    {
      "type": "file",
      "source": "npmrc",
      "destination": "/etc/npmrc"
    },
    {
      "type": "shell",
      "inline": [
        "cd /srv/app",
        "npm install"
      ]
    }
  ]
}

Again, all of the files required to build the image, along with the Docker Compose file to run it, are in the GitHub repository at https://github.com/russmckendrick/extending-docker/tree/master/chapter06/images/packer.

Rather than using the Docker Compose file to build the image, we are going to have to run packer and then import the image file. To start the build, run the following command:

packer build docker.json

You should see the following in your terminal:

Building with Packer

Once Packer has built the image, it will save a copy to the folder you initiated the Packer build command from; in our case, the image file is called mobycounter.tar.

To import the image so that we can use it, run the following command:

docker import mobycounter.tar mobycounter

This will import the image and name it mobycounter; you can check whether the image is available by running this:

docker images

You should see something like this:

Building with Packer

Once you have confirmed the image has been imported and is called mobycounter, you can launch a container by running this:

docker-compose up -d

Again, you will be able to open your browser and start clicking around to place logos by running this:

open http://$(docker-machine ip chapter06)/

While there may not seem to be much difference, let's take a look at what's going on under the hood.

Packer versus Docker Build

Before we go into detail about the difference between the two methods of building images, let's try running Packer again.

This time though, let's to try and reduce the image size: rather than using the russmckendrick/nodejs image, which has nodejs preinstalled, let's use the base image that this was built on, russmckendrick/base.

This image just has bash installed; install NodeJS and the application using Packer:

{
  "builders":[{
    "type": "docker",
    "image": "russmckendrick/base",
    "export_path": "mobycounter-small.tar"
  }],
  "provisioners":[
    {
      "type": "file",
      "source": "app",
      "destination": "/srv"
    }, 
    {
      "type": "file",
      "source": "npmrc",
      "destination": "/etc/npmrc"
    }, 
    {
      "type": "shell",
      "inline": [
        "apk update",
        "apk add --update nodejs",
        "npm -g install npm",
        "cd /srv/app",
        "npm install",
        "rm -rf /var/cache/apk/**/",
        "npm cache clean"
      ]
    }
  ]
}

As you can see, we have added a few more commands to the shell provisioner; these use Alpine Linux's package manager to perform an update, install nodejs, configure the application, and finally, clean both the apk and npm caches.

If you like, you can build the image using the following command:

packer build docker-small.json

This will leave us with two image files. I also exported a copy of the container we built using the Dockerfile using the following command while the container was running:

docker export docker_web_1 > docker_web.tar

I now have three image files, and all three are running the same application, with the same software stack installed, using as close to the same commands as possible. As you can see from the following list of file sizes, there is a difference in the image size:

  • Dockerfile (using russmckendrick/nodejs) = 52 MB
  • Packer (using russmckendrick/nodejs) = 47 MB
  • Packer (installing the full stack using packer) = 40 MB

12 MB may not seem like a lot, but when you are dealing with an image that is only 52 MB big, that's quite a decent saving.

So why is there a difference? Let's start by discussing the way in which Docker images work.

They are essentially made up of layers of changes on top of a base. When we built our first image using the Dockerfile, you may have noticed that each line of the Dockerfile generated a different step in the build process.

Each step is actually Docker starting a new filesystem layer to store the changes for that step of the build. So, for example, when our Dockerfile ran, we had six filesystem layers:

FROM russmckendrick/nodejs
ADD . /srv/app
WORKDIR /srv/app
RUN npm install
EXPOSE 80
ENTRYPOINT ["node", "index.js"]

The first layer contains the base operating system along with the layers on which NodeJS is installed, and the second layer contains the files for the application itself.

The third layer just contains the metadata for setting the workdir variable; next up, we have the layer that contains the NodeJS dependencies for the application. The fifth and sixth layers just contain the metadata needed to configure which ports are exposed and what the "entry point" is.

As each of these layers is effectively a separate archive within the image file, we also have the additional overhead of these archives within our image file.

A better example of how the layers work is to look at some of the most popular images from the Docker Hub in the ImageLayers website, which can be found at https://imagelayers.io/.

This site is a tool provided by Century Link Labs (https://labs.ctl.io/) to visualize Docker images that have been built from a Dockerfile.

As you can see from the following screenshot, some of the official images are quite complex and also quite large:

Packer versus Docker Build

You can view the previous page at the following URL:

https://imagelayers.io/?images=java:latest,golang:latest,node:latest,python:latest,php:latest,ruby:latest.

Even while the official images should be getting smaller thanks to Docker hiring the creator of Alpine Linux and moving the official images over to the smaller base operating system (check out the following hacker news post for more information https://news.ycombinator.com/item?id=11000827), it does not change the amount of layers required for each image. It's also worth pointing out that each image can have a maximum of 127 layers.

So what does Packer do differently? Rather than creating a separate filesystem layer for each step, it produces only two: the first layer is the base image you define, and the second one is everything else—this is where our space savings come in.

The other advantage of using Packer over Dockfiles is that you can reuse your scripts. Imagine you were doing your local development work using Docker but when you launched into production, you for one reason or another had to launch on one of the containerized virtual machines. Using Packer, you can do exactly that knowing that you could actually use the same set of build scripts to bootstrap your virtual machines as you did for your development containers.

As I have already mentioned, I have been using Packer for a while and it helps to no end to have a single tool that you can use to target different platforms with the same set of build scripts. The consistency this approach brings is well worth the initial effort of learning a tool such as Packer as you will end up saving a lot of time in the long run; it also helps with eliminating the whole "worked in development" meme we discussed at the start of Chapter 1, Introduction to Extending Docker.

There are some downsides to using this approach, which may put some people off.

The biggest one in my opinion is that while you are able to push the final image automatically to the Docker Hub, you will not be able to add it as an automated build.

This means that while it may be available for people to download, it might not be considered trusted as people cannot see exactly what has been added to the image.

Next up is the lack of support for metadata—functions that configure runtime options such as exposing ports and the command executed by default when the container launches are not currently supported.

While this can be seen as a drawback, it is easily overcome by defining what you would have defined in your Dockerfile in a Docker Compose file or passing the information directly using the docker run command.

Image summary

So, to summarize, if you need to build not only container images but also target different platforms, then Packer is exactly the tool you are after. If it's just container images you need to build, then you may be better off sticking with the Dockerfile build.

Some of the other tools we have looked at in this chapter, such as Ansible and Puppet, also support building images by issuing a docker build command against a Dockerfile, so there are plenty of ways to build that into your workflow, which leads us to the next tool we are going be looking at: Jenkins.

Before we move on, let's quickly just double-check that you are not running any Docker hosts. To do this, run the following commands to check for any Docker hosts and then remove them:

docker-machine ls
docker-machine rm <name-of-host>

Don't forget to only remove hosts that you are using for following along with this book; don't remove any you are using for you own projects!

..................Content has been hidden....................

You can't read the all page of ebook, please click here login for view all page.
Reset