Bug 1316183 - Compress docker images with zstd. r=dustin
authorJonas Finnemann Jensen <jopsen@gmail.com>
Mon, 07 Nov 2016 11:26:27 -0800
changeset 322128 2168cbd4b15410c24f6947e9218385dd8a0ab3dd
parent 322127 a2ea3980ed5df53c242ba569fc89282281744329
child 322129 09a8134e26ea7b22e6b3c316d2089f4a0ffe5456
push id83791
push usercbook@mozilla.com
push dateFri, 11 Nov 2016 15:44:04 +0000
treeherdermozilla-inbound@e3096c5d3ec2 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersdustin
bugs1316183
milestone52.0a1
first release with
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
last release without
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
Bug 1316183 - Compress docker images with zstd. r=dustin * Compress docker images with zstd * Removed need for context.tar from decision task * Index images by level rather than project MozReview-Commit-ID: 4RL4QXNWmpd
.taskcluster.yml
taskcluster/ci/docker-image/image.yml
taskcluster/mach_commands.py
taskcluster/taskgraph/docker.py
taskcluster/taskgraph/task/docker_image.py
taskcluster/taskgraph/test/test_taskgraph.py
taskcluster/taskgraph/transforms/task.py
taskcluster/taskgraph/util/docker.py
testing/docker/README.md
testing/docker/image_builder/Dockerfile
testing/docker/image_builder/VERSION
testing/docker/image_builder/bin/build_image.sh
testing/docker/image_builder/build-image.sh
testing/docker/image_builder/setup.sh
--- a/.taskcluster.yml
+++ b/.taskcluster.yml
@@ -114,16 +114,12 @@ tasks:
               --head-rev='{{revision}}'
               --revision-hash='{{revision_hash}}'
 
         artifacts:
           'public':
             type: 'directory'
             path: '/home/worker/artifacts'
             expires: '{{#from_now}}364 days{{/from_now}}'
-          'public/docker_image_contexts':
-            type: 'directory'
-            path: '/home/worker/docker_image_contexts'
-            expires: '{{#from_now}}7 days{{/from_now}}'
 
       extra:
         treeherder:
           symbol: D
--- a/taskcluster/ci/docker-image/image.yml
+++ b/taskcluster/ci/docker-image/image.yml
@@ -5,56 +5,64 @@ task:
   deadline:
     relative-datestamp: "24 hours"
   metadata:
     name: 'Docker Image Build: {{image_name}}'
     description: 'Build the docker image {{image_name}} for use by dependent tasks'
     source: '{{source}}'
     owner: mozilla-taskcluster-maintenance@mozilla.com
   tags:
-    createdForUser: {{owner}}
+    createdForUser: '{{owner}}'
 
   workerType: taskcluster-images
   provisionerId: aws-provisioner-v1
   schedulerId: task-graph-scheduler
 
   routes:
-      - index.docker.images.v1.{{project}}.{{image_name}}.latest
-      - index.docker.images.v1.{{project}}.{{image_name}}.pushdate.{{year}}.{{month}}-{{day}}-{{pushtime}}
-      - index.docker.images.v1.{{project}}.{{image_name}}.hash.{{context_hash}}
+      # Indexing routes to avoid building the same image twice
+      - index.{{index_image_prefix}}.level-{{level}}.{{image_name}}.latest
+      - index.{{index_image_prefix}}.level-{{level}}.{{image_name}}.pushdate.{{year}}.{{month}}-{{day}}-{{pushtime}}
+      - index.{{index_image_prefix}}.level-{{level}}.{{image_name}}.hash.{{context_hash}}
+      # Treeherder routes
       - tc-treeherder.v2.{{project}}.{{head_rev}}.{{pushlog_id}}
       - tc-treeherder-stage.v2.{{project}}.{{head_rev}}.{{pushlog_id}}
 
