Building and publishing a Docker image with Packer

A few weeks ago I was playing around with the Hashicorp stack using Vagrant, Nomad and Consul. I was just trying to build a cluster of machines to deploy a Docker-based Golang service. I could have done the work with Dockerfiles directly to provision the container, but I decided instead to give Packer a try.

Packer is a Hashicorp tool used to provision virtual machine images that can be deployed to a cloud provider (e.g. AWS, Azure, Oracle, etc…) Packer can also be used to provision Docker containers.

In this post, I am going to walk through what I did to take a compile a simple Golang-based service, build it as a Docker container and then push it to Docker hub.

Note: All of the source code for this project can be found here.

Why did I choose Go for my microservice example?

I have worked with a lot of different programming languages throughout my career. Many of them are excellent languages. I chose Go because frankly it is super simple to learn and compiles down to a single native, binary executable. A single binary executable makes provisioning containers a snap because you are usually only worried about one thing when you are provisioning you are a container, installing the binary. I will take simplicity any day of the week and Go in most cases fits the bill for what I need for day-to-day development.

Building the Go microservice

All code for the Go service is located in the src directory of the Github repository. This simple service does nothing more then expose a /healthcheck endpoint on port 8888. In a *nix environment you can run the make file command to build the project. If you are running in a Windows environment, you can change to the src directory and run go build.

Building the Docker Container

Once you have a binary you can build the Docker container using Packer. Packer lets you declaratively define how you want to provision a virtual image or container in a human readable text format. Packer supports two different markup languages for defining the provisioning process: JSON and HCL (Hashicorp Markup Language). For this example, we are going to use HCL. HCL tends to be much less verbose than JSON and since I was doing this work in the context of other Hashicorp projects (Nomand and Consul) I wanted to stick with HCL as this is the most popular markup language used in these other tools.

I am not going to walk through the syntax of HCL here. For a well-written explanation of HCL, I suggest you take a look Adam Bertram’s excellent overview of the subject here.

Note: If you are going to use HCL with Packer, your provisioning file needs to end in .hcl.

Let’s go ahead and walk through what is in our Packer provisioning file. This file is located in the provision directory and called orders.pkr.hcl.

In the orders.pkr.hcl file above we have four key activities:

  1. Defining the plugins used we are going to use in Packer.

  2. Defining input variables to generalize the build configuration.

  3. Configuring the Docker image we are going to build our image from.

  4. Building Docker container and pushing it to Docker hub.

The code contained in this file looks like this:


#Defining the plugins used we are going to use in Packer.
packer {
  required_plugins {
    docker = {
      version = ">= 1.0.1"
      source  = "github.com/hashicorp/docker"
    }
  }
}

#Defining input variables for values for generalizing the build.
variable  "docker_repo" {
  type = string
  sensitive = true
}

variable  "docker_username" {
  type = string
  sensitive = true
}

variable "docker_password" {
  type = string
  sensitive = true
}

#Configuring the Docker image we are going to build our image from
source "docker" "golang" {
  image       = "golang"
  commit      = true
  pull        = true
  changes = [
    "EXPOSE 8888 8888",
    "ENTRYPOINT [\"/bin/orders\"]"
  ]
}

#Building Docker container and pushing it to Docker hub.
build {
  sources = ["source.docker.golang"]

  provisioner "file" {
    source      = "../bin/orders"
    destination = "/bin/"
  }
  
  post-processors {
    post-processor "docker-tag" {
      repository = var.docker_repo
      tags       = ["1.0"]
    }

    post-processor "docker-push" {
      login=true
      login_username = var.docker_username
      login_password = var.docker_password
    }
  }  
}

Defining the plugins used we are going to use in Packer.

All HCL files contain one or more stanzas in them. A stanza defines a discrete piece of functionality that is going to be carried out by the application processing the block. In the case of our Packer file, the first stanze intializes Packer and tells it what “plugins” are going to be used in. The plugin are how Hashicorp makes their products extensible. Some of their products (e.g. Terraform) allow the open source community to write and contribute plugins. In the case of Packer, each different environment in which you can provision a VM or Docker container instance is its own plugin.

1
2
3
4
5
6
7
8
packer {
  required_plugins {
    docker = {
      version = ">= 1.0.1"
      source  = "github.com/hashicorp/docker"
    }
  }
}

Packer’s plugin system uses semantic versioning to determine what version of a plugin in should be used with this configuration. In the lines 4 and 5 above, we are indicating that this script can use any of the Focker plugins that are of version 1.0.1. or higher.

Note: The source attribute above does not match exactly to the Hashicorp source control repository. The source for the Docker plugin can be found here.

Defining input variables for values for generalizing the build

Packer allows you to generalize your provisioning scripts through the use of variables. Packer has three types of variables: input, output, and local. In this blog post we are only going to demonstrate the usage of input variables. Input variables are set before the configuration is processed. At runtime, these variables can be set by passing them in the command-line or as environment variables.

In our Packer configuration we define three input variables that will be used to control how we publish our built Docker image to a Docker repository (e.g. Docker Hub):

  1. docker_repo. The Docker repository we are going to publish the image to.

  2. docker_username. The Docker Hub account user name the Packer configuration is going to use for publication.

  3. docker_password. The Docker Hub account password the Packer configuration is going to use for publication.

