Skip to content
This repository has been archived by the owner on Sep 13, 2023. It is now read-only.

add support for deployment to K8s #374

Merged
merged 48 commits into from
Sep 15, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
48 commits
Select commit Hold shift + click to select a range
3c5dd97
fix tests
mike0sv Jul 20, 2022
d836ba4
Sagemaker deployments (#366)
mike0sv Aug 29, 2022
b5717b3
test that all configs in entrypoints
mike0sv Sep 1, 2022
9d5e07b
fix short tests
mike0sv Sep 8, 2022
566c19a
wip kubernetes support
madhur-tandon Aug 5, 2022
da7fb3c
use APIs to deploy and get status, deletion still pending
madhur-tandon Aug 8, 2022
a5f94a1
remove get client from state
madhur-tandon Aug 8, 2022
34dfe83
fix param
madhur-tandon Aug 8, 2022
861da4d
fix jinja template
madhur-tandon Aug 8, 2022
a4fb3a5
working remove and status
madhur-tandon Aug 19, 2022
6650c5d
fix client
madhur-tandon Aug 19, 2022
2110fd2
small fixes
madhur-tandon Aug 19, 2022
b62255d
attempt to add tests
madhur-tandon Aug 24, 2022
4b047d6
setup github actions for k8s tests
madhur-tandon Aug 31, 2022
7bd52c3
fix linter
madhur-tandon Aug 31, 2022
77b5e44
use predict method of client
madhur-tandon Aug 31, 2022
f99e5ac
allow registry to be configurable by cli
madhur-tandon Sep 7, 2022
75b4a33
change calculation of host and port according to service type
madhur-tandon Sep 7, 2022
313b747
re-enable k8s test as new workflow
madhur-tandon Sep 7, 2022
db1f972
fix daemon access in tests
madhur-tandon Sep 7, 2022
2f6ee6c
make linter happy
madhur-tandon Sep 7, 2022
7d72b07
fix fixtures
madhur-tandon Sep 7, 2022
da2a6ff
suggested fixes and refactor
madhur-tandon Sep 8, 2022
d4fa125
make namespace as a separate field and use enums
madhur-tandon Sep 8, 2022
47e0137
use watcher to figure out when resources are deleted
madhur-tandon Sep 8, 2022
d0720de
check minikube status before loading kubeconfig in fixture
madhur-tandon Sep 8, 2022
b8a3519
minor suggestions
madhur-tandon Sep 8, 2022
7007327
use enums for comparisons as well
madhur-tandon Sep 8, 2022
d0df7cc
create abstract class for services for host and port info
madhur-tandon Sep 9, 2022
a23e853
raise error when service of type clusterIP
madhur-tandon Sep 9, 2022
a2098a7
fix build and use tag as model hash
madhur-tandon Sep 9, 2022
35a3b9c
fix echo message
madhur-tandon Sep 9, 2022
ef2bace
hot swapping of docker image deployed
madhur-tandon Sep 9, 2022
24e2684
remove unnecessary f-string
madhur-tandon Sep 10, 2022
7a018f7
skip swapping when same hash is tried to be deployed again
madhur-tandon Sep 10, 2022
a2fe1e2
suggested improvements
madhur-tandon Sep 13, 2022
73a065e
fix lint
madhur-tandon Sep 13, 2022
97f7b8a
fix pylint
madhur-tandon Sep 13, 2022
37f9f02
suggested improvements
madhur-tandon Sep 13, 2022
881a512
fix pylint
madhur-tandon Sep 13, 2022
a3356c3
update entrypoints
madhur-tandon Sep 13, 2022
6bdad73
add docstrings for K8sYamlBuildArgs
madhur-tandon Sep 13, 2022
fc61d33
add docstrings for k8s service type classes
madhur-tandon Sep 13, 2022
5e6b6a7
capitalize docstrings for fields
madhur-tandon Sep 13, 2022
af69662
remove service type enum
madhur-tandon Sep 13, 2022
3417fd7
Remove new workflow for K8s
madhur-tandon Sep 14, 2022
8744072
remove duplicate methods
madhur-tandon Sep 14, 2022
7a92d42
remove version from iterative-telemetry
madhur-tandon Sep 14, 2022
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/check-test-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,9 @@ jobs:
pip install pre-commit .[tests]
- run: pre-commit run pylint -a -v --show-diff-on-failure
if: matrix.python != '3.7'
- name: Start minikube
if: matrix.os == 'ubuntu-latest' && matrix.python == '3.9'
uses: medyagh/setup-minikube@master
- name: Run tests
timeout-minutes: 40
run: pytest
Expand Down
Empty file.
219 changes: 219 additions & 0 deletions mlem/contrib/kubernetes/base.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,219 @@
import os
from typing import ClassVar, List, Optional

from kubernetes import client, config

from mlem.config import project_config
from mlem.core.errors import DeploymentError, EndpointNotFound, MlemError
from mlem.core.objects import (
DeployState,
DeployStatus,
MlemBuilder,
MlemDeployment,
MlemEnv,
MlemModel,
)
from mlem.runtime.client import Client, HTTPClient
from mlem.runtime.server import Server
from mlem.ui import EMOJI_OK, echo

from ..docker.base import (
DockerDaemon,
DockerImage,
DockerRegistry,
generate_docker_container_name,
)
from .build import build_k8s_docker
from .context import K8sYamlBuildArgs, K8sYamlGenerator
from .utils import create_k8s_resources, namespace_deleted, pod_is_running

POD_STATE_MAPPING = {
"Pending": DeployStatus.STARTING,
"Running": DeployStatus.RUNNING,
"Succeeded": DeployStatus.STOPPED,
"Failed": DeployStatus.CRASHED,
"Unknown": DeployStatus.UNKNOWN,
}


class K8sDeploymentState(DeployState):
"""DeployState implementation for Kubernetes deployments"""

type: ClassVar = "kubernetes"

image: Optional[DockerImage] = None
"""Docker Image being used for Deployment"""
deployment_name: Optional[str] = None
"""Name of Deployment"""


class K8sDeployment(MlemDeployment, K8sYamlBuildArgs):
"""MlemDeployment implementation for Kubernetes deployments"""

type: ClassVar = "kubernetes"
state_type: ClassVar = K8sDeploymentState
"""Type of state for Kubernetes deployments"""

server: Optional[Server] = None
"""Type of Server to use, with options such as FastAPI, RabbitMQ etc."""
registry: Optional[DockerRegistry] = DockerRegistry()
"""Docker registry"""
daemon: Optional[DockerDaemon] = DockerDaemon(host="")
"""Docker daemon"""
kube_config_file_path: Optional[str] = None
"""Path for kube config file of the cluster"""
templates_dir: List[str] = []
"""List of dirs where templates reside"""

def load_kube_config(self):
config.load_kube_config(
madhur-tandon marked this conversation as resolved.
Show resolved Hide resolved
mike0sv marked this conversation as resolved.
Show resolved Hide resolved
config_file=self.kube_config_file_path
or os.getenv("KUBECONFIG", default="~/.kube/config")
)

def _get_client(self, state: K8sDeploymentState) -> Client:
host, port = None, None
self.load_kube_config()
service = client.CoreV1Api().list_namespaced_service(self.namespace)
try:
host, port = self.service_type.get_host_and_port(
service, self.namespace
)
except MlemError as e:
raise EndpointNotFound(
"Couldn't determine host and port from the service deployed"
) from e
if host is not None and port is not None:
return HTTPClient(host=host, port=port)
raise MlemError(
f"host and port determined are not valid, received host as {host} and port as {port}"
)


class K8sEnv(MlemEnv[K8sDeployment]):
"""MlemEnv implementation for Kubernetes Environments"""

type: ClassVar = "kubernetes"
deploy_type: ClassVar = K8sDeployment
"""Type of deployment being used for the Kubernetes environment"""

registry: Optional[DockerRegistry] = None
"""Docker registry"""
templates_dir: List[str] = []
"""List of dirs where templates reside"""

def get_registry(self, meta: K8sDeployment):
registry = meta.registry or self.registry
if not registry:
raise MlemError(
"registry to be used by Docker is not set or supplied"
)
return registry

def get_image_name(self, meta: K8sDeployment):
return meta.image_name or generate_docker_container_name()

def get_server(self, meta: K8sDeployment):
return (
meta.server
or project_config(
meta.loc.project if meta.is_saved else None
).server
)

def deploy(self, meta: K8sDeployment):
self.check_type(meta)
redeploy = False
with meta.lock_state():
meta.load_kube_config()
state: K8sDeploymentState = meta.get_state()
if state.image is None or meta.model_changed():
image_name = self.get_image_name(meta)
state.image = build_k8s_docker(
meta=meta.get_model(),
image_name=image_name,
registry=self.get_registry(meta),
daemon=meta.daemon,
server=self.get_server(meta),
)
meta.update_model_hash(state=state)
redeploy = True

if (
state.deployment_name is None or redeploy
) and state.image is not None:
generator = K8sYamlGenerator(
namespace=meta.namespace,
image_name=state.image.name,
image_uri=state.image.uri,
image_pull_policy=meta.image_pull_policy,
port=meta.port,
service_type=meta.service_type,
templates_dir=meta.templates_dir or self.templates_dir,
)
create_k8s_resources(generator)

if pod_is_running(namespace=meta.namespace):
deployments_list = (
client.AppsV1Api().list_namespaced_deployment(
namespace=meta.namespace
)
)

if len(deployments_list.items) == 0:
raise DeploymentError(
f"Deployment {image_name} couldn't be found in {meta.namespace} namespace"
)
dpl_name = deployments_list.items[0].metadata.name
state.deployment_name = dpl_name
meta.update_state(state)

echo(
EMOJI_OK
+ f"Deployment {state.deployment_name} is up in {meta.namespace} namespace"
)
else:
raise DeploymentError(
f"Deployment {image_name} couldn't be set-up on the Kubernetes cluster"
)

def remove(self, meta: K8sDeployment):
self.check_type(meta)
with meta.lock_state():
meta.load_kube_config()
state: K8sDeploymentState = meta.get_state()
if state.deployment_name is not None:
client.CoreV1Api().delete_namespace(name=meta.namespace)
if namespace_deleted(meta.namespace):
echo(
EMOJI_OK
+ f"Deployment {state.deployment_name} and the corresponding service are removed from {meta.namespace} namespace"
)
state.deployment_name = None
meta.update_state(state)

def get_status(
self, meta: K8sDeployment, raise_on_error=True
) -> DeployStatus:
self.check_type(meta)
meta.load_kube_config()
state: K8sDeploymentState = meta.get_state()
if state.deployment_name is None:
return DeployStatus.NOT_DEPLOYED

pods_list = client.CoreV1Api().list_namespaced_pod(meta.namespace)

return POD_STATE_MAPPING[pods_list.items[0].status.phase]


class K8sYamlBuilder(MlemBuilder, K8sYamlGenerator):
"""MlemBuilder implementation for building Kubernetes manifests/yamls"""

type: ClassVar = "kubernetes"

target: str
"""Target path for the manifest/yaml"""

def build(self, obj: MlemModel):
self.write(self.target)
echo(EMOJI_OK + f"{self.target} generated for {obj.basename}")
30 changes: 30 additions & 0 deletions mlem/contrib/kubernetes/build.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
from typing import Optional

from mlem.core.objects import MlemModel
from mlem.runtime.server import Server
from mlem.ui import EMOJI_BUILD, echo, set_offset

from ..docker.base import DockerDaemon, DockerEnv, DockerRegistry
from ..docker.helpers import build_model_image


def build_k8s_docker(
meta: MlemModel,
image_name: str,
registry: Optional[DockerRegistry],
daemon: Optional[DockerDaemon],
server: Server,
platform: Optional[str] = "linux/amd64",
# runners usually do not support arm64 images built on Mac M1 devices
):
echo(EMOJI_BUILD + f"Creating docker image {image_name}")
with set_offset(2):
return build_model_image(
meta,
image_name,
server,
DockerEnv(registry=registry, daemon=daemon),
tag=meta.meta_hash(),
force_overwrite=True,
platform=platform,
)
55 changes: 55 additions & 0 deletions mlem/contrib/kubernetes/context.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import logging
import os
from enum import Enum
from typing import ClassVar

from pydantic import BaseModel

from mlem.contrib.kubernetes.service import NodePortService, ServiceType
from mlem.utils.templates import TemplateModel

logger = logging.getLogger(__name__)


class ImagePullPolicy(str, Enum):
always = "Always"
never = "Never"
if_not_present = "IfNotPresent"


class K8sYamlBuildArgs(BaseModel):
"""Class encapsulating parameters for Kubernetes manifests/yamls"""

class Config:
use_enum_values = True

namespace: str = "mlem"
"""Namespace to create kubernetes resources such as pods, service in"""
image_name: str = "ml"
"""Name of the docker image to be deployed"""
image_uri: str = "ml:latest"
"""URI of the docker image to be deployed"""
image_pull_policy: ImagePullPolicy = ImagePullPolicy.always
"""Image pull policy for the docker image to be deployed"""
port: int = 8080
"""Port where the service should be available"""
service_type: ServiceType = NodePortService()
"""Type of service by which endpoints of the model are exposed"""


class K8sYamlGenerator(K8sYamlBuildArgs, TemplateModel):
madhur-tandon marked this conversation as resolved.
Show resolved Hide resolved
TEMPLATE_FILE: ClassVar = "resources.yaml.j2"
TEMPLATE_DIR: ClassVar = os.path.dirname(__file__)

def prepare_dict(self):
logger.debug(
'Generating Resource Yaml via templates from "%s"...',
self.templates_dir,
)

logger.debug('Docker image is based on "%s".', self.image_uri)

k8s_yaml_args = self.dict()
k8s_yaml_args["service_type"] = self.service_type.get_string()
k8s_yaml_args.pop("templates_dir")
return k8s_yaml_args
47 changes: 47 additions & 0 deletions mlem/contrib/kubernetes/resources.yaml.j2
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
apiVersion: v1
kind: Namespace
metadata:
name: {{ namespace }}
labels:
name: {{ namespace }}

---

apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ image_name }}
namespace: {{ namespace }}
spec:
selector:
matchLabels:
app: {{ image_name }}
template:
metadata:
labels:
app: {{ image_name }}
spec:
containers:
- name: {{ image_name }}
image: {{ image_uri }}
imagePullPolicy: {{ image_pull_policy }}
ports:
- containerPort: {{ port }}

---

apiVersion: v1
kind: Service
metadata:
name: {{ image_name }}
namespace: {{ namespace }}
labels:
run: {{ image_name }}
spec:
ports:
- port: {{ port }}
protocol: TCP
targetPort: {{ port }}
selector:
app: {{ image_name }}
type: {{ service_type }}
Loading