Docker Runtime Arguments
Last night I fell down the rabbit hole of different ways to configure docker apps with runtime arguments.
Somehow it ended with me searching for ASCII art of a d20.. I settled on this:
If you’re thinking “what does ASCII art of dice have to do with docker?” Nothing really.. nothing at all.
Moving along, let’s get right to it.
My app use case
I have a python application that’s intended to be run with numerous different configurations. In particular, each instance of the application is configured for a different client, and they each are set to run on a daily schedule.
The app structure looks something like this:
.
├── Dockerfile
├── README.md
├── config
│ ├── client_1.py
│ └── client_2.py
├── my_app
│ ├── __main__.py
│ └── core.py
└── requirements.txt
So if my core.py
looks like this:
def main(config_arg):
print("hello from my_app.core")
print("using config for {}".format(config_arg))
and my __main__.py
looks like this:
from my_app.core import main
import sys
main(sys.argv[1])
then running the app with python -m my_app client_1
will print to the screen:
hello from my_app.core
using config for client_1
Alrighty, no docker yet but hopefully we are all on the same page.
My goal is to dockerize this project while maintaining the same ability to have a runtime argument that specifies the configuration to use.
Build configurations
One option is to hard code the config argument in the Dockerfile and build separate images for each client.
In this case I might have a file Dockerfile.client_1
that looks as follows:
FROM python:3.8-slim
WORKDIR /app
COPY . .
CMD ["python", "-m", "my_app", "client_1"]
Then I could build and run an image specifically for that client configuration:
docker build -t my-app-client-1 . -f Dockerfile.client_1
docker run --rm my-app-client-1
This should print the same output as above:
hello from my_app.core
using config for client_1
Using this idea, we can build similar containers for each client and run them as needed:
docker build -t my-app-client-2 . -f Dockerfile.client_2
docker build -t my-app-client-3 . -f Dockerfile.client_3
...
I don’t like this approach because it doesn’t scale well. Each new configuration requires a separate docker image which (using python3.8-slim) check in at 165MB a piece, and that’s not including additional source code and project dependencies that a real app would have. Not to mention the problem of having to rebuild every image if the source code changes.
Runtime arguments with CMD
This is probably the easiest way to speak with your python app on runtime. With a small modification to the Dockerfile CMD, you can pass the argument directly with docker run
.
The new Dockerfile can have the CMD completely removed (or not, since you’ll be overriding it)
FROM python:3.8-slim
WORKDIR /app
COPY . .
After building it with docker-build -t my-app .
the image can be run as follows:
docker run --rm my-app python -m my_app client_1
This tells docker to execute the COMMAND python -m my_app client_1
in place of any CMD that might exist in the Dockerfile.
This approach might work nicely for a really simple application, but I would prefer if the CMD could still exist in the Dockerfile so that usage is more straightforward. The ENTRYPOINT strategy below will allow for this.
Runtime arguments with ENTRYPOINT
Setting an ENTRYPOINT can help you simplify the above docker run
command so that your app is easier to use.
The new Dockerfile will look like this:
FROM python:3.8-slim
WORKDIR /app
COPY . .
ENTRYPOINT ["python", "-m", "my_app"]
CMD ["no_config_specified"]
You don’t need to include a CMD at all actually. If included (as seen above) it will be passed as a default argument to our python script.
So this way if you build and run the app as follows:
docker build -t my-app .
docker run --rm my-app
You get the output:
hello from my_app.core
using config for no_config_specified
Or you can pass in an argument as follows:
docker run --rm my-app client_1
Depending on how you’ve setup your app, this method might be the best fit for you. But there’s one more way that I like even better.
Runtime arguments with ENV
The idea here is to source the configuration of you app from an environment variable. First let’s adapt our example project to handle this.
The new core.py
will look like this (adding os.getenv lookup):
def main():
print("hello from my_app.core")
import os
config_arg = os.getenv("MY_APP_CONFIG")
print("using config for {}".format(config_arg))
and the new __main__.py
will look like this (removing sys.argv):
from my_app.core_env import main
main()
We can then adapt the Dockerfile as follows:
FROM python:3.8-slim
WORKDIR /app
COPY . .
ENV MY_APP_CONFIG=no_config_specified
CMD ["python", "-m", "my_app"]
Building and running the app will go the same as before:
docker build -t my-app .
docker run --rm my-app
and produce the same output:
hello from my_app.core
using config for no_config_specified
And now to pass arguments you can use the -e
flag in docker run
:
docker run --rm -e MY_APP_CONFIG=client_1 my-app
This method also plays well with docker-compose
, which I usually prefer.
In that case you could add a file called docker-compose.yaml
as follows:
version: "3.0"
services:
my_app:
build: .
And then run it with docker-compose up
, which will output the following:
Creating network "my-app_default" with the default driver
Building my_app
Step 1/5 : FROM python:3.8-slim
---> 9d84edf35a0a
Step 2/5 : WORKDIR /app
---> Using cache
---> f1a9fd7fbcd0
Step 3/5 : COPY . .
---> 75cf671cd1df
Step 4/5 : ENV MY_APP_CONFIG=no_config_specified
---> Running in 170806a4afca
Removing intermediate container 170806a4afca
---> c1c6dc3931e5
Step 5/5 : CMD ["python", "-m", "my_app"]
---> Running in 3c0fba25bba7
Removing intermediate container 3c0fba25bba7
---> 1840e044645f
Successfully built 1840e044645f
Successfully tagged my-app_my_app:latest
WARNING: Image for service my_app was built because it did not already exist. To rebuild this image you must use `docker-compose build` or `docker-compose up --build`.
Creating my-app_my_app_1 ... done
Attaching to my-app_my_app_1
my_app_1 | hello from my_app.core
my_app_1 | using config for no_config_specified
my-app_my_app_1 exited with code 0
Then, analogously to the docker run
command above, you can pass environment variables to docker-compose run
(note that you cannot pass env variables this way when using up
)
docker-compose run -e MY_APP_CONFIG=client_1 my_app
As expected, this will output the same damn thing we’ve been seeing this whole blog post:
hello from my_app.core
using config for client_1
Conclusion
I have to say, we’ve come a long way from google searching for ASCII art of dice. What was that all about anyway?? Unfortunately I don’t have a good answer for you, but if you’re realllly curious then reach out on twitter or send me an email.
As always, thanks for reading. Now get back to your code! Your projects are missing you ;)