Skip to content

Commit cde7595

Browse files
committed
Add production deployment pipeline and related steps for model deployment
1 parent 5fa513b commit cde7595

File tree

11 files changed

+407
-10
lines changed

11 files changed

+407
-10
lines changed
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
# Apache Software License 2.0
2+
#
3+
# Copyright (c) ZenML GmbH 2024. All rights reserved.
4+
#
5+
# Licensed under the Apache License, Version 2.0 (the "License");
6+
# you may not use this file except in compliance with the License.
7+
# You may obtain a copy of the License at
8+
#
9+
# http://www.apache.org/licenses/LICENSE-2.0
10+
#
11+
# Unless required by applicable law or agreed to in writing, software
12+
# distributed under the License is distributed on an "AS IS" BASIS,
13+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
# See the License for the specific language governing permissions and
15+
# limitations under the License.
16+
#
17+
18+
# environment configuration
19+
settings:
20+
docker:
21+
python_package_installer: uv
22+
required_integrations:
23+
- aws
24+
- sklearn
25+
- bentoml
26+
27+
28+
# configuration of steps
29+
steps:
30+
notify_on_success:
31+
parameters:
32+
notify_on_success: False
33+
34+
# configuration of the Model Control Plane
35+
model:
36+
name: gitguarden
37+
version: staging
38+
39+
# pipeline level extra configurations
40+
extra:
41+
notify_on_failure: True
42+
43+
44+
parameters:
45+
target_env: staging

train_and_deploy/pipelines/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,3 +19,4 @@
1919
from .batch_inference import gitguarden_batch_inference
2020
from .training import gitguarden_training
2121
from .local_deployment import gitguarden_local_deployment
22+
from .deploy_production import gitguarden_production_deployment
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
# Apache Software License 2.0
2+
#
3+
# Copyright (c) ZenML GmbH 2024. All rights reserved.
4+
#
5+
# Licensed under the Apache License, Version 2.0 (the "License");
6+
# you may not use this file except in compliance with the License.
7+
# You may obtain a copy of the License at
8+
#
9+
# http://www.apache.org/licenses/LICENSE-2.0
10+
#
11+
# Unless required by applicable law or agreed to in writing, software
12+
# distributed under the License is distributed on an "AS IS" BASIS,
13+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
# See the License for the specific language governing permissions and
15+
# limitations under the License.
16+
#
17+
18+
from steps import dockerize_bento_model, notify_on_failure, notify_on_success, deploy_model_to_k8s
19+
20+
from zenml import pipeline
21+
22+
23+
@pipeline(on_failure=notify_on_failure, enable_cache=False)
24+
def gitguarden_production_deployment(
25+
target_env: str,
26+
):
27+
"""Model deployment pipeline.
28+
29+
This is a pipeline deploys trained model for future inference.
30+
"""
31+
### ADD YOUR OWN CODE HERE - THIS IS JUST AN EXAMPLE ###
32+
# Link all the steps together by calling them and passing the output
33+
# of one step as the input of the next step.
34+
########## Deployment stage ##########
35+
# Get the production model artifact
36+
bento_model_image = dockerize_bento_model(target_env=target_env)
37+
deploy_model_to_k8s(bento_model_image)
38+
39+
notify_on_success(after=["deploy_model_to_k8s"])
40+
### YOUR CODE ENDS HERE ###