+  scopes:
+      - secrets:get:project/taskcluster/gecko/hgfingerprint
+      - docker-worker:cache:level-{{level}}-imagebuilder-v1
+
   payload:
     env:
       HASH: '{{context_hash}}'
       PROJECT: '{{project}}'
       CONTEXT_URL: '{{context_url}}'
-      CONTEXT_PATH: '{{context_path}}'
-      BASE_REPOSITORY: '{{base_repository}}'
-      HEAD_REPOSITORY: '{{head_repository}}'
-      HEAD_REV: '{{head_rev}}'
-      HEAD_REF: '{{head_ref}}'
+      IMAGE_NAME: '{{image_name}}'
+      GECKO_BASE_REPOSITORY: '{{base_repository}}'
+      GECKO_HEAD_REPOSITORY: '{{head_repository}}'
+      GECKO_HEAD_REV: '{{head_rev}}'
+      HG_STORE_PATH: '/home/worker/checkouts/hg-store'
+    cache:
+      'level-{{level}}-imagebuilder-v1': '/home/worker/checkouts'
     features:
       dind: true
       chainOfTrust: true
+      taskclusterProxy: true
     image: '{{#docker_image}}image_builder{{/docker_image}}'
-    command:
-      - /bin/bash
-      - -c
-      - /home/worker/bin/build_image.sh
     maxRunTime: 3600
     artifacts:
       '{{artifact_path}}':
         type: 'file'
-        path: '/artifacts/image.tar'
+        path: '/home/worker/workspace/artifacts/image.tar.zst'
         expires:
           relative-datestamp: "1 year"
   extra:
+    imageMeta: # Useful when converting back from JSON in action tasks
+      level: '{{level}}'
+      contextHash: '{{context_hash}}'
+      imageName: '{{image_name}}'
     treeherderEnv:
       - staging
       - production
     treeherder:
       jobKind: other
       build:
         platform: 'taskcluster-images'
       symbol: 'I'
-
--- a/taskcluster/mach_commands.py
+++ b/taskcluster/mach_commands.py
@@ -269,16 +269,22 @@ class TaskClusterImagesProvider(object):
         except Exception:
             traceback.print_exc()
             sys.exit(1)
 
     @Command('taskcluster-build-image', category='ci',
              description='Build a Docker image')
     @CommandArgument('image_name',
                      help='Name of the image to build')
-    def build_image(self, image_name):
-        from taskgraph.docker import build_image
-
+    @CommandArgument('--context-only',
+                     help="File name the context tarball should be written to."
+                          "with this option it will only build the context.tar.",
+                     metavar='context.tar')
+    def build_image(self, image_name, context_only):
+        from taskgraph.docker import build_image, build_context
         try:
-            build_image(image_name)
+            if context_only is None:
+                build_image(image_name)
+            else:
+                build_context(image_name, context_only)
         except Exception:
             traceback.print_exc()
             sys.exit(1)
--- a/taskcluster/taskgraph/docker.py
+++ b/taskcluster/taskgraph/docker.py
@@ -13,17 +13,17 @@ import tarfile
 import tempfile
 import urllib2
 import which
 
 from taskgraph.util import docker
 
 GECKO = os.path.realpath(os.path.join(__file__, '..', '..', '..'))
 IMAGE_DIR = os.path.join(GECKO, 'testing', 'docker')
-INDEX_URL = 'https://index.taskcluster.net/v1/task/docker.images.v1.{}.{}.hash.{}'
+INDEX_URL = 'https://index.taskcluster.net/v1/task/' + docker.INDEX_PREFIX + '.{}.{}.hash.{}'
 ARTIFACT_URL = 'https://queue.taskcluster.net/v1/task/{}/artifacts/{}'
 
 
 def load_image_by_name(image_name):
     context_path = os.path.join(GECKO, 'testing', 'docker', image_name)
     context_hash = docker.generate_context_hash(GECKO, context_path, image_name)
 
     image_index_url = INDEX_URL.format('mozilla-central', image_name, context_hash)
