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

516 lines
17 KiB
Python

import importlib
import importlib.metadata
import logging
import os
import secrets
import shlex
import signal
import sys
import tempfile
import textwrap
import types
import warnings
from pathlib import Path
_logger = logging.getLogger("mlflow.server")
from flask import Flask, Response, send_from_directory
from packaging.version import Version
from mlflow.environment_variables import (
_MLFLOW_INTERNAL_GATEWAY_AUTH_TOKEN,
_MLFLOW_SGI_NAME,
MLFLOW_FLASK_SERVER_SECRET_KEY,
MLFLOW_SERVER_ENABLE_JOB_EXECUTION,
)
from mlflow.exceptions import MlflowException
from mlflow.server import handlers
from mlflow.server.constants import (
ARTIFACT_ROOT_ENV_VAR,
ARTIFACTS_DESTINATION_ENV_VAR,
ARTIFACTS_ONLY_ENV_VAR,
BACKEND_STORE_URI_ENV_VAR,
HUEY_STORAGE_PATH_ENV_VAR,
PROMETHEUS_EXPORTER_ENV_VAR,
READ_REPLICA_BACKEND_STORE_URI_ENV_VAR,
REGISTRY_STORE_URI_ENV_VAR,
SECRETS_CACHE_MAX_SIZE_ENV_VAR,
SECRETS_CACHE_TTL_ENV_VAR,
SERVE_ARTIFACTS_ENV_VAR,
)
from mlflow.server.handlers import (
STATIC_PREFIX_ENV_VAR,
_add_static_prefix,
_search_datasets_handler,
create_promptlab_run_handler,
gateway_proxy_handler,
get_artifact_handler,
get_logged_model_artifact_handler,
get_metric_history_bulk_handler,
get_metric_history_bulk_interval_handler,
get_model_version_artifact_handler,
get_trace_artifact_handler,
get_ui_telemetry_handler,
post_ui_telemetry_handler,
upload_artifact_handler,
)
from mlflow.server.workspace_helpers import (
workspace_before_request_handler,
workspace_teardown_request_handler,
)
from mlflow.utils.os import is_windows
from mlflow.utils.plugins import get_entry_points
from mlflow.utils.process import _exec_cmd
from mlflow.version import VERSION
REL_STATIC_DIR = "js/build"
app = Flask(__name__, static_folder=REL_STATIC_DIR)
IS_FLASK_V1 = Version(importlib.metadata.version("flask")) < Version("2.0")
is_running_as_server = (
"gunicorn" in sys.modules
or "uvicorn" in sys.modules
or "waitress" in sys.modules
or os.environ.get(BACKEND_STORE_URI_ENV_VAR)
or os.environ.get(SERVE_ARTIFACTS_ENV_VAR)
)
if is_running_as_server:
from mlflow.server import security
security.init_security_middleware(app)
app.before_request(workspace_before_request_handler)
app.teardown_request(workspace_teardown_request_handler)
for http_path, handler, methods in handlers.get_endpoints():
app.add_url_rule(http_path, handler.__name__, handler, methods=methods)
if os.environ.get(PROMETHEUS_EXPORTER_ENV_VAR):
from mlflow.server.prometheus_exporter import activate_prometheus_exporter
prometheus_metrics_path = os.environ.get(PROMETHEUS_EXPORTER_ENV_VAR)
os.makedirs(prometheus_metrics_path, exist_ok=True)
activate_prometheus_exporter(app)
# Provide a health check endpoint to ensure the application is responsive
@app.route(_add_static_prefix("/health"))
def health():
return "OK", 200
# Provide an endpoint to query the version of mlflow running on the server
@app.route(_add_static_prefix("/version"))
def version():
return VERSION, 200
# Serve the "get-artifact" route.
@app.route(_add_static_prefix("/get-artifact"))
def serve_artifacts():
return get_artifact_handler()
# Serve the "model-versions/get-artifact" route.
@app.route(_add_static_prefix("/model-versions/get-artifact"))
def serve_model_version_artifact():
return get_model_version_artifact_handler()
# Serve the "metrics/get-history-bulk" route.
@app.route(_add_static_prefix("/ajax-api/2.0/mlflow/metrics/get-history-bulk"))
def serve_get_metric_history_bulk():
return get_metric_history_bulk_handler()
# Serve the "metrics/get-history-bulk-interval" route.
@app.route(_add_static_prefix("/ajax-api/2.0/mlflow/metrics/get-history-bulk-interval"))
def serve_get_metric_history_bulk_interval():
return get_metric_history_bulk_interval_handler()
# Serve the "experiments/search-datasets" route.
@app.route(_add_static_prefix("/ajax-api/2.0/mlflow/experiments/search-datasets"), methods=["POST"])
def serve_search_datasets():
return _search_datasets_handler()
# Serve the "runs/create-promptlab-run" route.
@app.route(_add_static_prefix("/ajax-api/2.0/mlflow/runs/create-promptlab-run"), methods=["POST"])
def serve_create_promptlab_run():
return create_promptlab_run_handler()
@app.route(_add_static_prefix("/ajax-api/2.0/mlflow/gateway-proxy"), methods=["POST", "GET"])
def serve_gateway_proxy():
return gateway_proxy_handler()
@app.route(_add_static_prefix("/ajax-api/2.0/mlflow/upload-artifact"), methods=["POST"])
def serve_upload_artifact():
return upload_artifact_handler()
# Serve the "/get-trace-artifact" route to allow frontend to fetch trace artifacts
# and render them in the Trace UI. The request body should contain the request_id
# of the trace.
@app.route(_add_static_prefix("/ajax-api/2.0/mlflow/get-trace-artifact"), methods=["GET"])
@app.route(_add_static_prefix("/ajax-api/3.0/mlflow/get-trace-artifact"), methods=["GET"])
def serve_get_trace_artifact():
return get_trace_artifact_handler()
@app.route(
_add_static_prefix("/ajax-api/2.0/mlflow/logged-models/<model_id>/artifacts/files"),
methods=["GET"],
)
def serve_get_logged_model_artifact(model_id: str):
return get_logged_model_artifact_handler(model_id)
@app.route(_add_static_prefix("/ajax-api/3.0/mlflow/ui-telemetry"), methods=["GET"])
def serve_get_ui_telemetry():
return get_ui_telemetry_handler()
@app.route(_add_static_prefix("/ajax-api/3.0/mlflow/ui-telemetry"), methods=["POST"])
def serve_post_ui_telemetry():
return post_ui_telemetry_handler()
# We expect the react app to be built assuming it is hosted at /static-files, so that requests for
# CSS/JS resources will be made to e.g. /static-files/main.css and we can handle them here.
# The files are hashed based on source code, so ok to send Cache-Control headers via max_age.
@app.route(_add_static_prefix("/static-files/<path:path>"))
def serve_static_file(path):
if IS_FLASK_V1:
return send_from_directory(app.static_folder, path, cache_timeout=2419200)
else:
return send_from_directory(app.static_folder, path, max_age=2419200)
# Serve the index.html for the React App for all other routes.
@app.route(_add_static_prefix("/"))
def serve():
if os.path.exists(os.path.join(app.static_folder, "index.html")):
return send_from_directory(app.static_folder, "index.html")
text = textwrap.dedent(
"""
Unable to display MLflow UI - landing page (index.html) not found.
You are very likely running the MLflow server using a source installation of the Python MLflow
package.
If you are a developer making MLflow source code changes and intentionally running a source
installation of MLflow, you can view the UI by running the Javascript dev server:
https://github.com/mlflow/mlflow/blob/master/CONTRIBUTING.md#running-the-javascript-dev-server
Otherwise, uninstall MLflow via 'pip uninstall mlflow', reinstall an official MLflow release
from PyPI via 'pip install mlflow', and rerun the MLflow server.
"""
)
return Response(text, mimetype="text/plain")
def _find_app(app_name: str) -> str:
apps = get_entry_points("mlflow.app")
for app in apps:
if app.name == app_name:
return app.value
raise MlflowException(
f"Failed to find app '{app_name}'. Available apps: {[a.name for a in apps]}"
)
def _is_factory(app: str) -> bool:
"""
Returns True if the given app is a factory function, False otherwise.
Args:
app: The app to check, e.g. "mlflow.server.app:app
"""
module, obj_name = app.rsplit(":", 1)
mod = importlib.import_module(module)
obj = getattr(mod, obj_name)
return isinstance(obj, types.FunctionType)
def get_app_client(app_name: str, *args, **kwargs):
"""
Instantiate a client provided by an app.
Args:
app_name: The app name defined in `setup.py`, e.g., "basic-auth".
args: Additional arguments passed to the app client constructor.
kwargs: Additional keyword arguments passed to the app client constructor.
Returns:
An app client instance.
"""
clients = get_entry_points("mlflow.app.client")
for client in clients:
if client.name == app_name:
cls = client.load()
return cls(*args, **kwargs)
raise MlflowException(
f"Failed to find client for '{app_name}'. Available clients: {[c.name for c in clients]}"
)
def _build_waitress_command(waitress_opts, host, port, app_name, is_factory):
opts = shlex.split(waitress_opts) if waitress_opts else []
return [
sys.executable,
"-m",
"waitress",
*opts,
f"--host={host}",
f"--port={port}",
"--ident=mlflow",
*(["--call"] if is_factory else []),
app_name,
]
def _build_gunicorn_command(gunicorn_opts, host, port, workers, app_name):
bind_address = f"{host}:{port}"
opts = shlex.split(gunicorn_opts) if gunicorn_opts else []
return [
sys.executable,
"-m",
"gunicorn",
*opts,
"-b",
bind_address,
"-w",
str(workers),
app_name,
]
_UVICORN_LOG_CONFIG = Path(__file__).parent / "uvicorn_log_config.yaml"
def _build_uvicorn_command(
uvicorn_opts, host, port, workers, app_name, env_file=None, is_factory=False
):
"""Build command to run uvicorn server."""
opts = shlex.split(uvicorn_opts) if uvicorn_opts else []
if not any(o == "--log-config" or o.startswith("--log-config=") for o in opts):
opts.extend(["--log-config", str(_UVICORN_LOG_CONFIG)])
cmd = [
sys.executable,
"-m",
"uvicorn",
*opts,
"--host",
host,
"--port",
str(port),
"--workers",
str(workers),
]
if env_file:
cmd.extend(["--env-file", env_file])
if is_factory:
cmd.append("--factory")
cmd.append(app_name)
return cmd
def _run_server(
*,
file_store_path,
read_replica_backend_store_uri=None,
registry_store_uri,
default_artifact_root,
serve_artifacts,
artifacts_only,
artifacts_destination,
host,
port,
static_prefix=None,
workers=None,
gunicorn_opts=None,
waitress_opts=None,
expose_prometheus=None,
app_name=None,
uvicorn_opts=None,
env_file=None,
secrets_cache_ttl=None,
secrets_cache_max_size=None,
):
"""
Run the MLflow server, wrapping it in gunicorn, uvicorn, or waitress on windows
Args:
static_prefix: If set, the index.html asset will be served from the path static_prefix.
If left None, the index.html asset will be served from the root path.
uvicorn_opts: Additional options for uvicorn server.
Returns:
None
"""
env_map = {}
if file_store_path:
env_map[BACKEND_STORE_URI_ENV_VAR] = file_store_path
if read_replica_backend_store_uri:
env_map[READ_REPLICA_BACKEND_STORE_URI_ENV_VAR] = read_replica_backend_store_uri
if registry_store_uri:
env_map[REGISTRY_STORE_URI_ENV_VAR] = registry_store_uri
if default_artifact_root:
env_map[ARTIFACT_ROOT_ENV_VAR] = default_artifact_root
if serve_artifacts:
env_map[SERVE_ARTIFACTS_ENV_VAR] = "true"
if artifacts_only:
env_map[ARTIFACTS_ONLY_ENV_VAR] = "true"
if artifacts_destination:
env_map[ARTIFACTS_DESTINATION_ENV_VAR] = artifacts_destination
if static_prefix:
env_map[STATIC_PREFIX_ENV_VAR] = static_prefix
if expose_prometheus:
env_map[PROMETHEUS_EXPORTER_ENV_VAR] = expose_prometheus
if secrets_cache_ttl is not None:
env_map[SECRETS_CACHE_TTL_ENV_VAR] = str(secrets_cache_ttl)
if secrets_cache_max_size is not None:
env_map[SECRETS_CACHE_MAX_SIZE_ENV_VAR] = str(secrets_cache_max_size)
if secret_key := MLFLOW_FLASK_SERVER_SECRET_KEY.get():
env_map[MLFLOW_FLASK_SERVER_SECRET_KEY.name] = secret_key
# Determine which server we're using (only one should be true)
using_gunicorn = gunicorn_opts is not None
using_waitress = waitress_opts is not None
using_uvicorn = not using_gunicorn and not using_waitress
if using_uvicorn:
env_map[_MLFLOW_SGI_NAME.name] = "uvicorn"
elif using_waitress:
env_map[_MLFLOW_SGI_NAME.name] = "waitress"
elif using_gunicorn:
env_map[_MLFLOW_SGI_NAME.name] = "gunicorn"
if app_name is None:
is_factory = False
# For uvicorn, use the FastAPI app; for gunicorn/waitress, use the Flask app
app = "mlflow.server.fastapi_app:app" if using_uvicorn else f"{__name__}:app"
else:
app = _find_app(app_name)
is_factory = _is_factory(app)
# `waitress` doesn't support `()` syntax for factory functions.
# Instead, we need to use the `--call` flag.
# Don't use () syntax if we're using uvicorn
use_factory_syntax = not is_windows() and is_factory and not using_uvicorn
app = f"{app}()" if use_factory_syntax else app
# Determine which server to use
if using_uvicorn:
# Use uvicorn (default when no specific server options are provided)
full_command = _build_uvicorn_command(
uvicorn_opts, host, port, workers or 4, app, env_file, is_factory
)
elif using_waitress:
# Use waitress if explicitly requested
warnings.warn(
"We recommend using uvicorn for improved performance. "
"Please use uvicorn by default or specify '--uvicorn-opts' "
"instead of '--waitress-opts'.",
FutureWarning,
stacklevel=2,
)
full_command = _build_waitress_command(waitress_opts, host, port, app, is_factory)
elif using_gunicorn:
# Use gunicorn if explicitly requested
if sys.platform == "win32":
raise MlflowException(
"Gunicorn is not supported on Windows. "
"Please use uvicorn (default) or specify '--waitress-opts'."
)
warnings.warn(
"We recommend using uvicorn for improved performance. "
"Please use uvicorn by default or specify '--uvicorn-opts' "
"instead of '--gunicorn-opts'.",
FutureWarning,
stacklevel=2,
)
full_command = _build_gunicorn_command(gunicorn_opts, host, port, workers or 4, app)
else:
# This shouldn't happen given the logic in CLI, but handle it just in case
raise MlflowException("No server configuration specified.")
# Check if job execution can be enabled (requirements met)
job_execution_enabled = False
if MLFLOW_SERVER_ENABLE_JOB_EXECUTION.get():
from mlflow.server.jobs.utils import _check_requirements
try:
_check_requirements(file_store_path)
job_execution_enabled = True
except Exception as e:
_logger.warning(
f"MLflow job execution requirements not met ({e!s}). "
"Server will start without job execution support. "
"Errors will be surfaced at job invocation time."
)
if app_name == "basic-auth" and job_execution_enabled:
# Generate the token here (before forking uvicorn workers) so that all
# worker processes and job subprocesses share the same token.
env_map[_MLFLOW_INTERNAL_GATEWAY_AUTH_TOKEN.name] = secrets.token_hex(32)
if job_execution_enabled:
# The `HUEY_STORAGE_PATH_ENV_VAR` is used by both MLflow server handler workers and
# huey job runner (huey_consumer).
env_map[HUEY_STORAGE_PATH_ENV_VAR] = (
tempfile.mkdtemp(dir="/dev/shm") # Use in-memory file system if possible
if os.path.exists("/dev/shm")
else tempfile.mkdtemp()
)
server_proc = _exec_cmd(
full_command,
extra_env=env_map,
capture_output=False,
synchronous=False,
)
def _forward_signal(signum, _frame):
"""Forward signals to the child server process to enable graceful shutdown."""
if server_proc.poll() is not None:
return
try:
server_proc.send_signal(signum)
except ProcessLookupError:
pass
signal.signal(signal.SIGTERM, _forward_signal)
signal.signal(signal.SIGINT, _forward_signal)
if job_execution_enabled:
from mlflow.environment_variables import MLFLOW_GATEWAY_URI, MLFLOW_TRACKING_URI
from mlflow.server.jobs.utils import _launch_job_runner
server_uri = f"http://{host}:{port}"
job_env = {
**env_map,
# Set tracking URI environment variable for job runner
# so that all job processes inherit it.
MLFLOW_TRACKING_URI.name: server_uri,
}
# Set gateway URI for job workers if not already set. Jobs may call
# _get_tracking_store() which overwrites MLFLOW_TRACKING_URI with the backend
# store URI (e.g., sqlite://). MLFLOW_GATEWAY_URI preserves the HTTP URI for
# gateway routing (e.g., judge LLM calls via /gateway/mlflow/v1/).
if not MLFLOW_GATEWAY_URI.is_set():
job_env[MLFLOW_GATEWAY_URI.name] = server_uri
_launch_job_runner(job_env, server_proc.pid)
server_proc.wait()