2 Building Containers
02_building_containers
Containers are like light-weight virtual machines — container engines use operating system tricks to isolate programs from each other (“containing” them), making them work as though they were running on their own hardware with their own filesystem. This makes execution environments more reproducible, for example by preventing accidental cross-contamination of environments on the same machine. For added security, Modal runs containers using the sandboxed gVisor container runtime.
Containers are started up from a stored “snapshot” of their filesystem state called an image. Producing the image for a container is called building the image.
By default, Modal functions are executed in a Debian Linux container with a basic Python installation of the same minor version v3.x as your local Python interpreter.
Customizing this environment is critical. To make your apps and functions useful, you will probably need some third party system packages or Python libraries. To make them start up faster, you can bake data like model weights into the container image, taking advantage of Modal’s optimized filesystem for serving containers.
Modal provides a number of options to customize your container images at different levels of abstraction and granularity, from high-level convenience methods like pip_install through wrappers of core container image build features like RUN and ENV to full on “bring-your-own-Dockerfile”. We’ll cover each of these in this guide, along with tips and tricks for building images effectively when using each tool.
2.1 import_sklearn.py
2.1.1 Install scikit-learn in a custom image
This builds a custom image which installs the sklearn (scikit-learn) Python package in it. It’s an example of how you can use packages, even if you don’t have them installed locally.
First, the imports:
import time
import modal
Next, we’ll define an app, with a custom image that installs sklearn
.
= modal.App(
app "import-sklearn",
=modal.Image.debian_slim()
image"libgomp1")
.apt_install("scikit-learn"),
.pip_install( )
A nice design in modal
is the idea of method chaining, where the image is built by layers.
The app.image.imports()
lets us conditionally import in the global scope. This is needed because we might not have sklearn and numpy installed locally, but we know they are installed inside the custom image.
with app.image.imports():
import numpy as np
from sklearn import datasets, linear_model
Now, let’s define a function that uses one of scikit-learn’s built-in datasets and fits a very simple model (linear regression) to it.
@app.function()
def fit():
print("Inside run!")
= time.time()
t0 = datasets.load_diabetes(return_X_y=True)
diabetes_X, diabetes_y = diabetes_X[:, np.newaxis, 2]
diabetes_X = linear_model.LinearRegression()
regr
regr.fit(diabetes_X, diabetes_y)return time.time() - t0
Finally, we’d trigger the run locally, which we time with time.time() - t0
.
Observe that the first time we run this snippet, it will build the image. This might take 1-2 minutes.
But when we run this subsequent times, the image is already build, and it will run much faster.
if __name__ == "__main__":
= time.time()
t0 with app.run():
= fit.remote()
t print("Function time spent:", t)
print("Full time spent:", time.time() - t0)
So let’s run it:
$ modal run import_sklearn.py
✓ Initialized. View run at https://modal.com/charlotte-llm/main/apps/ap-xxxxxxxxxx
Building image im-m9EoOtS0dmWsGUat8WCWFc
=> Step 0: FROM base
=> Step 1: RUN apt-get update
Get:1 http://deb.debian.org/debian bullseye InRelease [116 kB]
Get:2 http://deb.debian.org/debian-security bullseye-security InRelease [48.4 kB]
Get:3 http://deb.debian.org/debian bullseye-updates InRelease [44.1 kB]
Get:4 http://deb.debian.org/debian bullseye/main amd64 Packages [8068 kB]
Get:5 http://deb.debian.org/debian-security bullseye-security/main amd64 Packages [275 kB]
Get:6 http://deb.debian.org/debian bullseye-updates/main amd64 Packages.diff/Index [26.3 kB]
Get:7 http://deb.debian.org/debian bullseye-updates/main amd64 Packages T-2023-12-29-1403.39-F-2023-07-31-2005.11.pdiff [6053 B]
Get:7 http://deb.debian.org/debian bullseye-updates/main amd64 Packages T-2023-12-29-1403.39-F-2023-07-31-2005.11.pdiff [6053 B]
Get:8 http://deb.debian.org/debian bullseye-updates/main amd64 Packages [18.8 kB]
Fetched 8602 kB in 4s (2239 kB/s)
Reading package lists...
=> Step 2: RUN apt-get install -y libgomp1
Reading package lists...
Building dependency tree...
Reading state information...
libgomp1 is already the newest version (10.2.1-6).
libgomp1 set to manually installed.
0 upgraded, 0 newly installed, 0 to remove and 46 not upgraded.
Creating image snapshot...
Finished snapshot; took 1.14s
Built image im-m9EoOtS0dmWsGUat8WCWFc in 8.53s
Building image im-Kndkz3TpRhPEMy6UcNR7YR
=> Step 0: FROM base
=> Step 1: RUN python -m pip install scikit-learn
Looking in indexes: http://pypi-mirror.modal.local:5555/simple
Collecting scikit-learn
Downloading http://pypi-mirror.modal.local:5555/simple/scikit-learn/scikit_learn-1.5.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl (13.3 MB)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 13.3/13.3 MB 169.9 MB/s eta 0:00:00
Requirement already satisfied: numpy>=1.19.5 in /usr/local/lib/python3.10/site-packages (from scikit-learn) (1.25.0)
Collecting scipy>=1.6.0 (from scikit-learn)
Downloading http://pypi-mirror.modal.local:5555/simple/scipy/scipy-1.13.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl (38.6 MB)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 38.6/38.6 MB 233.1 MB/s eta 0:00:00
Collecting joblib>=1.2.0 (from scikit-learn)
Downloading http://pypi-mirror.modal.local:5555/simple/joblib/joblib-1.4.2-py3-none-any.whl (301 kB)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 301.8/301.8 kB 252.9 MB/s eta 0:00:00
Collecting threadpoolctl>=3.1.0 (from scikit-learn)
Downloading http://pypi-mirror.modal.local:5555/simple/threadpoolctl/threadpoolctl-3.5.0-py3-none-any.whl (18 kB)
Installing collected packages: threadpoolctl, scipy, joblib, scikit-learn
Successfully installed joblib-1.4.2 scikit-learn-1.5.0 scipy-1.13.1 threadpoolctl-3.5.0
[notice] A new release of pip is available: 23.1.2 -> 24.0
[notice] To update, run: pip install --upgrade pip
Creating image snapshot...
Finished snapshot; took 2.27s
Built image im-Kndkz3TpRhPEMy6UcNR7YR in 13.14s
✓ Created objects.
├── 🔨 Created mount /modal-examples/02_building_containers/import_sklearn.py
└── 🔨 Created function fit.
Inside run!
Stopping app - local entrypoint completed.
✓ App completed. View run at https://modal.com/charlotte-llm/main/apps/ap-xxxxxxxxxx
That took 8.53s
to build the first image, 2.27s
to create the snapshot and 13.14s
to build the second image.
But if we run this again, it’ll be much faster than before as we’ve already.
2.2 import_sklearn_r2.py
Just for fun, let’s modify this script to now output the R^2 value on the test data.
import_sklearn_r2.py
import time
import modal
= modal.App(
app "import-sklearn",
=modal.Image.debian_slim()
image"libgomp1")
.apt_install("scikit-learn"),
.pip_install(
)
with app.image.imports():
import numpy as np
from sklearn import datasets, linear_model
from sklearn.model_selection import train_test_split
from sklearn.metrics import r2_score
@app.function()
def fit():
print("Inside run!")
= datasets.load_diabetes(return_X_y=True)
X, y = X[:, np.newaxis, 2]
X = train_test_split(X, y, test_size=0.33, random_state=42)
X_train, X_test, y_train, y_test = linear_model.LinearRegression()
regr
regr.fit(X_train, y_train)= regr.predict(X_test)
predict
return r2_score(predict, y_test)
if __name__ == "__main__":
= time.time()
t0 with app.run():
= fit.remote()
t print("R Squared is:", t)
print("Full time spent:", time.time() - t0)
Running this, we get:
$ modal run import_sklearn_r2.py
✓ Initialized. View run at https://modal.com/charlotte-llm/main/apps/ap-xxxxxxxxx
✓ Created objects.
├── 🔨 Created mount /modal-examples/02_building_containers/import_sklearn_r2.py
└── 🔨 Created function fit.
Inside run!
Stopping app - local entrypoint completed.
✓ App completed. View run at https://modal.com/charlotte-llm/main/apps/ap-xxxxxxxxxx
This result somewhat surprised me.
First, I didn’t see the output R^2. I was expecting this perhaps the first time running, but didn’t see it.
Second, after running, unlike the previous example that shut down immediately, this container was running ephemerally:
So let’s rerun, but this time renaming our function from fit
to fit_r2
:
$ modal run import_sklearn_r2.py
✓ Initialized. View run at https://modal.com/charlotte-llm/main/apps/ap-xxxxxxxxxx
✓ Created objects.
├── 🔨 Created mount /modal-examples/02_building_containers/import_sklearn_r2.py
└── 🔨 Created function fit_r2.
Inside run!
Stopping app - local entrypoint completed.
✓ App completed. View run at https://modal.com/charlotte-llm/main/apps/ap-xxxxxxxxxx
This avoided the issue of perpetually running but didn’t print the R^2 to console:
Instead, I modified to put the print
within the function such that:
@app.function()
def fit_r2():
print("Inside run!")
= datasets.load_diabetes(return_X_y=True)
X, y = X[:, np.newaxis, 2]
X = train_test_split(X, y, test_size=0.33, random_state=42)
X_train, X_test, y_train, y_test = linear_model.LinearRegression()
regr
regr.fit(X_train, y_train)= regr.predict(X_test)
predict = r2_score(predict, y_test)
r2 print("R squared is:", r2) # added this
return r2
When doing this, I now get the result I want:
$ modal run 02_building_containers/import_sklearn_r2.py
✓ Initialized. View run at https://modal.com/charlotte-llm/main/apps/ap-xxxxxxxxxx
✓ Created objects.
├── 🔨 Created mount /Users/ryan/modal-examples/02_building_containers/import_sklearn_r2.py
└── 🔨 Created function fit_r2.
Inside run!
R squared is: -0.8503156043967386
Stopping app - local entrypoint completed.
✓ App completed. View run at https://modal.com/charlotte-llm/main/apps/ap-xxxxxxxxxx
It’s important to mindful of scope of local versus remote when using Modal. This will be an extended discussion we’ll see in later examples.
2.3 install_cuda.py
The next examples shows how to use the Nvidia CUDA base image from DockerHub.
Here’s a list of the different CUDA images available.
We need to add Python 3 and pip with the add_python
option because the image doesn’t have these by default.
install_cuda.py
from modal import App, Image
= Image.from_registry(
image "nvidia/cuda:12.2.0-devel-ubuntu22.04", add_python="3.11"
)= App(image=image)
app
@app.function(gpu="T4")
def f():
import subprocess
"nvidia-smi"]) subprocess.run([
.from_registry
You don’t always need to start from scratch! Public registries like Docker Hub have many pre-built container images for common software packages.
You can use any public image in your function using Image.from_registry
, so long as:
- Python 3.8 or above is present, and is available as python
pip
is installed correctly- The image is built for the
linux/amd64
platform - The image has a valid
ENTRYPOINT
from modal import Image
= Image.from_registry("huanjason/scikit-learn")
sklearn_image
@app.function(image=sklearn_image)
def fit_knn():
from sklearn.neighbors import KNeighborsClassifier
...
If an existing image does not have either python or pip set up properly, you can still use it. Just provide a version number as the add_python
argument to install a reproducible, standalone build of Python:
from modal import Image
= Image.from_registry("ubuntu:22.04", add_python="3.11")
image1 = Image.from_registry("gisops/valhalla:latest", add_python="3.11") image2
The from_registry
method can load images from all public registries, such as Nvidia’s nvcr.io, AWS ECR, and GitHub’s ghcr.io.
Modal also supports access to private AWS ECR and GCP Artifact Registry images.
Running it provides:
$ modal run install_cuda.py
✓ Initialized. View run at https://modal.com/charlotte-llm/main/apps/ap-xxxxxxxxxx
Building image im-NAV0762Ag7PgTCJY8XyAqb
=> Step 0: FROM nvidia/cuda:12.2.0-devel-ubuntu22.04
Getting image source signatures
Copying blob sha256:9a9dd462fc4c5ca1dd29994385be60a5bb359843fc93447331b8c97dfec99bf9
Copying blob sha256:9fe5ccccae45d6811769206667e494085cb511666be47b8e659087c249083c3f
Copying blob sha256:aece8493d3972efa43bfd4ee3cdba659c0f787f8f59c82fb3e48c87cbb22a12e
Copying blob sha256:bdddd5cb92f6b4613055bcbcd3226df9821c7facd5af9a998ba12dae080ef134
Copying blob sha256:8054e9d6e8d6718cc3461aa4172ad048564cdf9f552c8f9820bd127859aa007c
Copying blob sha256:5324914b447286e0e6512290373af079a25f94499a379e642774245376e60885
Copying blob sha256:95eef45e00fabd2bce97586bfe26be456b0e4b3ef3d88d07a8b334ee05cc603c
Copying blob sha256:e2554c2d377e1176c0b8687b17aa7cbe2c48746857acc11686281a4adee35a0a
Copying blob sha256:4640d022dbb8eb47da53ccc2de59f8f5e780ea046289ba3cffdf0a5bd8d19810
Copying blob sha256:aa750c79a4cc745750c40a37cad738f9bcea14abb96b0c5a811a9b53f185b9c9
Copying blob sha256:9e2de25be969afa4e73937f8283a1100f4d964fc0876c2f2184fda25ad23eeda
Copying config sha256:fead46ae620f9febc59f92a8f1f277f502ef6dca8111ce459c154d236ee84eee
Writing manifest to image destination
Unpacking OCI image
• unpacking rootfs ...
• ... done
• unpacked image rootfs: /tmp/.tmpDUhHRA
=> Step 1: COPY /python/. /usr/local
=> Step 2: RUN ln -s /usr/local/bin/python3 /usr/local/bin/python
=> Step 3: ENV TERMINFO_DIRS=/etc/terminfo:/lib/terminfo:/usr/share/terminfo:/usr/lib/terminfo
=> Step 4: COPY /modal_requirements.txt /modal_requirements.txt
=> Step 5: RUN python -m pip install --upgrade pip
Looking in indexes: http://pypi-mirror.modal.local:5555/simple
Requirement already satisfied: pip in /usr/local/lib/python3.11/site-packages (23.2.1)
Collecting pip
Obtaining dependency information for pip from http://pypi-mirror.modal.local:5555/simple/pip/pip-24.0-py3-none-any.whl.metadata
Downloading http://pypi-mirror.modal.local:5555/simple/pip/pip-24.0-py3-none-any.whl.metadata (3.6 kB)
Downloading http://pypi-mirror.modal.local:5555/simple/pip/pip-24.0-py3-none-any.whl (2.1 MB)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 2.1/2.1 MB 216.4 MB/s eta 0:00:00
Installing collected packages: pip
Attempting uninstall: pip
Found existing installation: pip 23.2.1
Uninstalling pip-23.2.1:
Successfully uninstalled pip-23.2.1
Successfully installed pip-24.0
=> Step 6: RUN python -m pip install -r /modal_requirements.txt
Looking in indexes: http://pypi-mirror.modal.local:5555/simple
Ignoring cloudpickle: markers 'python_version < "3.11"' don't match your environment
Ignoring ddtrace: markers 'python_version < "3.11"' don't match your environment
Collecting aiohttp==3.8.3 (from -r /modal_requirements.txt (line 2))
...
Creating image snapshot...
Finished snapshot; took 6.10s
Built image im-NAV0762Ag7PgTCJY8XyAqb in 136.43s
✓ Created objects.
├── 🔨 Created mount /modal-examples/02_building_containers/install_cuda.py
└── 🔨 Created function f.
==========
== CUDA ==
==========
CUDA Version 12.2.0
Container image Copyright (c) 2016-2023, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
This container image and its contents are governed by the NVIDIA Deep Learning Container License.
By pulling and using the container, you accept the terms and conditions of this license:
https://developer.nvidia.com/ngc/nvidia-deep-learning-container-license
A copy of this license is made available in this container at /NGC-DL-CONTAINER-LICENSE for your convenience.
Thu Jun 13 23:08:03 2024
+-----------------------------------------------------------------------------------------+
| NVIDIA-SMI 550.54.15 Driver Version: 550.54.15 CUDA Version: 12.4 |
|-----------------------------------------+------------------------+----------------------+
| GPU Name Persistence-M | Bus-Id Disp.A | Volatile Uncorr. ECC |
| Fan Temp Perf Pwr:Usage/Cap | Memory-Usage | GPU-Util Compute M. |
| | | MIG M. |
|=========================================+========================+======================|
| 0 Tesla T4 On | 00000000:36:00.0 Off | ERR! |
| N/A 32C ERR! 9W / 70W | 0MiB / 15360MiB | 0% Default |
| | | N/A |
+-----------------------------------------+------------------------+----------------------+
+-----------------------------------------------------------------------------------------+
| Processes: |
| GPU GI CI PID Type Process name GPU Memory |
| ID ID Usage |
|=========================================================================================|
| No running processes found |
+-----------------------------------------------------------------------------------------+
Stopping app - local entrypoint completed.
✓ App completed. View run at https://modal.com/charlotte-llm/main/apps/ap-xxxxxxxxxx
While this process did take a few minutes, it’s very easy and should be very quick if rerunning.
It’s also helpful to note how to run a subprocess as we would in Python anyways:
import subprocess
"nvidia-smi"]) subprocess.run([
2.4 screenshot.py
In this example, we use Modal functions and the playwright
package to take screenshots of websites from a list of URLs in parallel.
You can run this example on the command line with:
modal run 02_building_containers/screenshot.py --url 'https://www.youtube.com/watch?v=dQw4w9WgXcQ'
playwright
locally
When I first ran screenshot.py
, I received an error like:
Stopping app - uncaught exception raised locally: ExecutionError('Could not deserialize remote exception due to local error:\nDeserialization failed because the \'playwright\' module is not available in the local environment.\nThis can happen if your local environment does not have the remote exception definitions.
It was fixed after I installed playwright
into my active local Python environment.
This should take a few seconds then create a /tmp/screenshots/screenshot.png
file, shown below.
2.4.1 Setup
First we import the Modal client library.
import pathlib
import modal
= modal.App("example-screenshot") app
2.4.2 Define a custom image
We need an image with the playwright
Python package as well as its chromium
plugin pre-installed.
This requires intalling a few Debian packages, as well as setting up a new Debian repository. Modal lets you run arbitrary commands, just like in Docker:
= modal.Image.debian_slim().run_commands(
image "apt-get update",
"apt-get install -y software-properties-common",
"apt-add-repository non-free",
"apt-add-repository contrib",
"pip install playwright==1.42.0",
"playwright install-deps chromium",
"playwright install chromium",
)
2.4.3 The screenshot
function
Next, the scraping function which runs headless Chromium, goes to a website, and takes a screenshot.
This is a Modal function which runs inside the remote container.
@app.function(image=image)
async def screenshot(url):
from playwright.async_api import async_playwright
async with async_playwright() as p:
= await p.chromium.launch()
browser = await browser.new_page()
page await page.goto(url, wait_until="networkidle")
await page.screenshot(path="screenshot.png")
await browser.close()
= open("screenshot.png", "rb").read()
data print("Screenshot of size %d bytes" % len(data))
return data
2.4.4 Entrypoint code
Let’s kick it off by reading a bunch of URLs from a txt file and scrape some of those.
@app.local_entrypoint()
def main(url: str = "https://modal.com"):
= pathlib.Path("/tmp/screenshots/screenshot.png")
filename = screenshot.remote(url)
data =True)
filename.parent.mkdir(exist_okwith open(filename, "wb") as f:
f.write(data)print(f"wrote {len(data)} bytes to {filename}")
And we’re done! Modal’s introductory guide also has another example of a web scraper, with more in-depth logic.