@@ -34,19 +34,24 @@ def load_image_by_name(image_name):
 
 
 def load_image_by_task_id(task_id):
     # because we need to read this file twice (and one read is not all the way
     # through), it is difficult to stream it.  So we download to disk and then
     # read it back.
     filename = 'temp-docker-image.tar'
 
-    artifact_url = ARTIFACT_URL.format(task_id, 'public/image.tar')
+    artifact_url = ARTIFACT_URL.format(task_id, 'public/image.tar.zst')
     print("Downloading", artifact_url)
-    subprocess.check_call(['curl', '-#', '-L', '-o', filename, artifact_url])
+    tempfilename = 'temp-docker-image.tar.zst'
+    subprocess.check_call(['curl', '-#', '-L', '-o', tempfilename, artifact_url])
+    print("Decompressing")
+    subprocess.check_call(['zstd', '-d', tempfilename, '-o', filename])
+    print("Deleting temporary file")
+    os.unlink(tempfilename)
 
     print("Determining image name")
     tf = tarfile.open(filename)
     repositories = json.load(tf.extractfile('repositories'))
     name = repositories.keys()[0]
     tag = repositories[name].keys()[0]
     name = '{}:{}'.format(name, tag)
     print("Image name:", name)
@@ -61,16 +66,31 @@ def load_image_by_task_id(task_id):
 
     print("Deleting temporary file")
     os.unlink(filename)
 
     print("The requested docker image is now available as", name)
     print("Try: docker run -ti --rm {} bash".format(name))
 
 
+def build_context(name, outputFile):
+    """Build a context.tar for image with specified name.
+    """
+    if not name:
+        raise ValueError('must provide a Docker image name')
+    if not outputFile:
+        raise ValueError('must provide a outputFile')
+
+    image_dir = os.path.join(IMAGE_DIR, name)
+    if not os.path.isdir(image_dir):
+        raise Exception('image directory does not exist: %s' % image_dir)
+
+    docker.create_context_tar(GECKO, image_dir, outputFile, "")
+
+
 def build_image(name):
     """Build a Docker image of specified name.
 
     Output from image building process will be printed to stdout.
     """
     if not name:
         raise ValueError('must provide a Docker image name')
 
--- a/taskcluster/taskgraph/task/docker_image.py
+++ b/taskcluster/taskgraph/task/docker_image.py
@@ -2,32 +2,30 @@
 # License, v. 2.0. If a copy of the MPL was not distributed with this
 # file, You can obtain one at http://mozilla.org/MPL/2.0/.
 
 from __future__ import absolute_import, print_function, unicode_literals
 
 import logging
 import json
 import os
-import re
 import urllib2
 
 from . import base
 from taskgraph.util.docker import (
-    create_context_tar,
     docker_image,
     generate_context_hash,
+    INDEX_PREFIX,
 )
 from taskgraph.util.templates import Templates
 
 logger = logging.getLogger(__name__)
 GECKO = os.path.realpath(os.path.join(__file__, '..', '..', '..', '..'))
 ARTIFACT_URL = 'https://queue.taskcluster.net/v1/task/{}/artifacts/{}'
 INDEX_URL = 'https://index.taskcluster.net/v1/task/{}'
-INDEX_REGEX = r'index\.(docker\.images\.v1\.(.+)\.(.+)\.hash\.(.+))'
 
 
 class DockerImageTask(base.Task):
 
     def __init__(self, *args, **kwargs):
         self.index_paths = kwargs.pop('index_paths')
         super(DockerImageTask, self).__init__(*args, **kwargs)
 
@@ -49,67 +47,41 @@ class DockerImageTask(base.Task):
             'base_repository': params['base_repository'] or params['head_repository'],
             'head_repository': params['head_repository'],
             'head_ref': params['head_ref'] or params['head_rev'],
             'head_rev': params['head_rev'],
             'owner': params['owner'],
             'level': params['level'],
             'source': '{repo}file/{rev}/taskcluster/ci/docker-image/image.yml'
                       .format(repo=params['head_repository'], rev=params['head_rev']),
