Sitemap

Getting Started with Docker: Containerizing a Python Flask Application and Deploying to Azure

13 min readOct 19, 2024

--

Press enter or click to view image in full size
Python, Flask (python Lib), Docker and Azure: A powerful combination for web app development

Docker has revolutionized how developers build, ship, and run applications by providing a lightweight, portable, consistent runtime environment. In this article, we’ll explore how to get started with Docker Desktop, containerize a Python Flask application, and push your Docker image to Microsoft Azure. This guide is designed for beginners and will walk you through each step, from setting up your environment to deploying your application in the cloud.

Table of Contents

  1. Prerequisites
  2. Setting Up Docker Desktop
  3. Containerizing a Python Flask Application
  4. Writing the Dockerfile
  5. Creating an Environment File
  6. Building and Running the Docker Image
  7. Saving and Loading Docker Images
  8. Hosting our Docker Image on Azure
  9. Troubleshooting Common Docker Errors
  10. Conclusion
  11. Additional Resources

Prerequisites

Before you begin, make sure you have the following:

Setting Up Docker Desktop

Docker Desktop is an easy-to-install application that includes Docker Engine, Docker CLI client, and Docker Compose. It provides a straightforward way to manage your Docker environment as well as local deployment of Docker containers of course.

  1. Download Docker Desktop: Visit the Docker Desktop download page and select the appropriate version for your operating system (Windows or macOS).
  2. Install Docker Desktop: Run the installer and follow the on-screen instructions. You may need to enable virtualization in your BIOS settings if it’s not already enabled.
  3. Verify Installation:
docker --version

You should see the Docker version displayed in your terminal.

Containerizing a Python Flask Application

Once Docker desktop is installed we are ready to containerise our applications. In this example, I’m containerising a complex(enough) web app with numerous pages, classes, database references etc. The app itself isn't that important, what we will focus on is the dockerfile and the containerisation in general. With Docker desktop open we’ll create a Dockerfile, build an image, and run a container locally.

Writing the Dockerfile

Create a file named Dockerfile (without any extension) in the root directory of your Flask application. Open the docker file and begin to define the Python base image, working directory, files and directories that need to be copied to the image and cmd required to run the application. Here is our Dockerfile for the flask app above.

# Use the official Python 3.8 slim image as the base image
FROM python:3.8-slim

# Set the working directory within the container
WORKDIR /FLASK_APP

# Copy necessary files and directories into the container
COPY config/ /FLASK_APP/config/
COPY data/ /FLASK_APP/data/
COPY static/ /FLASK_APP/static/
COPY templates/ /FLASK_APP/templates/
COPY .env requirements.txt api.py app.py /FLASK_APP/
COPY dashboard_computations.py inventory_computations.py inventory_demand_forecast.py /FLASK_APP/
COPY product_computations.py product_computations_planning.py /FLASK_APP/
COPY support_functions.py db_cleaner.py /FLASK_APP/

# Upgrade pip and install Python dependencies
RUN pip3 install --upgrade pip && pip install --no-cache-dir -r requirements.txt

# Expose port 5000 for the Flask application
EXPOSE 5000

# Define the command to run the Flask application using Gunicorn
CMD ["gunicorn", "app:app", "-b", "0.0.0.0:5000", "-w", "4"]

