Securing your Containerised Models and Workloads
Containerisation is now the de facto means of deploying many applications, with Docker being the forefront software driving its adoption. With its popularity also comes the increased risk of attacks [1]. Hence it will serve us well to secure our docker applications. The most fundamental means of doing this is to ensure that we set the user within our containers as a non-root user.
CONTENTS
========
Why use non-root?
What you can & cannot do as a default non-root user
The Four Scenarios
1) Serve a model from host (Read Only)
2) Run data processing pipelines (Write within Container)
3) Libraries automatically writing files (Write within Container)
4) Save trained models (Write to Host)
Summary
Why use Non-Root?
Or rather, why not use the root user? Let's take an example of a dummy architecture like the one below.
Security is often viewed in a multi-layered approach. If an attacker manages to enter a container, the permissions it has as a user will be the first layer of defence. If the container user is assigned to have root access, the attacker can have free control of everything within the container. With such broad access, it can also exploit any potential vulnerabilities present and using that, potentially escape out to the host, and gain full access access to all connecting systems. The consequences are severe, including the following:
- retrieve the secrets stored
- intercept and disrupt your traffic
- run malicious services like crypto-mining
- gain access to any connecting sensitive services like databases
Damn, that sounds really scary! Well, the solution is simple, change your containers to a non-root user!
Before we even go to the rest of the article, if you do not have a good grasp of Linux permissions and access rights, do take a look at my previous article [2].
What You Can & Cannot Do as a Default Non-Root User
Let's attempt to create a simple Docker application with a default non-root user. Use the Dockerfile
below.
# Dockerfile
FROM python:3.11-slim
WORKDIR /app
# create a dummy py file
RUN echo "print('I can run an existing py file')" > example.py
# create & switch to non-root user
RUN adduser --no-create-home nonroot
USER nonroot
Build the image and create a container with it.
docker build -t test .
docker run -it test bash
Now that you are inside the container, let's try a few commands. So what are the things that you cannot do? You can see that all kinds of writing and installation permissions are not allowed.
On the opposite spectrum, we can run all kinds of read permissions.
Because we have python installed, it is a little unique. If we ls -l $(which python)
we can see that the python interpreter has full permissions. Thus, it can execute existing python files like the example.py
file we created initially in the Dockerfile
. We can even enter into the python console and run simple commands. However, as other system write permissions have been removed when we switch to the non-root user, you can see that we cannot create and modify the scripts, or use python to run write commands.
While system-wide restrictions are good for security, there will be many instances whereby write permissions for specific files and directories are required, and we need to cater for such allowances.
In the following sections, I will give examples of four scenarios in a Machine Learning operations lifecycle. With these examples, one should be able to gain an understanding of how to implement for most other instances.
The Four Scenarios
1) Serve a Model from Host – Read Only
When serving a model, it involves an inference and serving script to load the model and expose it via an API (e.g., Flask, FastAPI) to accept inputs. The model is sometimes loaded from the host machine, and separated from the image so that the image size is optimally small, and any reload of the image will be optimally quick without repeated model downloads. The model is then passed into the container via a bind-mount **** volume, to be loaded and served.
This is probably the least cumbersome way to implement a non-root user because only read permission is required, which by default is granted to all users. Below is a sample Dockerfile
of how that is done.
# Dockerfile
FROM python:3.11-slim
WORKDIR /app
COPY requirements.txt .
RUN pip3 install --no-cache-dir --upgrade pip~=23.2.1
&& pip3 install --no-cache-dir -r requirements.txt
COPY ./project/ /app
# add non-root user ---------------------
RUN adduser --no-create-home nonroot
# switch from root to non-root user -----
USER nonroot
CMD ["python", "inference.py"]
It has two simple commands to first create a new system user called nonroot
. Second, it is then switched from the root to the nonroot
user just before the last CMD
line. This is important as a default non-root user does not have any write and execute permissions, so it cannot install, copy or manipulate files that are required as seen in the earlier steps.
Now that we know how to assign a non-root user in Docker, let's go to the next step.
2) Run Data Processing Pipelines – Write within Container
Sometimes, we just want to store temporary files to execute some jobs, let's say, some data preprocessing work. This consists of adding and deleting files. We can do such tasks within the container since the files are not persistent.
However, we will need write permissions if we are using a non-root user. To do that, we will need to use the command chown
(change owner) and assign ownership to the nonroot
user for the specific folder where write access is required. With that done, we can then switch the user to nonroot
.
# Dockerfile
# ....
# add non-root user & grant ownership to processing folder
RUN adduser --no-create-home nonroot &&
mkdir processing &&
chown nonroot processing
# switch from root to non-root user
USER nonroot
CMD ["python", "preprocess.py"]
3) Libraries Automatically Writing Files – Write within Container
The previous example shows how to write files which we created ourselves. However, it is also common for the libraries you use to create files and directories automatically. You will only know they are created when you try running the container and it is denied permission to write.
I will show you two such examples, one from supervisor
, which is used to manage multiple processes, and another from huggingface-hub
, for downloading models from huggingface. Permission errors like these will be seen if we switch to a non-root user.
For the two supervisor
files, we can create them as empty files first, and assign ownership rights to them. For the huggingface-hub
download issue, it has already been hinted in the error log that we can change the download directory via the TRANSFORMERS_CACHE
variable, hence we can first assign the directory variable, create the directory, and then assign ownership.
# Dockerfile
# ....
# add non-root user ................
# change huggingface dl dir
ENV TRANSFORMERS_CACHE=/app/model
RUN adduser --no-create-home nonroot &&
# create supervisor files & huggingfacehub dir
touch /app/supervisord.log /app/supervisord.pid &&
mkdir $TRANSFORMERS_CACHE &&
# grant supervisor & huggingfacehub write access
chown nonroot /app/supervisord.log &&
chown nonroot /app/supervisord.pid &&
chown nonroot $TRANSFORMERS_CACHE
USER nonroot
CMD ["supervisord", "-c", "conf/supervisord.conf"]
Of course, there will be other examples that may slightly differ from what I show here, but the concept of allowing the least permissions to write will be the same.
4) Save Trained Models – Write to Host
Let's say we are using a container to train a model, and we want that model to be written to the host, e.g., to be picked up by another task for benchmarking or deployment. For this instance, we will need to write the model file out by linking a container directory to the host directory, also known as a bind mount.
First, we need to create a group and user for nonroot
, specifying a unique ID for each, where for this case, we use 1001
(any number from 1000 is fine). Then, a model directory to store the model is created.
A difference here compared to Scenario 2 is that chown
is not required for the model directory to write. Why?
# Dockerfile
# ....
# add non-root group/user & create model folder
ENV UID=1001
RUN addgroup --gid $UID nonroot &&
adduser --uid $UID --gid $UID --no-create-home nonroot &&
mkdir model
# switch from root to non-root user
USER nonroot
CMD ["python", "train.py"]
This is because the permission of the bind-mounted directory is determined by the host directory. Hence, we need to again create the same user in the host, ensuring that the user id is the same. The model directory is then created in the host and the nonroot
user is granted the owner permissions.
# in host terminal
# add the same user & group
addgroup --gid 1001
adduser --uid 1001 --gid 1001 --no-create-home nonroot
# create model dir to bind-mount & make nonroot an owner
mkdir /home/model
chown nonroot /home/model
Bind mount is usually specified in the docker-compose.yml
file or docker run
command to enable more flexibility. Below is an example of the former.
version: "3.5"
services:
modeltraining:
container_name: modeltraining
build:
dockerfile: Dockerfile
volumes:
- type: bind
source: /home/model # host dir
target: /app/model # container dir
And for the latter:
docker run -d --name modeltraining -v /home/model:/app/model
Run either of them, and you will see that your non-root user can execute the script without any issues.
Summary
We have seen how we can assign non-root user and still make the containers work with their desired tasks. This is mainly relevant when specific write permissions are required. We just need to know two fundamental concepts.
- For writing permissions in the container,
chown
in theDockerfile
- For writing permissions for a bind-mount, create the same non-root user in the host and
chown
in the host directory
If you need to enter into the docker container and run some tests as a root user, we can use the following command.
docker exec -it -u 0 bash
References
- [1] Wong et al. (2023) On the Security of Containers: Threat Modeling, Attack Analysis, and Mitigation Strategies. Computers & Security, Vol. 128.
- [2] My previous post on Linux permissions and access rights: https://medium.com/@teosiyang/securing-linux-servers-with-two-commands-de5b565dc104