+            'index_image_prefix': INDEX_PREFIX,
+            'artifact_path': 'public/image.tar.zst',
         }
 
         tasks = []
         templates = Templates(path)
         for image_name in config['images']:
             context_path = os.path.join('testing', 'docker', image_name)
+            context_hash = generate_context_hash(GECKO, context_path, image_name)
 
             image_parameters = dict(parameters)
-            image_parameters['context_path'] = context_path
-            image_parameters['artifact_path'] = 'public/image.tar'
             image_parameters['image_name'] = image_name
-
-            image_artifact_path = \
-                "public/docker_image_contexts/{}/context.tar.gz".format(image_name)
-            if os.environ.get('TASK_ID'):
-                # We put image context tar balls in a different artifacts folder
-                # on the Gecko decision task in order to have longer expiration
-                # dates for smaller artifacts.
-                destination = os.path.join(
-                    os.environ['HOME'],
-                    "docker_image_contexts/{}/context.tar.gz".format(image_name))
-                image_parameters['context_url'] = ARTIFACT_URL.format(
-                    os.environ['TASK_ID'], image_artifact_path)
-
-                destination = os.path.abspath(destination)
-                if not os.path.exists(os.path.dirname(destination)):
-                    os.makedirs(os.path.dirname(destination))
-
-                context_hash = create_context_tar(GECKO, context_path,
-                                                  destination, image_name)
-            else:
-                # skip context generation since this isn't a decision task
-                # TODO: generate context tarballs using subdirectory clones in
-                # the image-building task so we don't have to worry about this.
-                image_parameters['context_url'] = 'file:///tmp/' + image_artifact_path
-                context_hash = generate_context_hash(GECKO, context_path, image_name)
-
             image_parameters['context_hash'] = context_hash
 
             image_task = templates.load('image.yml', image_parameters)
-
             attributes = {'image_name': image_name}
 
-            # As an optimization, if the context hash exists for mozilla-central, that image
+            # As an optimization, if the context hash exists for a high level, that image
             # task ID will be used.  The reasoning behind this is that eventually everything ends
-            # up on mozilla-central at some point if most tasks use this as a common image
+            # up on level 3 at some point if most tasks use this as a common image
             # for a given context hash, a worker within Taskcluster does not need to contain
             # the same image per branch.
-            index_paths = ['docker.images.v1.{}.{}.hash.{}'.format(
-                                project, image_name, context_hash)
-                           for project in ['mozilla-central', params['project']]]
+            index_paths = ['{}.level-{}.{}.hash.{}'.format(
+                                INDEX_PREFIX, level, image_name, context_hash)
+                           for level in range(int(params['level']), 4)]
 
             tasks.append(cls(kind, 'build-docker-image-' + image_name,
                              task=image_task['task'], attributes=attributes,
                              index_paths=index_paths))
 
         return tasks
 
     def get_dependencies(self, taskgraph):
@@ -121,37 +93,34 @@ class DockerImageTask(base.Task):
                 url = INDEX_URL.format(index_path)
                 existing_task = json.load(urllib2.urlopen(url))
                 # Only return the task ID if the artifact exists for the indexed
                 # task.  Otherwise, continue on looking at each of the branches.  Method
                 # continues trying other branches in case mozilla-central has an expired
                 # artifact, but 'project' might not. Only return no task ID if all
                 # branches have been tried
                 request = urllib2.Request(
-                    ARTIFACT_URL.format(existing_task['taskId'], 'public/image.tar'))
+                    ARTIFACT_URL.format(existing_task['taskId'], 'public/image.tar.zst'))
                 request.get_method = lambda: 'HEAD'
                 urllib2.urlopen(request)
 
                 # HEAD success on the artifact is enough
                 return True, existing_task['taskId']
             except urllib2.HTTPError:
                 pass
 
         return False, None
 
     @classmethod
     def from_json(cls, task_dict):
         # Generating index_paths for optimization