Let’s take a look at how these input variables are defined in our orders.pkr.hcl file.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
variable  "docker_repo" {
  type = string
  sensitive = true
}

variable  "docker_username" {
  type = string
  sensitive = true
}

variable "docker_password" {
  type = string
  sensitive = true
}

We are only scratching the surface on Packers variable capabilities. For a much more detailed description of Packer’s variable system please take a look at the Packer variable documentation.

Configuring the Docker image we are going to build our image from.

Next we are going to define how we are going to configure the base Docker image we are going to use for a build. To do this we are going to use the Packer source stanze.

source "docker" "golang" {
  image       = "golang"
  commit      = true
  pull        = true
  changes = [
    "EXPOSE 8888 8888",
    "ENTRYPOINT [\"/bin/orders\"]"
  ]
}

In our source stanze above we are telling Packer we are going to use the docker source for configuration. We give the source a logical name (e.g. golang). Then we configure the source via number of attributes. These attributes include:

  1. image. This indicates the Docker hub image we are going to build our image from. In this case the image we are going to use is the golang image found on Docker Hub. This image can found here.

  2. commit. The commit attribute is a boolean value that will tell Packer whether the container we are building will be committed to an repo as a Docker image or tarred up into a tar file. A true value indicates the container will be created as a Docker image and published to a Docker repository. A false value tells the plugin to create a tar file.

  3. pull. The pull attribute is a boolean value that will tell Packer whether or not it should try to pull from Docker hub before it tries to build. If the attribute is true Packer will do a pull from Docker hub. If the attribute is false the image will assumed to be in the local Docker repository running on the machine running Packer and will not attemp to pull it from Docker hub.

  4. changes. This is the main configuration point for changes we are going to make to our Docker container. In the example above, we are going to expose port 8888 on the Docker container we are building and tell Docker that when the container is started it shoudl run /bin/orders. This is the where the orders binary is going to be placed when the container image is built. Remember, the orders binary was built in the “Building the Go microservice” section of the blog.

Building the Docker container and pushing it to a Docker registry.

The last part of our Packer build process is to actually build the Docker container and then push it to a Docker registry. To do this we are going to use the build stanza. This stanze has multiple parts and we will walkthrough them all in the section. Here is the source code for the build stanza:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
build {
  sources = ["source.docker.golang"]

  provisioner "file" {
    source      = "../bin/orders"
    destination = "/bin/"
  }
  
  post-processors {
    post-processor "docker-tag" {
      repository = var.docker_repo
      tags       = ["1.0"]
    }

    post-processor "docker-push" {
      login=true
      login_username = var.docker_username
      login_password = var.docker_password
    }
  }  
}

Let’s look through the highlighted lines above to understand the builder stanza. The first area of interest is the sources attribute on line 2 of the example above. This attribute contains a list of Docker images that we are going to build. The value in this list is the concatanated values of the source stanza we declared earlier in the file (e.g. source.docker.golang).

Note: I have no idea why Hashicorp made the source attribute a list of strings. There might be a use case for it, but I am not seeing it.

The provisioner stanza, on line 4 above, is used to tell Packer where to put a file in the Docker image. In this case we are putting the binary for the orders service (located in the ../bin directory locally ) into the /bin directory for our Docker image. This is the binary that will be executed when our Docker image is started.

The next stanza, post-processors defines a list of post-processor stanzas. The post-processor stanza is used to tell the builder what actions to take after the Docker image is built. In our example above, we have two post-processor stanzas. The first stanza, docker-tag (line 10) has two attributes: repository and value. The repository attributes the specific repository you are going to be creating the Docker image against. The repository attribute follows the convention of “docker userid/image name”. So for my repository it would be set to johncarnell/orders. The tags attributes defines a list of tags that will be applied to a Docker image. Tags are often used to uniquely identify multiple versions of the same Docker image. The tags attribute for our example is hard-coded with single value of “1.0”. This is a hard-coded value but could easily be replaced with a generated tag passed in a packer variable.

The second post-processor stanza docker-push(line 15) is used to push the image to a remote Docker repository after the image has built locally.. This stanza takes three attributes: login, login_user, and login_password.

The login attribute is a boolean value that tells the docker-push stanza to login into a Docker registry. The login_user and login_password values provide the user credentials needed to login into a Docker registry. By default the docker-push stanza will use the Docker Hub registry. If you want to point to a different public or private registry you need to set the login_server attribute. For more information on using the docker-push post-processor stanza you can visit the packer.io page.

Wrapping things up

At this point we have walked through all of the pieces of our Docker packer setup. Assuming you have already built the orders service binary you can build the Docker container using the packer command shown below. We are going to set pass in our three defined variables (docker_username,docker_password,docker_repo) using the -var command line parameters. The full command below is shown below with the individual values for my variables shown below.

packer build -var "docker_username=*****" -var "docker_password=*****" -var "docker_repo=*****" orders.pkr.hcl 

Packer is a great tool and makes it straightforward to build images across a large number of cloud providers. While its simple to build Docker images using Docker, the flexibility and deep feature set of Packer makes it easy to justify using it to build your Docker images.