At first glance, you might be thinking — this is not so bad, or you might be thinking the opposite. Either way, there is a bit to unpack here. Let's go through the main parts in order; the Base Image, Working Directory, Copy Files, Install Dependencies, Expose ports and finally, the run cmd.

  1. Base Image: We use python:3.8-slim as our base image for a lightweight environment. You can change this to whatever Python version you want.
  2. Working Directory: Sets the working directory inside the container to /FLASK_APP.
  3. Copy Files: Copies all necessary files and directories into the container. Notice the syntax for copying files to the docker image, we use the cmd syntax copy <flask_project_dir>/ /<our_previously_defined_WORKDIR>/<flask_project_dir>/. For files that remain within the root directory of our Flask app, the syntax is simply copy <filename> /<our_previously_defined_WORKDIR>/ you can add multiple files by taking a space between so copy <filename1> <filename2> <filename3> /our_previously_defined_WORKDIR>/. A note on copying files; I prefer to group my files and use multiple copy lines rather than just the single line with all the files listed. This is a personal choice, I feel it's just easy to miss a file with a large list.
  4. Install Dependencies: Upgrades pip and installs dependencies from requirements.txt. We do this with our first run cmd: RUN pip3 install — upgrade pip && pip install — no-cache-dir -r requirements.txt. You can just as easily have two separate Run cmds, however, I used the logical and (&&) operator to keep things tight.
  5. Expose Port: Exposes port 5000 to allow access to the Flask app.
  6. Command: Uses Gunicorn to run the Flask app with 4 workers and initial arguments. Let's break down these arguments within the CMD list: CMD [“gunicorn”, “app:app”, “-b”, “0.0.0.0:5000”, “-w”, “4”]
    - gunicorn’: This specifies that the Gunicorn server should be used to run the application.
    - ‘app’: This tells Gunicorn where to find the Flask application. The first “app” is the Python file name without the .py extension (so it refers to app.py). The second “app” refers to the Flask application instance inside the file. For example, if the Flask app is defined as app = Flask(__name__) in app.py, this argument will correctly point to that app instance.
    - “-b 0.0.0.0:5000”: “-b” or “ — bind” specifies the address and port for the server to listen on."0.0.0.0" allows the server to be accessible from all IP addresses, not just localhost. :5000 sets the port number for the server to listen on (port 5000 in this case).
    - “-w 4”: “-w” or “ — workers” specifies the number of worker processes Gunicorn should spawn to handle requests. 4 indicates that 4 worker processes will be created. Having multiple workers can help the application handle more requests simultaneously by utilizing multiple cores or threads. Note: using multiple worker threads can introduce a host of parallel computing nightmares so proceed with caution.

So now we have a complete dockerfile with a command that runs the Flask app (app:app) on all network interfaces at port 5000, using 4 Gunicorn worker processes for concurrency.

Creating an Environment File

