Files
2026-07-13 13:22:34 +08:00

256 lines
8.8 KiB
Python

"""
Initialize the environment and start model serving in a Docker container.
To be executed only during the model deployment.
"""
import logging
import multiprocessing
import os
import shlex
import shutil
import signal
import sys
from pathlib import Path
from subprocess import Popen, check_call
import mlflow
from mlflow import pyfunc
from mlflow.environment_variables import MLFLOW_DISABLE_ENV_CREATION
from mlflow.models import Model
from mlflow.models.model import MLMODEL_FILE_NAME
from mlflow.pyfunc import _extract_conda_env, scoring_server
from mlflow.utils import env_manager as em
from mlflow.utils.environment import _PythonEnv
from mlflow.utils.virtualenv import _get_or_create_virtualenv
from mlflow.version import VERSION as MLFLOW_VERSION
MODEL_PATH = "/opt/ml/model"
DEFAULT_SAGEMAKER_SERVER_PORT = 8080
DEFAULT_INFERENCE_SERVER_PORT = 8000
DEFAULT_NGINX_SERVER_PORT = 8080
SUPPORTED_FLAVORS = [pyfunc.FLAVOR_NAME]
DISABLE_NGINX = "DISABLE_NGINX"
SERVING_ENVIRONMENT = "SERVING_ENVIRONMENT"
_logger = logging.getLogger(__name__)
def _init(cmd, env_manager):
"""
Initialize the container and execute command.
Args:
cmd: Command param passed by Sagemaker. Can be "serve" or "train" (unimplemented).
"""
if cmd == "serve":
_serve(env_manager)
elif cmd == "train":
_train()
else:
raise Exception(f"Unrecognized command {cmd}, full args = {sys.argv}")
def _serve(env_manager):
"""
Serve the model.
Read the MLmodel config, initialize the Conda environment if needed and start python server.
"""
model_config_path = os.path.join(MODEL_PATH, MLMODEL_FILE_NAME)
m = Model.load(model_config_path)
if pyfunc.FLAVOR_NAME in m.flavors:
_serve_pyfunc(m, env_manager)
else:
raise Exception("This container only supports models with the PyFunc flavors.")
def _install_pyfunc_deps(model_path=None, install_mlflow=False, env_manager=em.VIRTUALENV):
"""
Creates a conda env for serving the model at the specified path and installs almost all serving
dependencies into the environment - MLflow is not installed as it's not available via conda.
"""
activate_cmd = _install_model_dependencies_to_env(model_path, env_manager) if model_path else []
# NB: install gunicorn[gevent] from pip rather than from conda because gunicorn is already
# dependency of mlflow on pip and we expect mlflow to be part of the environment.
server_deps = ["gunicorn[gevent]"]
install_server_deps = [f"pip install {' '.join(server_deps)}"]
if Popen(["bash", "-c", " && ".join(activate_cmd + install_server_deps)]).wait() != 0:
raise Exception("Failed to install serving dependencies into the model environment.")
# NB: If we don't use virtualenv or conda env, we don't need to install mlflow here as
# it's already installed in the container.
if len(activate_cmd):
if _container_includes_mlflow_source():
# If the MLflow source code is copied to the container,
# we always need to run `pip install /opt/mlflow` otherwise
# the MLflow dependencies are not installed.
install_mlflow_cmd = ["pip install /opt/mlflow/."]
elif install_mlflow:
install_mlflow_cmd = [f"pip install mlflow=={MLFLOW_VERSION}"]
else:
install_mlflow_cmd = []
if install_mlflow_cmd:
if Popen(["bash", "-c", " && ".join(activate_cmd + install_mlflow_cmd)]).wait() != 0:
raise Exception("Failed to install mlflow into the model environment.")
return activate_cmd
def _install_model_dependencies_to_env(model_path, env_manager) -> list[str]:
""":
Installs model dependencies to the specified environment, which can be either a local
environment, a conda environment, or a virtualenv.
Returns:
Empty list if local environment, otherwise a list of bash commands to activate the
virtualenv or conda environment.
"""
model_config_path = os.path.join(model_path, MLMODEL_FILE_NAME)
model = Model.load(model_config_path)
conf = model.flavors.get(pyfunc.FLAVOR_NAME, {})
if pyfunc.ENV not in conf:
return []
env_conf = conf[mlflow.pyfunc.ENV]
if env_manager == em.LOCAL:
python_env_config_path = os.path.join(model_path, env_conf[em.VIRTUALENV])
python_env = _PythonEnv.from_yaml(python_env_config_path)
pip_args = [sys.executable, "-m", "pip", "install"]
for dep in python_env.build_dependencies + python_env.dependencies:
dep_args = shlex.split(dep)
for i, arg in enumerate(dep_args):
if arg == "requirements.txt" or arg.endswith("/requirements.txt"):
dep_args[i] = os.path.join(model_path, "requirements.txt")
pip_args.extend(dep_args)
if Popen(pip_args).wait() != 0:
raise Exception("Failed to install model dependencies.")
return []
_logger.info("creating and activating custom environment")
env = _extract_conda_env(env_conf)
env_path_dst = os.path.join("/opt/mlflow/", env)
env_path_dst_dir = os.path.dirname(env_path_dst)
if not os.path.exists(env_path_dst_dir):
os.makedirs(env_path_dst_dir)
shutil.copy2(os.path.join(MODEL_PATH, env), env_path_dst)
if env_manager == em.CONDA:
conda_create_model_env = f"conda env create -n custom_env -f {env_path_dst}"
if Popen(["bash", "-c", conda_create_model_env]).wait() != 0:
raise Exception("Failed to create model environment.")
activate_cmd = ["source /miniconda/bin/activate custom_env"]
elif env_manager == em.VIRTUALENV:
env_activate_cmd = _get_or_create_virtualenv(model_path, env_manager=env_manager)
path = env_activate_cmd.split(" ")[-1]
os.symlink(path, "/opt/activate")
activate_cmd = [env_activate_cmd]
return activate_cmd
def _serve_pyfunc(model, env_manager):
# option to disable manually nginx. The default behavior is to enable nginx.
disable_nginx = os.environ.get(DISABLE_NGINX, "false").lower() == "true"
disable_env_creation = MLFLOW_DISABLE_ENV_CREATION.get()
conf = model.flavors[pyfunc.FLAVOR_NAME]
bash_cmds = []
if pyfunc.ENV in conf:
# NB: MLFLOW_DISABLE_ENV_CREATION is False only for SageMaker deployment, where the model
# files are loaded into the container at runtime rather than build time. In this case,
# we need to create a virtual environment and install the model dependencies into it when
# starting the container.
if not disable_env_creation:
_install_pyfunc_deps(
MODEL_PATH,
install_mlflow=True,
env_manager=env_manager,
)
if env_manager == em.CONDA:
bash_cmds.append("source /miniconda/bin/activate custom_env")
elif env_manager == em.VIRTUALENV:
bash_cmds.append("source /opt/activate")
procs = []
start_nginx = not disable_nginx
if start_nginx:
nginx_conf = Path(mlflow.models.__file__).parent.joinpath(
"container", "scoring_server", "nginx.conf"
)
nginx = Popen(["nginx", "-c", nginx_conf])
# link the log streams to stdout/err so they will be logged to the container logs.
# Default behavior is to do the redirection unless explicitly specified
# by environment variable.
check_call(["ln", "-sf", "/dev/stdout", "/var/log/nginx/access.log"])
check_call(["ln", "-sf", "/dev/stderr", "/var/log/nginx/error.log"])
procs.append(nginx)
cpu_count = multiprocessing.cpu_count()
nworkers = int(os.environ.get("MLFLOW_MODELS_WORKERS", cpu_count))
port = DEFAULT_INFERENCE_SERVER_PORT
cmd, cmd_env = scoring_server.get_cmd(model_uri=MODEL_PATH, nworkers=nworkers, port=port)
bash_cmds.append(cmd)
inference_server_process = Popen(["/bin/bash", "-c", " && ".join(bash_cmds)], env=cmd_env)
procs.append(inference_server_process)
signal.signal(signal.SIGTERM, lambda a, b: _sigterm_handler(pids=[p.pid for p in procs]))
# If either subprocess exits, so do we.
awaited_pids = _await_subprocess_exit_any(procs=procs)
_sigterm_handler(awaited_pids)
def _container_includes_mlflow_source():
return os.path.exists("/opt/mlflow/pyproject.toml")
def _train():
raise Exception("Train is not implemented.")
def _await_subprocess_exit_any(procs):
pids = [proc.pid for proc in procs]
while True:
pid, _ = os.wait()
if pid in pids:
break
return pids
def _sigterm_handler(pids):
"""
Cleanup when terminating.
Attempt to kill all launched processes and exit.
"""
_logger.info("Got sigterm signal, exiting.")
for pid in pids:
try:
os.kill(pid, signal.SIGTERM)
except OSError:
pass
sys.exit(0)