-        routes = task_dict['task']['routes']
-        index_paths = []
-        for route in routes:
-            index_path_regex = re.compile(INDEX_REGEX)
-            result = index_path_regex.search(route)
-            if result is None:
-                continue
-            index_paths.append(result.group(1))
-            index_paths.append(result.group(1).replace(result.group(2), 'mozilla-central'))
+        imgMeta = task_dict['task']['extra']['imageMeta']
+        image_name = imgMeta['imageName']
+        context_hash = imgMeta['contextHash']
+        index_paths = ['{}.level-{}.{}.hash.{}'.format(
+                            INDEX_PREFIX, level, image_name, context_hash)
+                       for level in range(int(imgMeta['level']), 4)]
         docker_image_task = cls(kind='docker-image',
                                 label=task_dict['label'],
                                 attributes=task_dict['attributes'],
                                 task=task_dict['task'],
                                 index_paths=index_paths)
         return docker_image_task
--- a/taskcluster/taskgraph/test/test_taskgraph.py
+++ b/taskcluster/taskgraph/test/test_taskgraph.py
@@ -6,36 +6,49 @@ from __future__ import absolute_import, 
 
 import unittest
 
 from ..graph import Graph
 from ..task.docker_image import DockerImageTask
 from ..task.transform import TransformTask
 from ..taskgraph import TaskGraph
 from mozunit import main
+from taskgraph.util.docker import INDEX_PREFIX
 
 
 class TestTargetTasks(unittest.TestCase):
 
     def test_from_json(self):
+        task = {
+            "routes": [],
+            "extra": {
+                "imageMeta": {
+                    "contextHash": "<hash>",
+                    "imageName": "<image>",
+                    "level": "1"
+                }
+            }
+        }
+        index_paths = ["{}.level-{}.<image>.hash.<hash>".format(INDEX_PREFIX, level)
+                       for level in range(1, 4)]
         graph = TaskGraph(tasks={
             'a': TransformTask(
                 kind='fancy',
                 task={
                     'label': 'a',
                     'attributes': {},
                     'dependencies': {},
                     'when': {},
                     'task': {'task': 'def'},
                 }),
             'b': DockerImageTask(kind='docker-image',
                                  label='b',
                                  attributes={},
-                                 task={"routes": []},
-                                 index_paths=[]),
+                                 task=task,
+                                 index_paths=index_paths),
         }, graph=Graph(nodes={'a', 'b'}, edges=set()))
 
         tasks, new_graph = TaskGraph.from_json(graph.to_json())
         self.assertEqual(graph.tasks['a'], new_graph.tasks['a'])
         self.assertEqual(graph, new_graph)
 
 if __name__ == '__main__':
     main()
--- a/taskcluster/taskgraph/transforms/task.py
+++ b/taskcluster/taskgraph/transforms/task.py
@@ -344,17 +344,17 @@ def payload_builder(name):
 def build_docker_worker_payload(config, task, task_def):
     worker = task['worker']
 
     image = worker['docker-image']
     if isinstance(image, dict):
         docker_image_task = 'build-docker-image-' + image['in-tree']
         task.setdefault('dependencies', {})['docker-image'] = docker_image_task
         image = {
-            "path": "public/image.tar",
+            "path": "public/image.tar.zst",
             "taskId": {"task-reference": "<docker-image>"},
             "type": "task-image",
         }
 
     features = {}
 
     if worker.get('relengapi-proxy'):
         features['relengAPIProxy'] = True
--- a/taskcluster/taskgraph/util/docker.py
+++ b/taskcluster/taskgraph/util/docker.py
@@ -13,16 +13,17 @@ import tempfile
 
 from mozpack.archive import (
     create_tar_gz_from_files,
 )
 
 
 GECKO = os.path.realpath(os.path.join(__file__, '..', '..', '..', '..'))
 DOCKER_ROOT = os.path.join(GECKO, 'testing', 'docker')
