Skip to content

Commit

Permalink
Custom base images for functions (#1297)
Browse files Browse the repository at this point in the history
  • Loading branch information
IceKhan13 authored May 7, 2024
1 parent 5957ad1 commit 510e1d4
Show file tree
Hide file tree
Showing 28 changed files with 424 additions and 73 deletions.
1 change: 1 addition & 0 deletions .github/workflows/update-component-versions.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ jobs:
shell: bash
run: |
sed -i "s/${OLDNUM}/${NEWNUM}/g" docs/deployment/cloud.rst
sed -i "s/${OLDNUM}-py310/${NEWNUM}-py310/g" docs/deployment/deploying_custom_image_function.rst
- name: Create PR
id: cpr
uses: peter-evans/create-pull-request@v5
Expand Down
152 changes: 109 additions & 43 deletions client/quantum_serverless/core/job.py
Original file line number Diff line number Diff line change
Expand Up @@ -455,54 +455,22 @@ def upload(self, program: QiskitFunction):
tracer = trace.get_tracer("client.tracer")
with tracer.start_as_current_span("job.run") as span:
span.set_attribute("program", program.title)

url = f"{self.host}/api/{self.version}/programs/upload/"
artifact_file_path = os.path.join(program.working_dir, "artifact.tar")

# check if entrypoint exists
if not os.path.exists(
os.path.join(program.working_dir, program.entrypoint)
):
raise QuantumServerlessException(
f"Entrypoint file [{program.entrypoint}] does not exist "
f"in [{program.working_dir}] working directory."
if program.image is not None:
# upload function with custom image
program_title = _upload_with_docker_image(
program=program, url=url, token=self._token, span=span
)

with tarfile.open(artifact_file_path, "w") as tar:
for filename in os.listdir(program.working_dir):
fpath = os.path.join(program.working_dir, filename)
tar.add(fpath, arcname=filename)

# check file size
size_in_mb = Path(artifact_file_path).stat().st_size / 1024**2
if size_in_mb > MAX_ARTIFACT_FILE_SIZE_MB:
raise QuantumServerlessException(
f"{artifact_file_path} is {int(size_in_mb)} Mb, "
f"which is greater than {MAX_ARTIFACT_FILE_SIZE_MB} allowed. "
f"Try to reduce size of `working_dir`."
elif program.entrypoint is not None:
# upload funciton with artifact
program_title = _upload_with_artifact(
program=program, url=url, token=self._token, span=span
)

with open(artifact_file_path, "rb") as file:
response_data = safe_json_request(
request=lambda: requests.post(
url=url,
data={
"title": program.title,
"entrypoint": program.entrypoint,
"arguments": json.dumps({}),
"dependencies": json.dumps(program.dependencies or []),
"env_vars": json.dumps(program.env_vars or {}),
},
files={"artifact": file},
headers={"Authorization": f"Bearer {self._token}"},
timeout=REQUESTS_TIMEOUT,
)
else:
raise QuantumServerlessException(
"Function must either have `entryoint` or `image` specified."
)
program_title = response_data.get("title", "na")
span.set_attribute("program.title", program_title)

if os.path.exists(artifact_file_path):
os.remove(artifact_file_path)

return program_title

Expand Down Expand Up @@ -835,3 +803,101 @@ def _map_status_to_serverless(status: str) -> str:
return status_map[status]
except KeyError:
return status


def _upload_with_docker_image(
program: QiskitFunction, url: str, token: str, span: Any
) -> str:
"""Uploads function with custom docker image.
Args:
program (QiskitFunction): function instance
url (str): upload gateway url
token (str): auth token
span (Any): tracing span
Returns:
str: uploaded function name
"""
response_data = safe_json_request(
request=lambda: requests.post(
url=url,
data={
"title": program.title,
"image": program.image,
"arguments": json.dumps({}),
"dependencies": json.dumps(program.dependencies or []),
"env_vars": json.dumps(program.env_vars or {}),
},
headers={"Authorization": f"Bearer {token}"},
timeout=REQUESTS_TIMEOUT,
)
)
program_title = response_data.get("title", "na")
span.set_attribute("program.title", program_title)
return program_title


def _upload_with_artifact(
program: QiskitFunction, url: str, token: str, span: Any
) -> str:
"""Uploads function with artifact.
Args:
program (QiskitFunction): function instance
url (str): endpoint for gateway upload
token (str): auth token
span (Any): tracing span
Raises:
QuantumServerlessException: if no entrypoint or size of artifact is too large.
Returns:
str: uploaded function name
"""
artifact_file_path = os.path.join(program.working_dir, "artifact.tar")

# check if entrypoint exists
if not os.path.exists(os.path.join(program.working_dir, program.entrypoint)):
raise QuantumServerlessException(
f"Entrypoint file [{program.entrypoint}] does not exist "
f"in [{program.working_dir}] working directory."
)

with tarfile.open(artifact_file_path, "w") as tar:
for filename in os.listdir(program.working_dir):
fpath = os.path.join(program.working_dir, filename)
tar.add(fpath, arcname=filename)

# check file size
size_in_mb = Path(artifact_file_path).stat().st_size / 1024**2
if size_in_mb > MAX_ARTIFACT_FILE_SIZE_MB:
raise QuantumServerlessException(
f"{artifact_file_path} is {int(size_in_mb)} Mb, "
f"which is greater than {MAX_ARTIFACT_FILE_SIZE_MB} allowed. "
f"Try to reduce size of `working_dir`."
)

with open(artifact_file_path, "rb") as file:
response_data = safe_json_request(
request=lambda: requests.post(
url=url,
data={
"title": program.title,
"entrypoint": program.entrypoint,
"arguments": json.dumps({}),
"dependencies": json.dumps(program.dependencies or []),
"env_vars": json.dumps(program.env_vars or {}),
},
files={"artifact": file},
headers={"Authorization": f"Bearer {token}"},
timeout=REQUESTS_TIMEOUT,
)
)
program_title = response_data.get("title", "na")
span.set_attribute("program.title", program_title)

if os.path.exists(artifact_file_path):
os.remove(artifact_file_path)

return program_title
7 changes: 7 additions & 0 deletions docs/deployment/custom_function/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
FROM icr.io/quantum-public/quantum-serverless-ray-node:0.9.0-py39

# install all necessary dependencies for your custom image

# copy our function implementation in `/runner.py` of the docker image
RUN mkdir /runner
COPY ./runner.py /runner/runner.py
Empty file.
Empty file.
10 changes: 10 additions & 0 deletions docs/deployment/custom_function/runner.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@


class Runner:
def run(self, arguments: dict) -> dict:
return {
**arguments,
**{
"answer": 42
}
}
99 changes: 99 additions & 0 deletions docs/deployment/deploying_custom_image_function.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
==================================
Building custom image for function
==================================


In this tutorial we will describe how to build custom docker image for function.

In this tutorial we will be following 3 steps to deploy our function with custom docker image:

* implement function template
* define dockerfile, build it and push to registry
* upload

All of our custom image files will be located in a folder `custom_function`, which will 2 files: `Dockerfile` and `runner.py`.

.. code-block::
:caption: Custom image folder source files
/custom_function
/runner.py
/Dockerfile
First we will implement function entrypoint by following template. All functions with custom docker images must follow same template structure.

We need to create class `Runner` and implement `run` method that will be called during invocation of the function and results of the run method will be returned as result of the function.

Let's create `runner.py` file with following content

.. code-block::
:caption: `runner.py` - Runner class implementation. This is an entrypoint to you custom image function.
class Runner:
def run(self, arguments: dict) -> dict:
# this is just an example
# your function can call for other modules, function, etc.
return {
**arguments,
**{
"answer": 42
}
}
As a next step let's define and build our custom docker image.

Dockerfile will be extending base serverless node image and adding required packages and structure to it.

In our simple case it will look something like this

.. code-block::
:caption: Dockerfile for custom image function.
FROM icr.io/quantum-public/quantum-serverless-ray-node:0.10.1-py310
# install all necessary dependencies for your custom image
# copy our function implementation in `/runner.py` of the docker image
COPY ./runner.py ./runner.py
and then we need to build it

.. code-block::
:caption: Build and push image.
docker build -t icr.io/quantum-public/my-custom-function-image:1.0.0 ./custom_function
docker push icr.io/quantum-public/my-custom-function-image:1.0.0
We got to our final step of function development - uploading to serverless.

Let define `QiskitFunction` with image we just build, give it a name and upload it.

.. code-block::
:caption: Uploading and using function with custom image.
import os
from quantum_serverless import QiskitFunction, ServerlessClient
serverless = ServerlessClient(
token=os.environ.get("GATEWAY_TOKEN", "awesome_token"),
host=os.environ.get("GATEWAY_HOST", "http://localhost:8010"),
)
serverless
function_with_custom_image = QiskitFunction(
title="custom-image-function",
image="icr.io/quantum-public/my-custom-function-image:1.0.0"
)
function_with_custom_image
serverless.upload(function_with_custom_image)
functions = {f.title: f for f in serverless.list()}
my_function = functions.get("custom-image-function")
my_function
job = my_function.run(test_argument_one=1, test_argument_two="two")
job
job.result()
1 change: 1 addition & 0 deletions docs/deployment/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,4 @@ compute resources.
local
cloud
client_configuration
deploying_custom_image_function
1 change: 1 addition & 0 deletions gateway/api/management/commands/free_resources.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
"""Cleanup resources command."""

import logging

from django.conf import settings
Expand Down
1 change: 1 addition & 0 deletions gateway/api/management/commands/schedule_queued_jobs.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
"""Cleanup resources command."""

import json
import logging
import time
Expand Down
1 change: 1 addition & 0 deletions gateway/api/management/commands/update_jobs_statuses.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
"""Cleanup resources command."""

import logging

from concurrency.exceptions import RecordModifiedError
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
# Generated by Django 4.2.11 on 2024-04-25 20:42

import api.models
import django.core.validators
from django.db import migrations, models


class Migration(migrations.Migration):
dependencies = [
("api", "0021_alter_program_options_program_instances"),
]

operations = [
migrations.AddField(
model_name="program",
name="image",
field=models.CharField(blank=True, max_length=511, null=True),
),
migrations.AlterField(
model_name="program",
name="artifact",
field=models.FileField(
blank=True,
null=True,
upload_to=api.models.get_upload_path,
validators=[
django.core.validators.FileExtensionValidator(
allowed_extensions=["tar"]
)
],
),
),
migrations.AlterField(
model_name="program",
name="entrypoint",
field=models.CharField(default="main.py", max_length=255),
),
]
11 changes: 8 additions & 3 deletions gateway/api/models.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
"""Models."""

import uuid

from concurrency.fields import IntegerVersionField
Expand All @@ -18,6 +19,9 @@ def get_upload_path(instance, filename):
return f"{instance.author.username}/{instance.id}/{filename}"


DEFAULT_PROGRAM_ENTRYPOINT = "main.py"


class JobConfig(models.Model):
"""Job Configuration model."""

Expand Down Expand Up @@ -61,11 +65,11 @@ class Program(ExportModelOperationsMixin("program"), models.Model):
created = models.DateTimeField(auto_now_add=True, editable=False)

title = models.CharField(max_length=255, db_index=True)
entrypoint = models.CharField(max_length=255)
entrypoint = models.CharField(max_length=255, default=DEFAULT_PROGRAM_ENTRYPOINT)
artifact = models.FileField(
upload_to=get_upload_path,
null=False,
blank=False,
null=True,
blank=True,
validators=[FileExtensionValidator(allowed_extensions=["tar"])],
)

Expand All @@ -77,6 +81,7 @@ class Program(ExportModelOperationsMixin("program"), models.Model):
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
)
image = models.CharField(max_length=511, null=True, blank=True)

class Meta:
permissions = ((RUN_PROGRAM_PERMISSION, "Can run function"),)
Expand Down
Loading

0 comments on commit 510e1d4

Please sign in to comment.