Containers and Virtualization Auto Docker Updates Lead image: Lead Image © yelenayemchuk, 123RF.com
Lead Image © yelenayemchuk, 123RF.com
 

Update your Docker containers safely

Safe Harbor

A scripted approach lets you know when to update your Docker containers. By Hans-Cees Speel

Docker containers are a great way to produce lean and portable services, not only for the enterprise, but also at home. Docker takes the complexity out of setting up software services that are inherently complicated, like an email or a Nextcloud server. However, it's difficult to know when and how to update the software inside the Docker container to keep it as secure as possible.

In this article, I present a scripted approach to alerting you about when to update Docker containers. The first script checks Docker Hub for image tags or versions available. If you want to update, you need to know your software version and adjust the image tag accordingly, so updates stay within a safe range of versions. The second set of scripts checks your local version against possible updates on Docker Hub. One mini-script produces an ID file containing the two Docker image IDs and another checks for updates available on the Hub.

With these scripts, you can automate your checks for available updates. The updating process itself is left in your hands.

Updating a Docker Container

Many official software projects like Nextcloud, MariaDB, Nginx, and so on maintain official Docker images. Often, though, their prescription for Docker is a Docker Compose file in which you install some latest version of the image; however, it is not advisable to keep blindly updating to a latest version. For instance, the Nginx container defined in the Docker Compose file shown in Listing 1, when run, pulls an nginx:alpine image from Docker Hub, where nginx is the image and alpine is the tag (referred to hereafter as <image><tag>).

Listing 1: docker-compose.yml

version: '2'
services:
  web:
    image: nginx:alpine
    restart: always
    container_name: nginx
    ports:
      - 8080:80
    environment:
      - NGINX_HOST=www.somewebsite.com
      - NGINX_PORT=80
    command: "nginx -g 'daemon off;'"

If you pull nginx:alpine, you pull a so-called "latest" version. Several commonly used tags also pull a latest version, such as <image>:latest, <image>:stable, nginx:alpine, nextcloud:fpm-alpine, and so on. By "latest," I mean the tag has no version (e.g., alpine-10.4).

If you leave the latest tag in your configuration files while updating the container, you are in for trouble: The software version might go from nginx-8.3 to nginx-11.2, which, bottom line, could break things. On the other hand, if you do not update at all, the software after some time will contain known vulnerabilities (which is why you update in the first place, of course).

Depending on the software in the container, you should decide what version changes are probably safe or, in other words, the version range in which you want to update. By version range, I mean it's probably fine to upgrade nextcloud-10.4.1 to nextcloud-10.4.8, but it's not advisable to update blindly from version 10.4 to version 12.3. A safe version range, then, would be to stay within version 10.4. Of course, it's entirely up to you what risks to take and what you consider a safe version range.

To update a Docker container within a safe version range, you need two pieces of information: the image version you are using right now and what <image><tag> you will use when updating, so you don't run into trouble.

Image Version

To know whether your Docker container needs an update within a safe version range, you need to know what version you are running. The answer lies in a set of identifiers and tags to be found in the manifest that is also downloaded when you pull an image. You can inspect the manifest in two steps: Find the image ID on your local machine, and inspect its manifest for a version tag.

You can inspect container or image manifests on a Docker host (a host running Docker containers) as shown in Listing 2. The docker image command produces the image ID of running containers. In this case, my running container uses the nginx image with the id bf85f2b6bf52.

Listing 2: Inspecting Image Manifests

docker images
REPOSITORY   TAG      IMAGE ID       CREATED         SIZE
nginx_web    latest   c100b674c0b5   13 months ago   19MB
nginx        alpine   bf85f2b6bf52   13 months ago   15.5MB

With the image ID in hand, you can inspect the image manifest:

docker inspect bf85f2b6bf52 | egrep -i version

In this case, it yields: NGINX_VERSION=1.13.7.

An easy way to get the versions of all running Docker containers is shown in Listing 3. When I run the script on my Nextcloud machine, it yields (together with some lines of no use):

