168 lines
6.2 KiB
Python
168 lines
6.2 KiB
Python
import logging
|
|
import os
|
|
import posixpath
|
|
import shutil
|
|
import subprocess
|
|
import tempfile
|
|
import urllib.parse
|
|
import urllib.request
|
|
|
|
import docker
|
|
|
|
from mlflow import tracking
|
|
from mlflow.environment_variables import MLFLOW_TRACKING_URI
|
|
from mlflow.exceptions import ExecutionException
|
|
from mlflow.projects.utils import MLFLOW_DOCKER_WORKDIR_PATH
|
|
from mlflow.utils import file_utils, process
|
|
from mlflow.utils.databricks_utils import get_databricks_env_vars
|
|
from mlflow.utils.file_utils import _handle_readonly_on_windows
|
|
from mlflow.utils.git_utils import get_git_commit
|
|
from mlflow.utils.mlflow_tags import MLFLOW_DOCKER_IMAGE_ID, MLFLOW_DOCKER_IMAGE_URI
|
|
|
|
_logger = logging.getLogger(__name__)
|
|
|
|
_GENERATED_DOCKERFILE_NAME = "Dockerfile.mlflow-autogenerated"
|
|
_MLFLOW_DOCKER_TRACKING_DIR_PATH = "/mlflow/tmp/mlruns"
|
|
_PROJECT_TAR_ARCHIVE_NAME = "mlflow-project-docker-build-context"
|
|
|
|
|
|
def validate_docker_installation():
|
|
"""
|
|
Verify if Docker is installed and running on host machine.
|
|
"""
|
|
if shutil.which("docker") is None:
|
|
raise ExecutionException(
|
|
"Could not find Docker executable. "
|
|
"Ensure Docker is installed as per the instructions "
|
|
"at https://docs.docker.com/install/overview/."
|
|
)
|
|
|
|
cmd = ["docker", "info"]
|
|
prc = process._exec_cmd(
|
|
cmd,
|
|
throw_on_error=False,
|
|
capture_output=False,
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.STDOUT,
|
|
)
|
|
if prc.returncode != 0:
|
|
joined_cmd = " ".join(cmd)
|
|
raise ExecutionException(
|
|
f"Ran `{joined_cmd}` to ensure docker daemon is running but it failed "
|
|
f"with the following output:\n{prc.stdout}"
|
|
)
|
|
|
|
|
|
def validate_docker_env(project):
|
|
if not project.name:
|
|
raise ExecutionException(
|
|
"Project name in MLProject must be specified when using docker for image tagging."
|
|
)
|
|
if not project.docker_env.get("image"):
|
|
raise ExecutionException(
|
|
"Project with docker environment must specify the docker image "
|
|
"to use via an 'image' field under the 'docker_env' field."
|
|
)
|
|
|
|
|
|
def build_docker_image(work_dir, repository_uri, base_image, run_id, build_image, docker_auth):
|
|
"""
|
|
Build a docker image containing the project in `work_dir`, using the base image.
|
|
"""
|
|
image_uri = _get_docker_image_uri(repository_uri=repository_uri, work_dir=work_dir)
|
|
client = docker.from_env()
|
|
if docker_auth is not None:
|
|
client.login(**docker_auth)
|
|
|
|
if not build_image:
|
|
if not client.images.list(name=base_image):
|
|
_logger.info(f"Pulling {base_image}")
|
|
image = client.images.pull(base_image)
|
|
else:
|
|
_logger.info(f"{base_image} already exists")
|
|
image = client.images.get(base_image)
|
|
image_uri = base_image
|
|
else:
|
|
dockerfile = (
|
|
f"FROM {base_image}\n COPY {_PROJECT_TAR_ARCHIVE_NAME}/ {MLFLOW_DOCKER_WORKDIR_PATH}\n"
|
|
f" WORKDIR {MLFLOW_DOCKER_WORKDIR_PATH}\n"
|
|
)
|
|
build_ctx_path = _create_docker_build_ctx(work_dir, dockerfile)
|
|
with open(build_ctx_path, "rb") as docker_build_ctx:
|
|
_logger.info("=== Building docker image %s ===", image_uri)
|
|
image, _ = client.images.build(
|
|
tag=image_uri,
|
|
forcerm=True,
|
|
dockerfile=posixpath.join(_PROJECT_TAR_ARCHIVE_NAME, _GENERATED_DOCKERFILE_NAME),
|
|
fileobj=docker_build_ctx,
|
|
custom_context=True,
|
|
encoding="gzip",
|
|
)
|
|
try:
|
|
os.remove(build_ctx_path)
|
|
except Exception:
|
|
_logger.info("Temporary docker context file %s was not deleted.", build_ctx_path)
|
|
tracking.MlflowClient().set_tag(run_id, MLFLOW_DOCKER_IMAGE_URI, image_uri)
|
|
tracking.MlflowClient().set_tag(run_id, MLFLOW_DOCKER_IMAGE_ID, image.id)
|
|
return image
|
|
|
|
|
|
def _get_docker_image_uri(repository_uri, work_dir):
|
|
"""
|
|
Args:
|
|
repository_uri: The URI of the Docker repository with which to tag the image. The
|
|
repository URI is used as the prefix of the image URI.
|
|
work_dir: Path to the working directory in which to search for a git commit hash
|
|
"""
|
|
repository_uri = repository_uri or "docker-project"
|
|
# Optionally include first 7 digits of git SHA in tag name, if available.
|
|
git_commit = get_git_commit(work_dir)
|
|
version_string = ":" + git_commit[:7] if git_commit else ""
|
|
return repository_uri + version_string
|
|
|
|
|
|
def _create_docker_build_ctx(work_dir, dockerfile_contents):
|
|
"""
|
|
Creates build context tarfile containing Dockerfile and project code, returning path to tarfile
|
|
"""
|
|
directory = tempfile.mkdtemp()
|
|
try:
|
|
dst_path = os.path.join(directory, "mlflow-project-contents")
|
|
shutil.copytree(src=work_dir, dst=dst_path)
|
|
with open(os.path.join(dst_path, _GENERATED_DOCKERFILE_NAME), "w") as handle:
|
|
handle.write(dockerfile_contents)
|
|
_, result_path = tempfile.mkstemp()
|
|
file_utils.make_tarfile(
|
|
output_filename=result_path, source_dir=dst_path, archive_name=_PROJECT_TAR_ARCHIVE_NAME
|
|
)
|
|
finally:
|
|
shutil.rmtree(directory, onerror=_handle_readonly_on_windows)
|
|
return result_path
|
|
|
|
|
|
def get_docker_tracking_cmd_and_envs(tracking_uri):
|
|
cmds = []
|
|
env_vars = {}
|
|
|
|
local_path, container_tracking_uri = _get_local_uri_or_none(tracking_uri)
|
|
if local_path is not None:
|
|
cmds = ["-v", f"{local_path}:{_MLFLOW_DOCKER_TRACKING_DIR_PATH}"]
|
|
env_vars[MLFLOW_TRACKING_URI.name] = container_tracking_uri
|
|
env_vars.update(get_databricks_env_vars(tracking_uri))
|
|
return cmds, env_vars
|
|
|
|
|
|
def _get_local_uri_or_none(uri):
|
|
if uri == "databricks":
|
|
return None, None
|
|
parsed_uri = urllib.parse.urlparse(uri)
|
|
if not parsed_uri.netloc and parsed_uri.scheme in ("", "file", "sqlite"):
|
|
path = urllib.request.url2pathname(parsed_uri.path)
|
|
if parsed_uri.scheme == "sqlite":
|
|
uri = file_utils.path_to_local_sqlite_uri(_MLFLOW_DOCKER_TRACKING_DIR_PATH)
|
|
else:
|
|
uri = file_utils.path_to_local_file_uri(_MLFLOW_DOCKER_TRACKING_DIR_PATH)
|
|
return path, uri
|
|
else:
|
|
return None, None
|