| #!/usr/bin/env python |
| # |
| # Copyright 2007 Google Inc. |
| # |
| # Licensed under the Apache License, Version 2.0 (the "License"); |
| # you may not use this file except in compliance with the License. |
| # You may obtain a copy of the License at |
| # |
| # http://www.apache.org/licenses/LICENSE-2.0 |
| # |
| # Unless required by applicable law or agreed to in writing, software |
| # distributed under the License is distributed on an "AS IS" BASIS, |
| # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| # See the License for the specific language governing permissions and |
| # limitations under the License. |
| # |
| """Manages a VM Runtime process running inside of a docker container.""" |
| |
| import datetime |
| import logging |
| import os |
| import socket |
| |
| import google |
| |
| from google.appengine.tools.devappserver2 import application_configuration |
| from google.appengine.tools.devappserver2 import http_proxy |
| from google.appengine.tools.devappserver2 import instance |
| from google.appengine.tools.devappserver2 import log_manager |
| from google.appengine.tools.docker import containers |
| |
| |
| _APP_ENGINE_PREFIX = 'google.appengine' |
| |
| # Number of seconds after a container start before we check if there is |
| # any old containers and images to cleanup. |
| _CLEANUP_DELAY_SEC = 10.0 |
| |
| _DOCKER_IMAGE_NAME_FORMAT = '{display}.{module}.{version}' |
| _DOCKER_CONTAINER_NAME_FORMAT = ( |
| _APP_ENGINE_PREFIX + '.{image_name}.{instance_id}.{timestamp}') |
| |
| # This is the number of containers the cleanup process will leave on docker for |
| # investigation purposes. |
| _CONTAINERS_TO_KEEP = 10 |
| |
| |
| class Error(Exception): |
| """Base class for errors in this module.""" |
| |
| |
| class InvalidEnvVariableError(Error): |
| """Raised if an environment variable name or value cannot be supported.""" |
| |
| |
| class VersionError(Error): |
| """Raised if no version is specified in application configuration file.""" |
| |
| |
| class InvalidForwardedPortError(Error): |
| """Raised if the forwarded port is already used (for example by debugger).""" |
| |
| |
| def _GetPortToPublish(port): |
| """Checks if given port is available. |
| |
| Useful for publishing debug ports when it's more convenient to bind to |
| the same address on each container restart. |
| |
| Args: |
| port: int, Port to check. |
| |
| Returns: |
| given port if it is available, None if it is already taken (then any |
| random available port will be selected by Dockerd). |
| """ |
| sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) |
| try: |
| sock.bind(('', port)) |
| sock.close() |
| return port |
| except socket.error: |
| logging.warning('Requested debug port %d is already in use. ' |
| 'Will use another available port.', port) |
| return None |
| |
| |
| def _ContainerName(image_name, instance_id, timestamp=None): |
| """Generates a container name. |
| |
| Args: |
| image_name: the base image name. |
| instance_id: the instance # of the module. |
| timestamp: the timestamp as a string you want to generate the name from. |
| If None, it will be the now in the UTC timezone in ISO 8601. |
| |
| Returns: |
| the generated container name. |
| """ |
| if timestamp is None: |
| # ":" is not allowed in the container names but are optional in |
| # ISO8601. |
| timestamp = datetime.datetime.utcnow().strftime('%Y-%m-%dT%H%M%S.%fZ') |
| |
| return _DOCKER_CONTAINER_NAME_FORMAT.format( |
| image_name=image_name, instance_id=instance_id, timestamp=timestamp) |
| |
| |
| class VMRuntimeProxy(instance.RuntimeProxy): |
| """Manages a VM Runtime process running inside of a docker container.""" |
| |
| DEFAULT_DEBUG_PORT = 5005 |
| |
| def __init__(self, docker_client, runtime_config_getter, |
| module_configuration, |
| default_port=8080, port_bindings=None, |
| additional_environment=None): |
| """Initializer for VMRuntimeProxy. |
| |
| Args: |
| docker_client: docker.Client object to communicate with Docker daemon. |
| runtime_config_getter: A function that can be called without arguments |
| and returns the runtime_config_pb2.Config containing the configuration |
| for the runtime. |
| module_configuration: An application_configuration.ModuleConfiguration |
| instance respresenting the configuration of the module that owns the |
| runtime. |
| default_port: int, main port inside of the container that instance is |
| listening on. |
| port_bindings: dict, Additional port bindings from the container. |
| additional_environment: doct, Additional environment variables to pass |
| to the container. |
| """ |
| super(VMRuntimeProxy, self).__init__() |
| |
| self._runtime_config_getter = runtime_config_getter |
| self._module_configuration = module_configuration |
| self._docker_client = docker_client |
| self._default_port = default_port |
| self._port_bindings = port_bindings |
| self._additional_environment = additional_environment |
| self._log_manager = log_manager.get( |
| self._docker_client, |
| enable_logging=self._runtime_config_getter().vm_config.enable_logs) |
| self._container = None |
| self._proxy = None |
| |
| def handle(self, environ, start_response, url_map, match, request_id, |
| request_type): |
| """Serves request by forwarding it to application instance via HttpProxy. |
| |
| Args: |
| environ: An environ dict for the request as defined in PEP-333. |
| start_response: A function with semantics defined in PEP-333. |
| url_map: An appinfo.URLMap instance containing the configuration for the |
| handler matching this request. |
| match: A re.MatchObject containing the result of the matched URL pattern. |
| request_id: A unique string id associated with the request. |
| request_type: The type of the request. See instance.*_REQUEST module |
| constants. |
| |
| Returns: |
| Generator of sequence of strings containing the body of the HTTP response. |
| |
| Raises: |
| InvalidEnvVariableError: if user tried to redefine any of the reserved |
| environment variables. |
| """ |
| return self._proxy.handle(environ, start_response, url_map, match, |
| request_id, request_type) |
| |
| def _instance_died_unexpectedly(self): |
| # TODO: Check if container is still up and running |
| return False |
| |
| def get_instance_logs(self): |
| # TODO: Handle docker container's logs |
| return '' |
| |
| def _escape_domain(self, application_external_name): |
| return application_external_name.replace(':', '.') |
| |
| def start(self, dockerfile_dir=None): |
| runtime_config = self._runtime_config_getter() |
| |
| if not self._module_configuration.major_version: |
| logging.error('Version needs to be specified in your application ' |
| 'configuration file.') |
| raise VersionError() |
| |
| self._log_manager.add( |
| self._escape_domain( |
| self._module_configuration.application_external_name), |
| self._module_configuration.module_name, |
| self._module_configuration.major_version, runtime_config.instance_id) |
| |
| if not dockerfile_dir: |
| dockerfile_dir = self._module_configuration.application_root |
| |
| # api_host set to 'localhost' won't be accessible from a docker container |
| # because container will have it's own 'localhost'. |
| # 10.0.2.2 is a special network setup by virtualbox to connect from the |
| # guest to the host. |
| api_host = runtime_config.api_host |
| if runtime_config.api_host in ('0.0.0.0', 'localhost'): |
| api_host = '10.0.2.2' |
| |
| image_name = _DOCKER_IMAGE_NAME_FORMAT.format( |
| # Escape domain if it is present. |
| display=self._escape_domain( |
| self._module_configuration.application_external_name), |
| module=self._module_configuration.module_name, |
| version=self._module_configuration.major_version) |
| |
| port_bindings = self._port_bindings if self._port_bindings else {} |
| port_bindings.setdefault(self._default_port, None) |
| debug_port = None |
| |
| environment = { |
| 'API_HOST': api_host, |
| 'API_PORT': runtime_config.api_port, |
| 'GAE_LONG_APP_ID': self._module_configuration.application_external_name, |
| 'GAE_PARTITION': self._module_configuration.partition, |
| 'GAE_MODULE_NAME': self._module_configuration.module_name, |
| 'GAE_MODULE_VERSION': self._module_configuration.major_version, |
| 'GAE_MINOR_VERSION': self._module_configuration.minor_version, |
| 'GAE_MODULE_INSTANCE': runtime_config.instance_id, |
| 'GAE_SERVER_PORT': runtime_config.server_port, |
| 'MODULE_YAML_PATH': os.path.basename( |
| self._module_configuration.config_path) |
| } |
| if self._additional_environment: |
| environment.update(self._additional_environment) |
| |
| # Handle user defined environment variables |
| if self._module_configuration.env_variables: |
| ev = (environment.viewkeys() & |
| self._module_configuration.env_variables.viewkeys()) |
| if ev: |
| raise InvalidEnvVariableError( |
| 'Environment variables [%s] are reserved for App Engine use' % |
| ', '.join(ev)) |
| |
| environment.update(self._module_configuration.env_variables) |
| |
| # Publish debug port if running in Debug mode. |
| if self._module_configuration.env_variables.get('DBG_ENABLE'): |
| debug_port = int(self._module_configuration.env_variables.get( |
| 'DBG_PORT', self.DEFAULT_DEBUG_PORT)) |
| environment['DBG_PORT'] = debug_port |
| port_bindings[debug_port] = _GetPortToPublish(debug_port) |
| |
| # Publish forwarded ports |
| # NOTE: fowarded ports are mapped as host_port => container_port, |
| # port_bindings are mapped the other way around. |
| for h, c in self._module_configuration.forwarded_ports.iteritems(): |
| if c in port_bindings: |
| raise InvalidForwardedPortError( |
| 'Port {port} is already used by debugger or runtime specific ' |
| 'VM Service. Please use a different forwarded_port.'.format(port=c)) |
| port_bindings[c] = h |
| |
| external_logs_path = os.path.join( |
| '/var/log/app_engine', |
| self._escape_domain( |
| self._module_configuration.application_external_name), |
| self._module_configuration.module_name, |
| self._module_configuration.major_version, |
| runtime_config.instance_id) |
| internal_logs_path = '/var/log/app_engine' |
| container_name = _ContainerName( |
| image_name=image_name, |
| instance_id=runtime_config.instance_id) |
| self._container = containers.Container( |
| self._docker_client, |
| containers.ContainerOptions( |
| image_opts=containers.ImageOptions( |
| dockerfile_dir=dockerfile_dir, |
| tag=image_name, |
| nocache=False), |
| port=self._default_port, |
| port_bindings=port_bindings, |
| environment=environment, |
| volumes={ |
| external_logs_path: {'bind': internal_logs_path} |
| }, |
| name=container_name |
| )) |
| |
| self._container.Start() |
| # As we add stuff, asynchronously check later for a cleanup. |
| containers.StartDelayedCleanup( |
| self._docker_client, _APP_ENGINE_PREFIX, _CLEANUP_DELAY_SEC, |
| _CONTAINERS_TO_KEEP) |
| |
| # Print the debug information before connecting to the container |
| # as debugging might break the runtime during initialization, and |
| # connecting the debugger is required to start processing requests. |
| if debug_port: |
| logging.info('To debug module {module} attach to {host}:{port}'.format( |
| module=self._module_configuration.module_name, |
| host=self.ContainerHost(), |
| port=self.PortBinding(debug_port))) |
| |
| self._proxy = http_proxy.HttpProxy( |
| host=self._container.host, port=self._container.port, |
| instance_died_unexpectedly=self._instance_died_unexpectedly, |
| instance_logs_getter=self.get_instance_logs, |
| error_handler_file=application_configuration.get_app_error_file( |
| self._module_configuration)) |
| |
| # If forwarded ports are used we do not really have to serve on 8080. |
| # We'll log /_ah/start request fail, but with health-checks disabled |
| # we should ignore that and continue working (accepting requests on |
| # our forwarded ports outside of dev server control). |
| health_check = self._module_configuration.vm_health_check |
| health_check_enabled = health_check and health_check.enable_health_check |
| |
| if health_check_enabled or not self._module_configuration.forwarded_ports: |
| self._proxy.wait_for_connection() |
| |
| def quit(self): |
| """Kills running container and removes it.""" |
| self._container.Stop() |
| |
| def PortBinding(self, port): |
| """Get the host binding of a container port. |
| |
| Args: |
| port: Port inside container. |
| |
| Returns: |
| Port on the host system mapped to the given port inside of |
| the container. |
| """ |
| return self._container.PortBinding(port) |
| |
| def ContainerHost(self): |
| """Get the host IP address of the container. |
| |
| Returns: |
| IP address on the host system for accessing the container. |
| """ |
| return self._container.host |