App Engine Python SDK version 1.9.12
[gae.git] / python / google / appengine / tools / docker / containers.py
blob9479b5c845b6c6c4af2fe859b4acb32602a46bac
1 #!/usr/bin/env python
3 # Copyright 2007 Google Inc.
5 # Licensed under the Apache License, Version 2.0 (the "License");
6 # you may not use this file except in compliance with the License.
7 # You may obtain a copy of the License at
9 # http://www.apache.org/licenses/LICENSE-2.0
11 # Unless required by applicable law or agreed to in writing, software
12 # distributed under the License is distributed on an "AS IS" BASIS,
13 # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14 # See the License for the specific language governing permissions and
15 # limitations under the License.
17 """Docker image and docker container classes.
19 In Docker terminology image is a read-only layer that never changes.
20 Container is created once you start a process in Docker from an Image. Container
21 consists of read-write layer, plus information about the parent Image, plus
22 some additional information like its unique ID, networking configuration,
23 and resource limits.
24 For more information refer to http://docs.docker.io/.
26 Mapping to Docker CLI:
27 Image is a result of "docker build path/to/Dockerfile" command.
28 Container is a result of "docker run image_tag" command.
29 ImageOptions and ContainerOptions allow to pass parameters to these commands.
31 Versions 1.9 and 1.10 of docker remote API are supported.
32 """
34 from collections import namedtuple
36 import itertools
37 import json
38 import logging
39 import re
40 import threading
41 import urlparse
43 import google
44 import docker
45 import requests
48 _SUCCESSFUL_BUILD_PATTERN = re.compile(r'Successfully built ([a-zA-Z0-9]{12})')
50 _ERROR_LOG_TMPL = 'Build Error: {error}.'
51 _ERROR_LOG_DETAILED_TMPL = _ERROR_LOG_TMPL + ' Detail: {detail}'
52 _STREAM = 'stream'
55 class ImageOptions(namedtuple('ImageOptionsT',
56 ['dockerfile_dir', 'tag', 'nocache', 'rm'])):
57 """Options for building Docker Images."""
59 def __new__(cls, dockerfile_dir=None, tag=None, nocache=False, rm=True):
60 """This method is redefined to provide default values for namedtuple.
62 Args:
63 dockerfile_dir: str, Path to the directory with the Dockerfile. If it is
64 None, no build is needed. We will be looking for the existing image
65 with the specified tag and raise an error if it does not exist.
66 tag: str, Repository name (and optionally a tag) to be applied to the
67 image in case of successful build. If dockerfile_dir is None, tag
68 is used for lookup of an image.
69 nocache: boolean, True if cache should not be used when building the
70 image.
71 rm: boolean, True if intermediate images should be removed after a
72 successful build. Default value is set to True because this is the
73 default value used by "docker build" command.
75 Returns:
76 ImageOptions object.
77 """
78 return super(ImageOptions, cls).__new__(
79 cls, dockerfile_dir=dockerfile_dir, tag=tag, nocache=nocache, rm=rm)
82 class ContainerOptions(namedtuple('ContainerOptionsT',
83 ['image_opts', 'port', 'port_bindings',
84 'environment', 'volumes', 'volumes_from',
85 'name'])):
86 """Options for creating and running Docker Containers."""
88 def __new__(cls, image_opts=None, port=None, port_bindings=None,
89 environment=None, volumes=None, volumes_from=None, name=None):
90 """This method is redefined to provide default values for namedtuple.
92 Args:
93 image_opts: ImageOptions, properties of underlying Docker Image.
94 port: int, Primary port that the process inside of a container is
95 listening on. If this port is not part of the port bindings
96 specified, a default binding will be added for this port.
97 port_bindings: dict, Port bindings for exposing multiple ports. If the
98 only binding needed is the default binding of just one port this
99 can be None.
100 environment: dict, Environment variables.
101 volumes: dict, Volumes to mount from the host system.
102 volumes_from: list, Volumes from the specified container(s).
103 name: str, Name of a container. Needed for data containers.
105 Returns:
106 ContainerOptions object.
108 return super(ContainerOptions, cls).__new__(
109 cls, image_opts=image_opts, port=port, port_bindings=port_bindings,
110 environment=environment, volumes=volumes, volumes_from=volumes_from,
111 name=name)
114 class Error(Exception):
115 """Base exception for containers module."""
118 class ImageError(Error):
119 """Image related errors."""
122 class ContainerError(Error):
123 """Container related erorrs."""
126 class DockerDaemonConnectionError(Error):
127 """Raised if the docker client can't connect to the docker daemon."""
130 class BaseImage(object):
131 """Abstract base class for Docker images."""
133 def __init__(self, docker_client, image_opts):
134 """Initializer for BaseImage.
136 Args:
137 docker_client: an object of docker.Client class to communicate with a
138 Docker daemon.
139 image_opts: an instance of ImageOptions class describing the parameters
140 passed to docker commands.
142 Raises:
143 DockerDaemonConnectionError: If the docker daemon isn't responding.
145 self._docker_client = docker_client
146 self._image_opts = image_opts
147 self._id = None
149 try:
150 self._docker_client.ping()
151 except requests.exceptions.ConnectionError:
152 raise DockerDaemonConnectionError(
153 'Couldn\'t connect to the docker daemon at %s. Please check that '
154 'the docker daemon is running and that you have specified the '
155 'correct docker host.' % self._docker_client.base_url)
157 def Build(self):
158 """Calls "docker build" if needed."""
159 raise NotImplementedError
161 def Remove(self):
162 """Calls "docker rmi" if needed."""
163 raise NotImplementedError
165 @property
166 def id(self):
167 """Returns 64 hexadecimal digit string identifying the image."""
168 # Might also be a first 12-characters shortcut.
169 return self._id
171 @property
172 def tag(self):
173 """Returns image tag string."""
174 return self._image_opts.tag
176 def __enter__(self):
177 """Makes BaseImage usable with "with" statement."""
178 self.Build()
179 return self
181 # pylint: disable=redefined-builtin
182 def __exit__(self, type, value, traceback):
183 """Makes BaseImage usable with "with" statement."""
184 self.Remove()
186 def __del__(self):
187 """Makes sure that build artifacts are cleaned up."""
188 self.Remove()
191 class Image(BaseImage):
192 """Docker image that requires building and should be removed afterwards."""
194 def __init__(self, docker_client, image_opts):
195 """Initializer for Image.
197 Args:
198 docker_client: an object of docker.Client class to communicate with a
199 Docker daemon.
200 image_opts: an instance of ImageOptions class that must have
201 dockerfile_dir set. image_id will be returned by "docker build"
202 command.
204 Raises:
205 ImageError: if dockerfile_dir is not set.
207 if not image_opts.dockerfile_dir:
208 raise ImageError('dockerfile_dir for images that require building '
209 'must be set.')
211 super(Image, self).__init__(docker_client, image_opts)
213 def Build(self):
214 """Calls "docker build".
216 Raises:
217 ImageError: if the image could not be built.
219 logging.info('Building image %s...', self.tag)
221 build_res = self._docker_client.build(
222 path=self._image_opts.dockerfile_dir,
223 tag=self.tag,
224 quiet=False, fileobj=None, nocache=self._image_opts.nocache,
225 rm=self._image_opts.rm)
227 log_lines = [json.loads(x.strip()) for x in build_res]
229 if not log_lines:
230 logging.error('Error building docker image %s [with no output]', self.tag)
231 raise ImageError
233 def _FormatBuildLog(lines):
234 if not lines:
235 return ''
236 return ('Full Image Build Log:\n%s' %
237 ''.join(l.get(_STREAM) for l in lines))
239 success_message = log_lines[-1].get(_STREAM)
240 if success_message:
241 m = _SUCCESSFUL_BUILD_PATTERN.match(success_message)
242 if m:
243 # The build was successful.
244 self._id = m.group(1)
245 logging.info('Image %s built, id = %s', self.tag, self.id)
246 logging.debug(_FormatBuildLog(log_lines))
247 return
249 logging.error('Error building docker image %s', self.tag)
251 # Last log line usually contains error details if not a success message.
252 err_line = log_lines[-1]
253 error = err_line.get('error')
254 error_detail = err_line.get('errorDetail')
255 if error_detail:
256 error_detail = error_detail.get('message')
258 stop = len(log_lines)
259 if error or error_detail:
260 el = (_ERROR_LOG_TMPL if error == error_detail
261 else _ERROR_LOG_DETAILED_TMPL).format(error=error,
262 detail=error_detail)
263 logging.error(el)
264 stop -= 1
266 logging.error(_FormatBuildLog(itertools.islice(log_lines, stop)))
267 raise ImageError
269 def Remove(self):
270 """Calls "docker rmi"."""
271 if self._id:
272 try:
273 self._docker_client.remove_image(self.id)
274 except docker.errors.APIError as e:
275 logging.warning('Image %s (id=%s) cannot be removed: %s. Try cleaning '
276 'up old containers that can be listed with '
277 '"docker ps -a" and removing the image again with '
278 '"docker rmi IMAGE_ID".',
279 self.tag, self.id, e)
280 self._id = None
283 class PrebuiltImage(BaseImage):
284 """Prebuilt Docker image. Build and Remove functions are noops."""
286 def __init__(self, docker_client, image_opts):
287 """Initializer for PrebuiltImage.
289 Args:
290 docker_client: an object of docker.Client class to communicate with a
291 Docker daemon.
292 image_opts: an instance of ImageOptions class that must have
293 dockerfile_dir not set and tag set.
295 Raises:
296 ImageError: if image_opts.dockerfile_dir is set or
297 image_opts.tag is not set.
299 if image_opts.dockerfile_dir:
300 raise ImageError('dockerfile_dir for PrebuiltImage must not be set.')
302 if not image_opts.tag:
303 raise ImageError('PrebuiltImage must have tag specified to find '
304 'image id.')
306 super(PrebuiltImage, self).__init__(docker_client, image_opts)
308 def Build(self):
309 """Searches for pre-built image with specified tag.
311 Raises:
312 ImageError: if image with this tag was not found.
314 logging.info('Looking for image_id for image with tag %s', self.tag)
315 images = self._docker_client.images(
316 name=self.tag, quiet=True, all=False, viz=False)
318 if not images:
319 raise ImageError('Image with tag %s was not found' % self.tag)
321 # TODO: check if it's possible to have more than one image returned.
322 self._id = images[0]
324 def Remove(self):
325 """Unassigns image_id only, does not remove the image as we don't own it."""
326 self._id = None
329 def CreateImage(docker_client, image_opts):
330 """Creates an new object to represent Docker image.
332 Args:
333 docker_client: an object of docker.Client class to communicate with a
334 Docker daemon.
335 image_opts: an instance of ImageOptions class.
337 Returns:
338 New object, subclass of BaseImage class.
340 image = Image if image_opts.dockerfile_dir else PrebuiltImage
341 return image(docker_client, image_opts)
344 def GetDockerHost(docker_client):
345 parsed_url = urlparse.urlparse(docker_client.base_url)
347 # Socket url schemes look like: unix:// or http+unix://.
348 # If the user is running docker locally and connecting over a socket, we
349 # should just use localhost.
350 if 'unix' in parsed_url.scheme:
351 return 'localhost'
352 return parsed_url.hostname
355 class Container(object):
356 """Docker Container."""
358 def __init__(self, docker_client, container_opts):
359 """Initializer for Container.
361 Args:
362 docker_client: an object of docker.Client class to communicate with a
363 Docker daemon.
364 container_opts: an instance of ContainerOptions class.
366 self._docker_client = docker_client
367 self._container_opts = container_opts
369 self._image = CreateImage(docker_client, container_opts.image_opts)
370 self._id = None
371 self._host = GetDockerHost(self._docker_client)
372 self._container_host = None
373 self._port = None
374 # Port bindings will be set to a dictionary mapping exposed ports
375 # to the interface they are bound to. This will be populated from
376 # the container options passed when the container is started.
377 self._port_bindings = None
379 # Use the daemon flag in case we leak these threads.
380 self._logs_listener = threading.Thread(target=self._ListenToLogs)
381 self._logs_listener.daemon = True
383 def Start(self):
384 """Builds an image (if necessary) and runs a container.
386 Raises:
387 ContainerError: if container_id is already set, i.e. container is already
388 started.
390 if self.id:
391 raise ContainerError('Trying to start already running container.')
393 self._image.Build()
395 logging.info('Creating container...')
396 port_bindings = self._container_opts.port_bindings or {}
397 if self._container_opts.port:
398 # Add primary port to port bindings if not already specified.
399 # Setting its value to None lets docker pick any available port.
400 port_bindings[self._container_opts.port] = port_bindings.get(
401 self._container_opts.port)
403 self._id = self._docker_client.create_container(
404 image=self._image.id, hostname=None, user=None, detach=True,
405 stdin_open=False,
406 tty=False, mem_limit=0,
407 ports=port_bindings.keys(),
408 volumes=(self._container_opts.volumes.keys()
409 if self._container_opts.volumes else None),
410 environment=self._container_opts.environment,
411 dns=None,
412 network_disabled=False,
413 name=self.name)
414 # create_container returns a dict sometimes.
415 if isinstance(self.id, dict):
416 self._id = self.id.get('Id')
417 logging.info('Container %s created.', self.id)
419 self._docker_client.start(
420 self.id,
421 port_bindings=port_bindings,
422 binds=self._container_opts.volumes,
423 # In the newer API version volumes_from got moved from
424 # create_container to start. In older version volumes_from option was
425 # completely broken therefore we support only passing volumes_from
426 # in start.
427 volumes_from=self._container_opts.volumes_from)
429 self._logs_listener.start()
431 if not port_bindings:
432 # Nothing to inspect
433 return
435 container_info = self._docker_client.inspect_container(self._id)
436 network_settings = container_info['NetworkSettings']
437 self._container_host = network_settings['IPAddress']
438 self._port_bindings = {
439 port: int(network_settings['Ports']['%d/tcp' % port][0]['HostPort'])
440 for port in port_bindings
443 def Stop(self):
444 """Stops a running container, removes it and underlying image if needed."""
445 if self._id:
446 self._docker_client.kill(self.id)
447 self._docker_client.remove_container(self.id, v=False,
448 link=False)
449 self._id = None
450 self._image.Remove()
452 def PortBinding(self, port):
453 """Get the host binding of a container port.
455 Args:
456 port: Port inside container.
458 Returns:
459 Port on the host system mapped to the given port inside of
460 the container.
462 return self._port_bindings.get(port)
464 @property
465 def host(self):
466 """Host the container can be reached at by the host (i.e. client) system."""
467 return self._host
469 @property
470 def port(self):
471 """Port (on the host system) mapped to the port inside of the container."""
472 return self._port_bindings[self._container_opts.port]
474 @property
475 def addr(self):
476 """An address the container can be reached at by the host system."""
477 return '%s:%d' % (self.host, self.port)
479 @property
480 def id(self):
481 """Returns 64 hexadecimal digit string identifying the container."""
482 return self._id
484 @property
485 def container_addr(self):
486 """An address the container can be reached at by another container."""
487 return '%s:%d' % (self._container_host, self._container_opts.port)
489 @property
490 def name(self):
491 """String, identifying a container. Required for data containers."""
492 return self._container_opts.name
494 def _ListenToLogs(self):
495 """Logs all output from the docker container.
497 The docker.Client.logs method returns a generator that yields log lines.
498 This method iterates over that generator and outputs those log lines to
499 the devappserver2 logs.
501 log_lines = self._docker_client.logs(container=self.id, stream=True)
502 for line in log_lines:
503 line = line.strip()
504 logging.debug('Container: %s: %s', self.id[0:12], line)
506 def __enter__(self):
507 """Makes Container usable with "with" statement."""
508 self.Start()
509 return self
511 # pylint: disable=redefined-builtin
512 def __exit__(self, type, value, traceback):
513 """Makes Container usable with "with" statement."""
514 self.Stop()
516 def __del__(self):
517 """Makes sure that all build and run artifacts are cleaned up."""
518 self.Stop()