"NEXTCLOUD_VERSION=12.0.4"
MARIADB_MAJOR=10.2   MARIADB_VERSION=10.2.12+maria~jessie

What Tag?

Listing 3: Docker Container Versions

## get versions for running containers
for i in `docker ps | egrep -v NAMES | awk '{print $1}'`; do
  echo " image-ID is now $i"
  docker inspect --format='{{.Config.Env}}' $i
  docker inspect $i | egrep VERSION
done

Now that you know your software versions, the next question to ask is what tag to use in docker-compose.yml. You can't use nginx:alpine:1.13, so what tag can you use?

The answer depends, of course, on what tags a software project has issued for its images on Docker Hub. I like to use the script in Listing 4, which asks the Docker Hub Registry API to list all available tags. To download the script, make it executable, and run it, enter:

curl https://raw.githubusercontent.com/  hanscees/dockerscripts/master/scripts/get-docker-hub--image-tags.sh > get-public-image-tags.sh
chmod +x get-public-image-tags.sh
./get-public-image-tags.sh library/nginx

Listing 4: get-public-image-tags.sh

01 #!/bin/bash
02
03 # Retrieves image tags from public
04 # images in DockerHub
05 # if someone improves this script, please let me know, preferably in Python
06 # hanscees@AT@hanscees.com
07 # modified from https://gist.github.com/cirocosta/17ea17be7ac11594cb0f290b0a3ac0d1x
08
09 set -o errexit
10
11 main() {
12   check_args "$@"
13
14   local image=$1
15   local token=$(get_token $image)
16   local tags=$(get_tags $image $token)
17   echo "tags reported are:"
18   echo $tags
19 }
20
21 get_token() {
22   local image=$1
23
24   echo "Retrieving Docker Hub token.
25     IMAGE: $image
26   " >&2
27
28   curl --silent "https://auth.docker.io/token?scope=repository:$image:pull&service=registry.docker.io" | jq -r '.token'
29 }
30
31 # Retrieve the digest, now specifying in the header
32 # that we have a token (so we can ...
33 get_tags() {
34   local image=$1
35   local token=$2
36
37   echo "Retrieving image tags.
38     IMAGE:  $image
39   " >&2
40
41   curl --silent --header "Accept: application/vnd.docker.distribution.manifest.v2+json" --header "Authorization: Bearer $token" "https://registry-1.docker.io/v2/$image/tags/list" | jq -r '.tags'
42 }
43 check_args() {
44   if (($# != 1)); then
45     echo "Error:
46     One argument must be provided - $# provided.
47
48     Usage:
49       ./get-docker-hub-image-tags.sh <image>
50       for instance ./get-docker-hub-image-tags.sh library/mariadb
51 Aborting."
52     exit 1
53   fi
54 }
55
56 main "$@"

In the last line, I prepended library/ to nginx: All official Docker Hub repositories require this prefix.

The output comprises many tags, including various versions of alpine, perl, and alpine-perl, along with their mainline and stable versions (Table 1).

Tabelle 1: get-public-image-tags.sh Output

Tags

1.13.3-alpine

1.13.8-alpine

1.8-alpine

1.13.3-perl

1.13.8-perl

1.8.1-alpine

1.13.3

1.13.8

1.8.1

1.13.5-alpine-perl

1.13.9-alpine-perl

1.8

1.13.5-alpine

1.13.9-alpine

alpine

1.13.5-perl

1.13.9-perl

latest

1.13.5

1.13.9

mainline-alpine-perl

1.13.6-alpine-perl

1.13

mainline-alpine

1.13.6-alpine

1.14-alpine-perl

mainline-perl

1.13.6-perl

1.14-alpine

mainline

1.13.6

1.14-perl

perl

1.13.7-alpine-perl

1.15-alpine

stable-alpine-perl

1.13.7-alpine

1.15.3-alpine

stable-alpine

1.13.7-perl

1.15.3-perl

stable-perl

1.13.7

1.15.3

stable

1.13.8-alpine-perl

1.15.4-alpine-perl

The get-public-image-tags.sh script uses jq (a command-line JSON processor) in lines 28 and 41 to parse the JSON. On Ubuntu, the command to install the utility is:

apt-get install jq

As you can see, Nginx has quite a few tags. It also becomes clear how to reference a version of nginx:alpine: Simply use nginx:<version>-alpine. Because Nginx seems unlikely to change in any important way, I would suggest considering the nginx:1.15-alpine tag in the YAML file.

Automating Update Checks

When I first pulled down an Nginx image as nginx:alpine in Listing 1, it turned out to be version 1.13. Using safe version logic, I should change the nginx:alpine tag in the YAML file to nginx:1.15-alpine.

Now I can update manually by running:

docker-compose pull
docker-compose up -d

Alternatively, you can let an automated solution update your containers. Watchtower [1] (a Docker container), can do this for you, although I have not tested it.

However, I want to stick with manual updating, and I simply want to know when updates are available for <image><tag> (compared with my current image, of course). If an update is available, I can make a snapshot of the virtual machine on which Docker runs, update, and roll back if things go wrong. This time, I use a script and learn something about image IDs, as well.

First, you need two more pieces of information: what version and image ID are you running and how it compares with the latest container ID of the version to which you want to update. The first item was already solved above, but not the second.

Repo ID vs. Image ID

The image ID was discovered by analyzing the manifest file of an image and extracting the version of the software. However, for an automated solution, this process won't do because every software project uses versions in a slightly different way. Therefore, you need a more general ID.

A Docker manifest uses several IDs, two of which are the image ID, which is a hash of the uncompressed image, and the repo ID (repo digest), which is a hash of the compressed image when it sits in the registry (from which you pull your images). Both are digests, by the way, but with different data payload forms.

Confusingly, different Docker command-line options show both IDs or parts of both, as you can see in the confusing output of the docker images command in Listing 5, which shows information about images you pulled to your computer.

Listing 5: docker images Output

docker images --digests
REPOSITORY   TAG           DIGEST                                                                    IMAGE ID       CREATED         SIZE
nginx        1.15-alpine   sha256:385fbcf0f04621981df6c6f1abd896101eb61a439746ee2921b26abc78f45571   315798907716   5 days ago      17.8MB
nginx        alpine        sha256:f1ca87d9adb678b180c31bf21eb9798b043c22571f419ed844bca1d103f2a2f7   bf85f2b6bf52   13 months ago   15.5MB

The output shows information on both the original nginx:alpine image and the updated nginx:1.15-alpine. The Image ID column refers to a short version of the (local unpacked) image ID and the Digest column refers to the repo ID.

If you want to see both the full image hash and the repo digest of an image on a Docker host, the command in Listing 6 shows both.

Listing 6: docker image inspect

01 docker image inspect 315798907716 | head
02     {
03         "Id": "sha256:315798907716a51610bb3c270c191e0e61112b19aae9a3bb0c2a60c53d074750",
04         "RepoTags": [
05             "nginx:1.15-alpine"
06         ],
07         "RepoDigests": [
08             "nginx@sha256:385fbcf0f04621981df6c6f1abd896101eb61a439746ee2921b26abc78f45571"

The "Id" (line 3) shows the image ID: The first 12 characters are the same as the output from docker images; the repo ID is the repo digest. By the way, docker image inspect reads a downloaded image manifest file that you get when you pull an image.

For the automated update check, you will collect both the image and repo IDs, but only the image ID can be used to automate a check without pulling an image because of the Docker Registry API (as far as I know).

A Scripted Update Check

After searching and finding some Bash scripts on GitHub that work on the Docker Registry API, I have come up with this solution:

1. A local Docker script first produces a local file on a Docker host with per-line <image><tag><current image ID> and a second line with <image><tag><current repo ID>. You can run this script on many Docker hosts, combine lines, and use one central host to check for updates on many Docker images you might have in production.

2. A second script reads the local file and, for each line, checks Docker Hub for the <image ID> of the latest version of <image><tag>; compares the local image tag to the image ID of latest on the Hub; and lets you know what local Docker services thus can be updated (if local and remote IDs don't match).

Although you can probably find many ways to solve this problem, the script lets you check a number of different images at the same time. Also, a centralized check is possible by combining with a centralized scanning solution.

The scripts to do this can be found on GitHub [2], which you can either clone or download one by one (Listing 7), because Git is quite a large package [3].

Listing 7: Downloading Update Scripts

mkdir dockerscripts
cd dockerscripts
curl https://raw.githubusercontent.com/hanscees/dockerscripts/master/scripts/check-docker-image-updates.sh > check-docker-image-updates.sh
 curl https://raw.githubusercontent.com/hanscees/dockerscripts/master/scripts/get-docker-hub--image-tags.sh > get-docker-hub--image-tags.sh
 curl https://raw.githubusercontent.com/hanscees/dockerscripts/master/scripts/get-docker-hub-image-tag-digest.sh > get-docker-hub-image-tag-digest.sh
 curl https://raw.githubusercontent.com/hanscees/dockerscripts/master/scripts/emailer.py > emailer.py
#-chmod them
   chmod +x *.sh
   chmod +x  *.py
#- install qs
  apt-get install jq

Local Docker Script

The script to get Docker file IDs on a Docker host is not much of a script, really; just copy and paste the code in Listing 8 in a terminal to produce the ImageId-file output file, which has two lines per image, as shown in Listing 9. The first line holds the repo ID, the second the image ID. Each line contains the image name, the tag name, and either the image ID or repo ID. If the last word of the line is RepoId, the line holds the repo ID, making it easy to grep lines with the right kind of ID.

Listing 9: ImageId-file List

nginx 1.15-alpine nginx@sha256:385fbcf0f04621981df6c6f1abd896101eb61a439746ee2921b26abc78f45571 RepoId
nginx 1.15-alpine 315798907716a51610bb3c270c191e0e61112b19aae9a3bb0c2a60c53d074750
nextcloud latest nextcloud@sha256:78515af937fe6c6d0213103197e09d88bbf9ded117b9877db59e8d70dbdae6b2 RepoId
nextcloud latest 8757ce9de782c2dd746a1dd702178b8309ca6d2feb5e84bad9184441170d4898
mariadb latest mariadb@sha256:12e32f8d1e8958cd076660bc22d19aa74f2da63f286e100fb58d41b740c57006 RepoId
mariadb latest b468922dbbd73bdc874c751778f1ec0ec10817691624976865cb3ec5c70cd4e0
mvance/unbound 1.8.3 mvance/unbound@sha256:d67469fad9cc965f032e4ea736c509df6d009245dac81339e2c6e1caef9b65ac RepoId
mvance/unbound 1.8.3 mvance/unbound latest a88e44773675294dcd10e08a9a2a5ee2a39796e5a832c99606b3c8a54901ea75

Listing 8: Get Docker File IDs

> ImageId-file
for i in `docker images -f dangling=false| egrep -v TAG | awk '{print $3}'` ; do
  echo copying name, tag and image ID digest to file
  ImageId=`docker image inspect $i | jq -r '.[0] | {Id: .Id}' | egrep Id | awk -F":" '{print $3'} | awk -F"\"" '{print $1}'`
  RepoId=`docker image inspect $i | jq -r '.[0] | {RepId: .RepoDigests}' | jq --raw-output  '.RepId | .[]'`
  ImageData=`docker images | egrep $i | awk '{print $1," " , $2}'`
  echo $ImageData $ImageId
  echo $ImageData $RepoId RepoId >> ImageId-file
  echo $ImageData $ImageId >> ImageId-file
done

You should check ImageId-file, because it might contain lines about images you do not use anymore: The script will produce lines on all images used in containers, even if you no longer run those containers or have not yet run them. Make sure to discard any "old" containers.

A Central Docker Host Check

If you run the script in Listing 8 on a Docker host, it will collect ID lines on all containers on that host. If you have multiple Docker hosts, you can run the script on multiple hosts and combine the lines into one big file on a central host; of course, you could automate this process and run checks on a central Docker host for all the containers you are using, run checks for updates for your <image><tag> combinations, and download the images and check them against known vulnerabilities.

With the repo ID lines collected, all images can be downloaded on the central host, because the repo ID can be used to pull images with docker pull <repo ID>; for instance,

docker pull mariadb@sha256:12e32f8d1e8958cd076660bc22d19aa74f2da63f286e100fb58d41b740c57006

will download that specific MariaDB image from Docker Hub. Therefore, you can download many images to a central host without actually running them in a container with,

for image in `cat ImageId-file | egrep RepoId | awk '{print $3}'` ; do
   echo $image ;
   docker pull $image
done

and show them by entering docker images.

Once you have your images on one host, you will find multiple solutions to check your images against known vulnerabilities. Examples are Anchore, Clair, or Dagda, which all run on Docker, so they are relatively easy to set up [4].

In the next section, however, I show you how to check for container updates – without the need to download the images, by the way.

Docker Updates Script

At this point you have the image ID file, which knows what Docker <image><tag> pairs you have, and the IDs of these images as used in production. Now, you need to see whether these images can be updated.

The first part of the update check script downloads a manifest file from Docker Hub for a specified Docker <image><tag> using the Registry API. The second part of the script does two things: Loops over all image lines, downloading a manifest file for each, and checks the local image ID against the ID from the downloaded manifest. If they differ, an update is available.

Listing 10 shows the first part of the script, which fetches a digest from Docker Hub. As it states (if you run it without arguments), you should feed it two arguments:

./get-docker-hub-digest library/nginx 1.15-alpine

Listing 10: get-docker-hub-digest

01 #!/bin/bash
02
03 # Retrieves image digest from public
04 # images in DockerHub
05 # modified from https://gist.github.com/cirocosta/17ea17be7ac11594cb0f290b0a3ac0d1x
06
07 set -o errexit
08
09 main() {
10   check_args "$@"
11
12   local image=$1
13   local tag=$2
14   local token=$(get_token $image)
15   local digest=$(get_digest $image $tag $token)
16
17 echo " $digest"
18
19 }
20
21 get_token() {
22   local image=$1
23
24   echo "Retrieving Docker Hub token.
25     IMAGE: $image
26   " >&2
27
28   curl --silent "https://auth.docker.io/token?scope=repository:$image:pull&service=registry.docker.io" | jq -r '.token'
29 }
30
31 # Retrieve the digest, now specifying in the header
32 # that we have a token (so we can ...
33 get_digest() {
34   local image=$1
35   local tag=$2
36   local token=$3
37
38   echo "Retrieving image digest.
39     IMAGE:  $image
40     TAG:    $tag
41   " >&2
42 #    TOKEN:  $token # add to echo for debug
43
44   curl --silent --header "Accept: application/vnd.docker.distribution.manifest.v2+json" --header "Authorization: Bearer $token" "https://registry-1.docker.io/v2/$image/manifests/$tag"
45 #    | jq -r '.config.digest'
46 }
47
48 check_args() {
49   if (($# != 2)); then
50     echo "Error:
51     Two arguments must be provided - $# provided.
52
53     Usage:
54       ./get-docker-hub-digest.sh <image> <tag>
55      for instance ./get-docker-hub-digest library/nginx 1.15-alpine
56
57 Aborting."
58     exit 1
59   fi
60 }
61 main "$@"

The script spits out a JSON file with a series of digests, of which the first is the image ID (not the repo ID, for some reason).

The second part of the check script (Listing 11) you will actually use. It checks for updates on Docker images and uses the script in Listing 10 to get digests. You can feed it the image ID file produced in Listing 10 (without the RepoId lines) with:

cat ImageId-file | egrep -v "RepoId|none|^$" | ./check-docker-image-updates.sh

Listing 11: check-docker-image-updates.sh

001 #!/bin/bash
002
003 # Checks Docker Hub for updates on <image><tag><ImageId>
004 # ImageId without sha256: part
005 # script accepts <image> <tag> <ImageId> or sdtin
006
007 # reads file from standard in: lines in the form of
008 # nginx 1.14-alpine #315798907716a51610bb3c270c191e0e61112b19aae9a3bb0c2a60c53d074750
009 # mvance/unbound latest 4568745687569875689745689756
010 #
011 # Script calls other script ./getdigest. Can be found at #https://github.com/hanscees/dockerscripts
012
013 set -o errexit
014 > UpdateTheseImages
015 main() {
016
017 REPOS=""
018 TAG=""
019 ID=""
020
021 if [ -t 0 ] ; then
022   echo terminal input; #and no stdin
023   check_args "$@"  #if input not from stdin
024   REPOS=$1
025   TAG=$2
026   ID=$3
027   report_result $REPOS $TAG $ID
028 else
029   echo "not a terminal, so reading stdin";
030   while read line;  do
031   declare -a ImageData=($line) #bash array
032   REPOS=${ImageData[0]}
033   TAG=${ImageData[1]}
034   ID=${ImageData[2]}
035   report_result $REPOS $TAG $ID
036   done
037 fi
038 }  #end main
039
040 report_result () {
041   REPOS=$1
042   TAG=$2
043   ID=$3
044   echo "repo/image and tag are "
045   echo $REPOS $TAG
046
047   check_result=$(check_for_updates $REPOS $TAG $ID)
048   echo check_result is $check_result
049
050   if [ $check_result ]
051   then
052    echo $REPOS $TAG can be updated: a new version is available
053    echo also check file UpdateTheseImages
054   else
055    echo NO update found for $REPOS $TAG
056   fi
057   }
058
059 check_for_updates () {
060   REPOS=$1
061   TAG=$2
062   ID=$3
063   local myresult=0 #default return value
064   timestamp=`date --rfc-3339=seconds`
065   echo $timestamp >> debug
066   echo $REPOS $TAG >> debug
067   blub=`echo $REPOS | egrep "\/"`
068   if [ ! "$blub" ] ; then REPOS=library/$REPOS ;fi  #add library/ before repo if needed
069   DockerIdNew=`./get-docker-hub-image-tag-digest.sh $REPOS $TAG | jq -r '.config.digest' | awk -F':' '{print $2}' `
070   echo DockeridNew is $DockerIdNew >> debug
071   echo DockerIdLocal is $ID >>debug
072   if [ ! $DockerIdNew ] ; then myresult="" ;echo $myresult ; exit ;fi    #if empty image was probably built locally
073
074   if [ "$DockerIdNew" == "$ID" ]
075   then
076     #echo "you r good, no updates for $REPOS:$TAG"
077     myresult=""
078     echo $myresult
079   else
080     #echo "update available for $REPOS:$TAG"
081     echo "update available for $REPOS:$TAG" >> UpdateTheseImages
082     echo "update available for $REPOS:$TAG" >> debug
083     myresult=1
084     echo $myresult
085   fi
086   }
087
088 check_args() {
089   if (($# != 3)); then
090     echo "Error:
091     Three argument must be provided - $# provided.
092
093     Usage:
094       ./check-docker-image-updates.sh <image> <tag> <imageID>
095       for instance ./check-docker-image-updates.sh library/mariadb latest 2345623745234753647
096       imageID is local digest image without sha256: part
097 Aborting."
098     exit 1
099   fi
100 }
101
102 main "$@"

The output will be:

nginx alpine can be updated: a new version is available
also check file UpdateTheseImages

Two files are produced: UpdateTheseImages, which holds lines on images that need updating, and debug, which holds logging on found IDs. Use cat to see the content of UpdateTheseImages:

cat UpdateTheseImages
update available for library/nextcloud:latest
update available for library/mariadb:latest
update available for mvance/unbound:1.8.3

The GitHub repository also has a Python email script, should you want to email update messages to a local SMTP server.

Conclusion

Playing around with Docker is fun, and before you know it, you have multiple Docker hosts running services. However, all software needs updating to keep it safe, and software in containers is no exception. In this article, I showed a practical way to start automating update notices of Dockerized services, so you know when to update.