train_and_deploy/run.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
from pipelines import (
2424
gitguarden_batch_inference,
2525
gitguarden_local_deployment,
26+
gitguarden_production_deployment,
2627
gitguarden_training,
2728
)
2829
from zenml.logger import get_logger
@@ -133,6 +134,12 @@
133134
default=False,
134135
help="Whether to run the inference pipeline.",
135136
)
137+
@click.option(
138+
"--production",
139+
is_flag=True,
140+
default=False,
141+
help="Whether to run the production pipeline.",
142+
)
136143
def main(
137144
no_cache: bool = False,
138145
no_drop_na: bool = False,
@@ -145,6 +152,7 @@ def main(
145152
training: bool = True,
146153
deployment: bool = False,
147154
inference: bool = False,
155+
production: bool = False,
148156
):
149157
"""Main entry point for the pipeline execution.
150158
@@ -166,6 +174,7 @@ def main(
166174
thresholds are violated - the pipeline will fail. If `False` thresholds will
167175
not affect the pipeline.
168176
only_inference: If `True` only inference pipeline will be triggered.
177+
production: If `True` only production pipeline will be triggered.
169178
"""
170179
# Run a pipeline with the required parameters. This executes
171180
# all steps in the pipeline in the correct order using the orchestrator
@@ -225,6 +234,20 @@ def main(
225234
gitguarden_batch_inference.with_options(**pipeline_args)(
226235
**run_args_inference
227236
)
237+
if production:
238+
# Execute Production Pipeline
239+
run_args_production = {}
240+
pipeline_args["config_path"] = os.path.join(
241+
os.path.dirname(os.path.realpath(__file__)),
242+
"configs",
243+
"deploy_production.yaml",
244+
)
245+
pipeline_args["run_name"] = (
246+
f"gitguarden_production_run_{dt.now().strftime('%Y_%m_%d_%H_%M_%S')}"
247+
)
248+
gitguarden_production_deployment.with_options(**pipeline_args)(
249+
**run_args_production
250+
)
228251

229252

230253
if __name__ == "__main__":

train_and_deploy/service.py

Lines changed: 26 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,32 @@
1+
import bentoml
2+
import numpy as np
3+
from bentoml.validators import Shape
4+
from typing_extensions import Annotated
15

26

3-
import bentoml
4-
from bentoml.io import NumpyNdarray
7+
@bentoml.service
8+
class GitGuarden:
9+
"""
10+
A simple service using a sklearn model
11+
"""
512

6-
gitguarden_runner = bentoml.sklearn.get("gitguarden").to_runner()
13+
# Load in the class scope to declare the model as a dependency of the service
14+
iris_model = bentoml.models.get("gitguarden:latest")
715

8-
svc = bentoml.Service(name="gitguarden_service", runners=[gitguarden_runner])
16+
def __init__(self):
17+
"""
18+
Initialize the service by loading the model from the model store
19+
"""
20+
import joblib
921

10-
input_spec = NumpyNdarray(dtype="float", shape=(-1, 30))
22+
self.model = joblib.load(self.iris_model.path_of("saved_model.pkl"))
1123

12-
@svc.api(input=input_spec, output=NumpyNdarray())
13-
async def predict(input_arr):
14-
return await gitguarden_runner.predict.async_run(input_arr)
24+
@bentoml.api
25+
def predict(
26+
self,
27+
input_series: Annotated[np.ndarray, Shape((-1, 30))],
28+
) -> np.ndarray:
29+
"""
30+
Define API with preprocessing and model inference logic
31+
"""
32+
return self.model.predict(input_series)

train_and_deploy/steps/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,4 +31,4 @@
3131
promote_with_metric_compare,
3232
)
3333
from .training import model_evaluator, model_trainer
34-
from .deployment import deployment_deploy, bento_builder
34+
from .deployment import deployment_deploy, bento_builder, dockerize_bento_model, deploy_model_to_k8s

train_and_deploy/steps/deployment/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,3 +18,5 @@
1818

1919
from .deployment_deploy import deployment_deploy
2020
from .bento_builder import bento_builder
21+
from .dockerize_bento import dockerize_bento_model
22+
from .deploy_to_k8s import deploy_model_to_k8s

train_and_deploy/steps/deployment/bento_builder.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ def bento_builder() -> (
6363
bento_model = bentoml.sklearn.save_model(model.name, model.load_artifact(name="model"))
6464
# Build the BentoML bundle
6565
bento = bentos.build(
66-
service="service.py:svc",
66+
service="service.py:GitGuarden",
6767
labels={
6868
"zenml_version": zenml_version,
6969
"model_name": model.name,
Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
# Copyright (c) ZenML GmbH 2024. All Rights Reserved.
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at:
6+
#
7+
# https://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express
12+
# or implied. See the License for the specific language governing
13+
# permissions and limitations under the License.
14+
from pathlib import Path
15+
from typing import Dict, Optional
16+
17+
import yaml
18+
from kubernetes import client, config
19+
from kubernetes.client.rest import ApiException
20+
from zenml import get_step_context, step
21+
from zenml.client import Client
22+
from zenml.logger import get_logger
23+
24+
logger = get_logger(__name__)
25+
26+
def apply_kubernetes_configuration(k8s_configs: list) -> None:
27+
"""Apply Kubernetes configurations using the K8s Python client.
28+
29+
Args:
30+
k8s_configs: List of Kubernetes configuration dictionaries
31+
"""
32+
# Load Kubernetes configuration
33+
try:
34+
config.load_kube_config()
35+
except:
36+
config.load_incluster_config() # For in-cluster deployment
37+
38+
# Initialize API clients
39+
k8s_apps_v1 = client.AppsV1Api()
40+
k8s_core_v1 = client.CoreV1Api()
41+
42+
for k8s_config in k8s_configs:
43+
kind = k8s_config["kind"]
44+
name = k8s_config["metadata"]["name"]
45+
namespace = k8s_config["metadata"].get("namespace", "default")
46+
47+
try:
48+
if kind == "Deployment":
49+
# Check if deployment exists
50+
try:
51+
k8s_apps_v1.read_namespaced_deployment(name, namespace)
52+
# Update existing deployment
53+
k8s_apps_v1.patch_namespaced_deployment(
54+
name=name,
55+
namespace=namespace,
56+
body=k8s_config
57+
)
58+
logger.info(f"Updated existing deployment: {name}")
59+
except ApiException as e:
60+
if e.status == 404:
61+
# Create new deployment
62+
k8s_apps_v1.create_namespaced_deployment(
63+
namespace=namespace,
64+
body=k8s_config
65+
)
66+
logger.info(f"Created new deployment: {name}")
67+
else:
68+
raise e
69+
70+
elif kind == "Service":
71+
# Check if service exists
72+
try:
73+
k8s_core_v1.read_namespaced_service(name, namespace)
74+
# Update existing service
75+
k8s_core_v1.patch_namespaced_service(
76+
name=name,
77+
namespace=namespace,
78+
body=k8s_config
79+
)
80+
logger.info(f"Updated existing service: {name}")
81+
except ApiException as e:
82+
if e.status == 404:
83+
# Create new service
84+
k8s_core_v1.create_namespaced_service(
85+
namespace=namespace,
86+
body=k8s_config
87+
)
88+
logger.info(f"Created new service: {name}")
89+
else:
90+
raise e
91+
92+
except ApiException as e:
93+
logger.error(f"Error applying {kind} {name}: {e}")
94+
raise e
95+
96+
@step
97+
def deploy_model_to_k8s(
98+
docker_image_tag: str,
99+
namespace: str = "default"
100+
) -> Dict:
101+
"""Deploy a service to Kubernetes with the specified docker image and tag.
102+
103+
Args:
104+
docker_image: The full docker image name (e.g. "organization/image-name")
105+
docker_image_tag: The tag to use for the docker image
106+
namespace: Kubernetes namespace to deploy to (default: "default")
107+
108+
Returns:
109+
dict: Dictionary containing deployment information
110+
"""
111+
# Get model name from context
112+
model_name = get_step_context().model.name
113+
114+
# Read the K8s template
115+
template_path = Path(__file__).parent / "k8s_template.yaml"
116+
with open(template_path, "r") as f:
117+
# Load all documents in the YAML file
118+
k8s_configs = list(yaml.safe_load_all(f))
119+
120+
# Update both Service and Deployment configurations
121+
for config in k8s_configs:
122+
# Add namespace
123+
config["metadata"]["namespace"] = namespace
124+
125+
# Update metadata labels and name
126+
config["metadata"]["labels"]["app"] = model_name
127+
config["metadata"]["name"] = model_name
128+
129+
if config["kind"] == "Service":
130+
# Update service selector
131+
config["spec"]["selector"]["app"] = model_name
132+
133+
elif config["kind"] == "Deployment":
134+
# Update deployment selector and template
135+
config["spec"]["selector"]["matchLabels"]["app"] = model_name
136+
config["spec"]["template"]["metadata"]["labels"]["app"] = model_name
137+
138+
# Update the container image and name
139+
containers = config["spec"]["template"]["spec"]["containers"]
140+
for container in containers:
141+
container["name"] = model_name
142+
container["image"] = docker_image_tag
143+
144+
# Apply the configurations
145+
try:
146+
apply_kubernetes_configuration(k8s_configs)
147+
deployment_status = "success"
148+
logger.info(f"Successfully deployed model {model_name} with image: {docker_image_tag}")
149+
except Exception as e:
150+
deployment_status = "failed"
151+
logger.error(f"Failed to deploy model {model_name}: {str(e)}")
152+
raise e
153+
154+
# Return deployment information
155+
deployment_info = {
156+
"model_name": model_name,
157+
"docker_image": docker_image_tag,
158+
"namespace": namespace,
159+
"status": deployment_status,
160+
"service_port": 3000,
161+
"configurations": k8s_configs
162+
}
163+
164+
return deployment_info

0 commit comments

Comments
 (0)