Next, we will create a .env file in the root directory to store environment variables. An .env file is used to define environment variables, which are key-value pairs that can configure the behaviour of an application without modifying the source code directly. Although it might sound obvious, we use an .env file to ensure:

  • Separation of configuration: The .env file keeps environment-specific settings separate from your source code. This is useful when you need different configurations for development, testing, and production environments.
  • Security: Sensitive information such as API keys, passwords, or database credentials can be stored in an .env file (though it's important to ensure .env files are not committed to version control).
  • Ease of configuration: By changing a few lines in the .env file, you can alter how the application behaves without touching the source code.

Our .env file:

DOMAIN=localhost
PORT=8080
PREFIX=
  • DOMAIN=localhost: This defines the domain where the application will be hosted. In this case, it’s set to localhost, which means the application will be accessible only from the local machine (i.e., not externally). You can override these during deployment to Azure, although typically it would be more appropriate to define this as the Azure domain name provided <your-app-name>.azurewebsites.net, or a custom domain if you have set one up.
  • PORT=8080: This specifies the port number the application will use to serve requests. Port 8080 is a commonly used alternative to the default port 80 for HTTP traffic. It allows you to run multiple web servers on the same machine without conflict. Similarly, in the case of Azure this can be left blank and Azure will inject the default ports 80 or 443.
  • PREFIX=: This defines a URL prefix or path for the application. The value is empty in your example, meaning that the application will be accessible from the root of the domain (e.g., http://localhost:8080/). If you set this to a value like /api, the app would be accessible at http://localhost:8080/api.

Adjust the variables as needed for your application. We don't need to get ahead of ourselves with the .env file definitions. We can change everything once we are sure that the deployment works as intended and are ready to push to Azure.

Building and Running the Docker Image

With Docker desktop installed and running, on dockerfile and .env file in place we are now ready to build the docker image.

  1. Build the Docker Image:
docker build -t <docker_image_name>.

This command builds an image named <docker_image_name> based on the Dockerfile in the current directory. The -t here assigns a tag to the image. We can expand our naming convention to include the name and tag in the complete docker image for ease of management i.e., docker build -t <docker_image_name>:<version1>. Note, without the -t docker will still build the image, but assign a random name to it — so can be difficult to manage.

2. Run the Docker Container:

docker run -d -p 5000:5000 --name <container_name> <docker_image_name>

Note: Sometimes Docker can get stuck if trying to run the image/container directly from Docker desktop on load. Generally speaking, it works better if you launch the container with the CLI.

  • -d: Runs the container in detached mode.
  • -p 5000:5000: Maps port 5000 inside the container to port 5000 on your host machine.
  • --name <container_name>: Names the container for easier reference.

3. Verify the Application is Running:

  • Open your web browser and navigate to http://localhost:5000. You should see your Flask application running.

If you can't see the page, check if the port is open using:

netstat -an | find "5000"

Saving and Loading Docker Images

Now that we have a complete and working docker image that we have been able to test locally, the next step is to save (but we may as well talk about loading images for completeness)

Saving the Docker Image to a File

You can save your Docker image to a file for distribution or backup.

docker save -o docker_image_name.tar docker_image_name
  • -o docker_image_name.tar: Specifies the output file. The -o is the output flag. You can use the redirection operator also if you prefer > but this can sometimes cause problems (discussed at the end).

To check the size and information about the saved image:

ls -sh docker_image_name.tar

Loading a Docker Image from a File

To load the Docker image from the file use the flag — input, or -i or as before the redirection operator < specifying the docker image name you want to load:

docker load --input docker_image_name.tar

After loading, you can verify that the image is available:

docker images

Hosting our Docker Image on Azure

We are now finished with Docker desktop and working with our Docker image locally. To summarise so far we have; created our dockerfile and our .env as part of the containerisation of our web app. Built the docker image and saved the docker image. Now it's time to set up Azure and push the image to our Azure container registry for hosting and deployment.

Hosting our docker image on Azure is really a two-step process. First, we have to push our docker image to the container registry. Then we need to create a web app using Azure app service which pulls and deploys from the container registry. For these next steps make sure you have Azure CLI installed.

Step 1: Azure Container Registry

Let's start by logging into Azure with the Azure CLI:

az login

If you haven’t already, create an Azure Container Registry (ACR):

az acr create --resource-group <ResourceGroupName> --name <appname> --sku Basic

Next Login to your ACR:

az acr login --name <appname>

NOTE: If you encounter a permissions error, enable admin access:

az acr update -n <appname> --admin-enabled true

Now we have the ACR setup and we are ready to prepare and push our docker image. Tag your Docker image with the ACR login server:

docker tag <docker_image_name> <appname>.azurecr.io/<docker_image_name>:<tag>

In the above, all we are doing is associating the docker image with the new ACR name and applying a tag. This tag could be something like ‘latest’ or whatever you want.

Now let's push the tagged image to your Azure Container Registry:

docker push <appname>.azurecr.io/<docker_image_name>:<tag>

In the Azure Portal, navigate to your Container Registry (<appname>) and verify that the image docker_image_name has been pushed successfully.

Step 2: Deploying the docker image as an Azure Web app

Create a new app service resource called. The following screenshots show the settings I typically use under the ‘basic’ section. The important thing is to choose ‘Docker Container’ in ‘Publish’.

Press enter or click to view image in full size
Basic Azure web app setup image 1
Press enter or click to view image in full size
Azure basic setup pricing plan setup (for free tier)

Now that we have the basics setup, we can configure the container parameters to pull our docker image from the Azure container registry.

Press enter or click to view image in full size
Azure container configuration

The web app service will now pull and deploy the docker image with the tag ‘latest’. After the resource is deployed, you should be able to access your resource by following the URL in the resource overview.

Troubleshooting Common Docker Errors

Like any tool, there are a multitude of errors that can arise from docker deployments. Fortunately, with such an active community and expansive documentation, there are great resources on almost every error you will likely encounter. I always approach errors with the assumption that (1) I’ve made a mistake somewhere in my code, and (2) I’ve made a mistake with some of my docker cmds. First ensuring that your code is functioning as expected particularly regarding the execution argument passed by the dockerfile will greatly reduce the probability of errors. Then look at specific docker-related quirks and bugs related to your use of the cmds. In the below examples, I provide first a code-based error, i.e., an error arising from my code base and the dockerfile, then a Docker-based error, i.e., something specifically related to the Docker cmds.

Code-based Error: SQLite Database is Locked

You may have noticed in our dockerfile that we requested four Gunicorn worker threads. However, we didn't explicitly include any mechanisms within our code base to handle concurrent threads and things like race conditions — which is exactly what this error is about. When running multiple Gunicorn workers, you may encounter the following error:

sqlite3.OperationalError: database is locked

Cause: SQLite doesn’t handle multiple concurrent write operations well, which can occur when using multiple worker processes. Fortunately, there are a few things we can do within our dockerfile, and other things we can do in our code. If you intend to use parallel computing in your code (which you should ideally), then this will be written throughout and therefore avoid errors like this. I’m not going to get into all of that here as it is out of scope — but maybe in a future article.

Solutions:

  1. Reduce Gunicorn Workers:

Modify your CMD in the Dockerfile to use a single worker:

CMD ["gunicorn", "app:app", "-b", "0.0.0.0:5000", "-w", "1"]

2. Use Threaded Workers:

Switch to threaded workers to handle concurrency within a single process:

CMD ["gunicorn", "app:app", "-b", "0.0.0.0:5000", "-w", "1", "--threads", "4"]

3. Implement a Retry Mechanism:

Add retry logic in your database operations to handle transient locking issues.

import sqlite3
import time

def execute_query_with_retry(conn, query, max_retries=5):
retries = 0
while retries < max_retries:
try:
conn.execute(query)
conn.commit()
break
except sqlite3.OperationalError as e:
if 'locked' in str(e):
retries += 1
time.sleep(1) # wait for 1 second before retrying
else:
raise

4. Switch to a Production-Ready Database: Consider using a more robust database like PostgreSQL or MySQL that handles concurrent connections better.

Docker-based Error: Invalid Tar Header When Saving Image, or massive image size and long save times

Invalid Tar header error is suggested to result from differences in STDOUT implementation between Windows and Linux (further compounded by differences between PowerShell and cmd line). The best way to avoid this error is to ensure that your save image cmd doesn't use the STDOUT library.

STDOUT save and load example:

In this example, the docker image docker_image_name is built using the cmd docker build -t docker_image_name . We then use the redirection operator to serialise the output to the binary docker_image_name.tar with the cmd docker save > docker_image_name.tar docker_image_name. The same is used to load the binary docker image to docker with the cmd docker load < docker_image_name.tar

docker build -t <docker_image_name> . 
docker save > docker_image_name.tar docker_image_name
docker load < docker_image_name.tar

The assumption is that these cmds are used within the cmd line, however, should you use the above with Powershell — then you might notice something very interesting. The redirection operator produces UTF16-LE (“Unicode”) files by default (whereas PowerShell Core uses UTF8), i.e., files that use (at least) 2 bytes per character. Therefore, it produces files that are twice the size of raw byte input, because each byte is interpreted as a character that receives a 2-byte representation in the output.

Correct save cmd avoiding STDOUT redirection:

docker build -t docker_image_name .
docker save -o docker_image_name.tar docker_image_name
docker load --input docker_image_name.tar

Lastly and as always update Docker: Make sure your Docker installation is up to date.

Conclusion

In this article, we’ve walked through the process of containerizing a Python Flask application using Docker and pushing the Docker image to Azure Container Registry. We’ve also addressed common issues you might encounter and provided solutions to troubleshoot them.

Key Takeaways:

  • Docker simplifies application deployment by packaging everything your application needs to run.
  • Containerizing applications ensures consistency across development, testing, and production environments.
  • Azure Container Registry allows you to store and manage private Docker container images, which can be deployed to Azure services.

By mastering Docker and integrating it with cloud platforms like Azure, you can enhance your deployment strategies and improve the scalability and reliability of your applications.

Additional Resources

--

--

Tony Robinson
Tony Robinson

Written by Tony Robinson

I am a Research Software Engineer at Ulster University. My background is in electrical/electronic engineering, computer science, and informatics.

No responses yet