+INDEX_PREFIX = 'docker.images.v2'
 ARTIFACT_URL = 'https://queue.taskcluster.net/v1/task/{}/artifacts/{}'
 
 
 def docker_image(name, default_version=None):
     '''Determine the docker image name, including repository and tag, from an
     in-tree docker file.'''
     try:
         with open(os.path.join(DOCKER_ROOT, name, 'REGISTRY')) as f:
--- a/testing/docker/README.md
+++ b/testing/docker/README.md
@@ -34,17 +34,17 @@ will use that indexed task.  This is to 
 that were built from the same context. In summary, if the image has been built for mozilla-central,
 pushes to any branch will use that already built image.
 
 To use within an in-tree task definition, the format is:
 
 ```yaml
 image:
   type: 'task-image'
-  path: 'public/image.tar'
+  path: 'public/image.tar.zst'
   taskId: '{{#task_id_for_image}}builder{{/task_id_for_image}}'
 ```
 
 ##### Context Directory Hashing
 
 Decision tasks will calculate the sha256 hash of the contents of the image
 directory and will determine if the image already exists for a given branch and hash
 or if a new image must be built and indexed.
@@ -62,19 +62,19 @@ of the context directory.
 This ensures that the hash is consistently calculated and path changes will result
 in different hashes being generated.
 
 ##### Task Image Index Namespace
 
 Images that are built on push and uploaded as an artifact of a task will be indexed under the
 following namespaces.
 
