Software Development

Sharing Environmental Variables With Docker & uwsgi

In this post I am going to describe how to share environmental variables between applications running in a uwsgi application server instance and the shell in the same Docker container.

Background

Consider the following scenario:

  • A Django project running in a uwsgi application server instance within a Docker container.
  • The Django project requires access to certain external environmental variables, e.g. DEBUG or LOGS_DIR.
  • The user requires these same environmental variables to be available to the shell so that they can use Django’s command line tools, such as manage.py, to perform various operations on the project.

Possible solutions include:

  • Hard code these variables into the Django project’s settings.py file and specify them on the command line when running manage.py, etc.
  • Hard code these variables into the Django project’s settings.py file and separately hard code them into a startup file for the shell, e.g. ~/.profile.
  • Keep all environmental variables in a single file and have both Django and the shell import them.

The first solution is cumbersome (remembering and typing out the variables every time you need them). The second solution is less so, but both lead to the possibility of inconsistency if a value is changed in one place but not the other.

This leaves the third solution as the best. In addition, it can allow for quicker switching between development and production environments.

The Solution

Create a file called environment.txt in your Docker build directory. This file should contain your environmental variables as key-value pairs:

# Comment
VAR1=VALUE1
VAR2=VALUE2
...

In the Dockerfile for your container, add the following line to copy this file into a suitable place in the container (I use /project/conf) during the build:

COPY environment.txt /project/conf/

Now the environmental variables will be present in your Docker container. The next step is making them available to the shell.

Create a shell startup file called profile in your Docker build directory and add the following line to it:

export $(grep -v '^#' /project/conf/environment.txt | xargs -0)

When executed, this file will read the environment.txt file and export the key-value pairs contained within it into the shell environment. The -v '^#' argument to grep will skip lines beginning with # so you can use them for comments. Piping the output of grep to xargs -0 allows values to contain spaces (the argument to xargs may vary depending on the distribution you are using).

In the Dockerfile for your container, add the following line to copy this file into the primary user (usually root) directory in your Docker container:

COPY profile /root/.profile

There is, however, one gotcha. In Alpine Linux (and possibly others) the default shell is not  a login shell, which means .profile will not be executed when the container is accessed using something like docker exec -it container /bin/sh. To fix this , also add the following to the same Dockerfile:

ENV ENV="/root/.profile"

Django gets its environmental variables from the application server it is running in, so we need uwsgi to also read environment.txt. Add the following to your uwsgi.ini file:

for-readline = /project/conf/environment.txt
  env = %(_)
endfor

The final part of this solution is allowing the Django project to also access these variables. Add the following to the top of your Django project’s settings.py file:

# DON'T USE THIS CODE - SEE UPDATE BELOW

def get_env_variable(var_name):
  try:
    return os.environ[var_name]
  except KeyError:
    error_msg = 'Set the {} environmental variable'.format(var_name)
    raise ImproperlyConfigured(error_msg)

*UPDATE 2019-02-23*

So I realised that for boolean variable my original approach would not work properly. If you have DEBUG=False in your environment.txt file, the result of get_env_variable('DEBUG') will be 'False' (a string value) as opposed to False (a boolean value). In Python, evaluating a variable set to a string value will result in True, which is not what we want.

I added some lines to deal with the boolean case:

# USE THIS INSTEAD

def get_env_variable(var_name):
  try:
    if value.lower() == 'true':
      return True
    elif value.lower() == 'false':
      return False

    return os.environ[var_name]
  except KeyError:
    error_msg = 'Set the {} environmental variable'.format(var_name)
    raise ImproperlyConfigured(error_msg)

Ideally I would use a more complex case-insensitive comparison routine (see casefold, unicode.normalize, etc.) but I think this will suffice for the purposes here.

Then anywhere in settings.py that you need to access an environmental variable, simply use:

VAR1 = get_env_variable('VAR1')

All done!

To-Do

One unsolved problem remains with this solution- if you execute manage.py directly using docker exec, it does not receive the environmental variables:

docker exec -it container command  ...

Instead you have to use the more cumbersome:

docker exec -it container /bin/sh -lc 'command ...'

I don’t do this often but the grey matter is searching for a solution nonetheless…

Conclusion

This solution should also apply to other configurations, e.g. different Linux distributions, other uWSGI applications, Python 2 vs. Python 3, etc., with minimal modification.

FYI – don’t use this for passwords and other secrets!

Any comments, suggestions or corrections welcome!


Photo by Valeria Farina on Unsplash because I like lizards.

Leave a Reply

Your e-mail address will not be published. Required fields are marked *