-* docker.images.v1.{project}.{image_name}.latest
-* docker.images.v1.{project}.{image_name}.pushdate.{year}.{month}-{day}-{pushtime}
-* docker.images.v1.{project}.{image_name}.hash.{context_hash}
+* docker.images.v2.level-{level}.{image_name}.latest
+* docker.images.v2.level-{level}.{image_name}.pushdate.{year}.{month}-{day}-{pushtime}
+* docker.images.v2.level-{level}.{image_name}.hash.{context_hash}
 
 Not only can images be browsed by the pushdate and context hash, but the 'latest' namespace
 is meant to view the latest built image.  This functions similarly to the 'latest' tag
 for docker images that are pushed to a registry.
 
 ### Docker Registry Images (prebuilt)
 
 ***Deprecation Warning: Use of prebuilt images should only be used for base images (those that other images
--- a/testing/docker/image_builder/Dockerfile
+++ b/testing/docker/image_builder/Dockerfile
@@ -1,34 +1,40 @@
-FROM ubuntu:14.04
+FROM ubuntu:16.04
+
+# %include testing/docker/recipes/tooltool.py
+ADD topsrcdir/testing/docker/recipes/tooltool.py /setup/tooltool.py
+
+# %include testing/docker/recipes/common.sh
+ADD topsrcdir/testing/docker/recipes/common.sh /setup/common.sh
 
-WORKDIR /home/worker/bin
+# %include testing/docker/recipes/install-mercurial.sh
+ADD topsrcdir/testing/docker/recipes/install-mercurial.sh /setup/install-mercurial.sh
+
+# %include testing/mozharness/external_tools/robustcheckout.py
+ADD topsrcdir/testing/mozharness/external_tools/robustcheckout.py /usr/local/mercurial/robustcheckout.py
+
+# %include testing/docker/recipes/run-task
+ADD topsrcdir/testing/docker/recipes/run-task /usr/local/bin/run-task
 
-RUN apt-get update && apt-get install -y apt-transport-https
-RUN sudo apt-key adv --keyserver hkp://keyserver.ubuntu.com:80 --recv-keys 36A1D7869245C8950F966E92D8576A8BA88D21E9 && \
-    sudo sh -c "echo deb https://get.docker.io/ubuntu docker main\
-    > /etc/apt/sources.list.d/docker.list"
-RUN apt-get update && apt-get install -y \
-    lxc-docker-1.6.1 \
-    curl \
-    wget \
-    git \
-    mercurial \
-    tar \
-    zip \
-    unzip \
-    vim \
-    sudo \
-    ca-certificates \
-    build-essential
+# Add and run setup script
+ADD build-image.sh      /usr/local/bin/build-image.sh
+ADD setup.sh            /setup/setup.sh
+RUN bash /setup/setup.sh
+
+# Setup a workspace that won't use AUFS
+VOLUME /home/worker/workspace
 
-ENV NODE_VERSION v0.12.4
-RUN cd /usr/local/ && \
-    curl https://nodejs.org/dist/$NODE_VERSION/node-$NODE_VERSION-linux-x64.tar.gz | tar -xz --strip-components 1 && \
-    node -v
+# Set variable normally configured at login, by the shells parent process, these
+# are taken from GNU su manual
+ENV           HOME          /home/worker
+ENV           SHELL         /bin/bash
+ENV           USER          worker
+ENV           LOGNAME       worker
+ENV           HOSTNAME      taskcluster-worker
+ENV           LC_ALL        C
 
-RUN npm install -g taskcluster-vcs@2.3.11
+# Create worker user
+RUN useradd -d /home/worker -s /bin/bash -m worker
 
-ADD bin /home/worker/bin
-RUN chmod +x /home/worker/bin/*
-
-# Set a default command useful for debugging
-CMD ["/bin/bash", "--login"]
+# Set some sane defaults
+WORKDIR /home/worker/
+CMD     build-image.sh
--- a/testing/docker/image_builder/VERSION
+++ b/testing/docker/image_builder/VERSION
@@ -1,1 +1,1 @@
-0.1.5
+1.0.0
deleted file mode 100755
--- a/testing/docker/image_builder/bin/build_image.sh
+++ /dev/null
@@ -1,37 +0,0 @@
-#!/bin/bash -vex
-
-# Set bash options to exit immediately if a pipeline exists non-zero, expand
-# print a trace of commands, and make output verbose (print shell input as it's
-# read)
-# See https://www.gnu.org/software/bash/manual/html_node/The-Set-Builtin.html
-set -x -e -v
-
-# Prefix errors with taskcluster error prefix so that they are parsed by Treeherder
-raise_error() {
-   echo
-   echo "[taskcluster-image-build:error] $1"
-   exit 1
-}
-
-# Ensure that the PROJECT is specified so the image can be indexed
-test -n "$PROJECT" || raise_error "Project must be provided."
-test -n "$HASH" || raise_error "Context Hash must be provided."
-
-mkdir /artifacts
-
-if [ ! -z "$CONTEXT_URL" ]; then
-    mkdir /context
-    if ! curl -L --retry 5 --connect-timeout 30 --fail "$CONTEXT_URL" | tar -xz --strip-components 1 -C /context; then
-        raise_error "Error downloading image context from decision task."
-    fi
-    CONTEXT_PATH=/context
-else
-    tc-vcs checkout /home/worker/workspace/src $BASE_REPOSITORY $HEAD_REPOSITORY $HEAD_REV $HEAD_REF
-    CONTEXT_PATH=/home/worker/workspace/src/$CONTEXT_PATH
-fi
-
-test -d $CONTEXT_PATH || raise_error "Context Path $CONTEXT_PATH does not exist."
-test -f "$CONTEXT_PATH/Dockerfile" || raise_error "Dockerfile must be present in $CONTEXT_PATH."
-
-docker build -t $PROJECT:$HASH $CONTEXT_PATH
-docker save $PROJECT:$HASH > /artifacts/image.tar
new file mode 100755
--- /dev/null
+++ b/testing/docker/image_builder/build-image.sh
@@ -0,0 +1,59 @@
+#!/bin/bash -vex
+
+# Set bash options to exit immediately if a pipeline exists non-zero, expand
+# print a trace of commands, and make output verbose (print shell input as it's
+# read)
+# See https://www.gnu.org/software/bash/manual/html_node/The-Set-Builtin.html
+set -x -e -v
+
+# Prefix errors with taskcluster error prefix so that they are parsed by Treeherder
+raise_error() {
+  echo
+  echo "[taskcluster-image-build:error] $1"
+  exit 1
+}
+
+# Ensure that the PROJECT is specified so the image can be indexed
+test -n "$PROJECT"    || raise_error "PROJECT must be provided."
+test -n "$HASH"       || raise_error "Context HASH must be provided."
+test -n "$IMAGE_NAME" || raise_error "IMAGE_NAME must be provided."
+
+# Create artifact folder
+mkdir -p /home/worker/workspace/artifacts
+
+# Construct a CONTEXT_FILE
+CONTEXT_FILE=/home/worker/workspace/context.tar
+
+# Run ./mach taskcluster-build-image with --context-only to build context
+run-task \
+  --chown-recursive "/home/worker/workspace" \
+  --vcs-checkout "/home/worker/checkouts/gecko" \
+  -- \
+  /home/worker/checkouts/gecko/mach taskcluster-build-image \
+  --context-only "$CONTEXT_FILE" \
+  "$IMAGE_NAME"
+test -f "$CONTEXT_FILE" || raise_error "Context file wasn't created"
+
+# Post context tar-ball to docker daemon
+# This interacts directly with the docker remote API, see:
+# https://docs.docker.com/engine/reference/api/docker_remote_api_v1.18/
+curl -s \
+  -X POST \
+  --header 'Content-Type: application/tar' \
+  --data-binary "@$CONTEXT_FILE" \
+  --unix-socket /var/run/docker.sock "http:/build?t=$IMAGE_NAME:$HASH" \
+  | tee /tmp/docker-build.log \
+  | jq -r '.status + .progress, .stream[:-1], .error | select(. != null)'
+
+# Exit non-zero if there is error entries in the log
+if cat /tmp/docker-build.log | jq -se 'add | .error' > /dev/null; then
+  raise_error "Image build failed: `cat /tmp/docker-build.log | jq -rse 'add | .error'`";
+fi
+
+# Get image from docker daemon
+# This interacts directly with the docker remote API, see:
+# https://docs.docker.com/engine/reference/api/docker_remote_api_v1.18/
+curl -s \
+  -X GET \
+  --unix-socket /var/run/docker.sock "http:/images/$IMAGE_NAME:$HASH/get" \
+  | zstd -3 -c -o /home/worker/workspace/artifacts/image.tar.zst
new file mode 100644
--- /dev/null
+++ b/testing/docker/image_builder/setup.sh
@@ -0,0 +1,53 @@
+#!/bin/bash -vex
+set -v -e -x
+
+export DEBIAN_FRONTEND=noninteractive
+
+# Update apt-get lists
+apt-get update -y
+
+# Install dependencies
+apt-get install -y \
+    curl \
+    tar \
+    jq \
+    python \
+    build-essential # Only needed for zstd installation, will be removed later
+
+# Install mercurial
+. /setup/common.sh
+. /setup/install-mercurial.sh
+
+# Install build-image.sh script
+chmod +x /usr/local/bin/build-image.sh
+chmod +x /usr/local/bin/run-task
+
+# Create workspace
+mkdir -p /home/worker/workspace
+
+# Install zstd 1.1.1
+cd /setup
+tooltool_fetch <<EOF
+[
+  {
+    "size": 734872,
+    "visibility": "public",
+    "digest": "a8817e74254f21ee5b76a21691e009ede2cdc70a78facfa453902df3e710e90e78d67f2229956d835960fd1085c33312ff273771b75f9322117d85eb35d8e695",
+    "algorithm": "sha512",
+    "filename": "zstd.tar.gz"
+  }
+]
+EOF
+cd -
+tar -xvf /setup/zstd.tar.gz -C /setup
+make -C /setup/zstd-1.1.1/programs install
+rm -rf /tmp/zstd-1.1.1/ /tmp/zstd.tar.gz
+apt-get purge -y build-essential
+
+# Purge apt-get caches to minimize image size
+apt-get auto-remove -y
+apt-get clean -y
+rm -rf /var/lib/apt/lists/
+
+# Remove this script
+rm -rf /setup/