Skip to main content

GitLab CI Pipeline: Python Flask Web Application, Deploy and Run Python in Container, Deploy and Run Compiled Python Bytecode in Container via Multistage Dockerfile, Deploy and Run Compiled Python Binary in Container via Multistage Dockerfile

1772 words·
GitLab GitLab CI CI Pipeline Python Flask Multistage Dockerfile Unprivileged Container
Table of Contents
GitHub Repository Available

Overview
#

My Setup
#

In this tutorial I’m using the following setup based on Ubuntu 22.04 servers, GitLab is dockerized:

192.168.70.4 # GitLab
192.168.70.5 # GitLab Runner
192.168.70.6 # Deployment Server with Docker platform installed

I’m using the same setup for the deployment via SSH as described in my blog post: GitLab CI Pipeline - Deploy website with Nginx Docker Container

The main requirements for the deployment via SSH key are the following:

  • Create a SSH key pair on the server where GitLab is deployed

  • Copy the public SSH key to the authorized keys file of the Deployment server

  • SSH into the deployment servers to add the fingerprint

  • Add the private SSH key as variable to the GitLab Repository CI/CD settings

  • On the deployment server, a user named “gitlab-deployment”, that is member of the Docker group, is used for the authentication of for the GitLab CI pipeline


Container Image Name
#

The build container image name in the GitLab CI pipeline follows this naming convention: $CI_REGISTRY_IMAGE/$CI_COMMIT_REF_SLUG:$CI_COMMIT_SHA

  • $CI_REGISTRY_IMAGE Points to the container registry for the project.

  • $CI_COMMIT_REF_SLUG Represents the branch or tag.

  • $CI_COMMIT_SHA Ensures every image is uniquely tied to a specific commit for traceability and reproducibility.


In my project named “flask-web-application”, the final image name looks like this:

# Container image name
gitlab-registry.jklug.work/python/flask-web-application/main:7f6d37fbc1aac00c89aba837b3ce083e75464290
  • Registry: gitlab-registry.jklug.work/python

  • Project: flask-web-application

  • Branch: main

  • Commit: 7f6d37fbc1aac00c89aba837b3ce083e75464290



GitLab Repository
#

File & Folder Structure
#

The file and folder structure of the GitLab repository looks like this:

GitLab-Repository
├── Dockerfiles
│   ├── Dockerfile_Multistage_Binary  # Runs compiled binary
│   ├── Dockerfile_Multistage_Bytecode  # Runs compile bytecode
│   └── Dockerfile_PythonCode  # Runs Python code
├── flask-app  # Python Flask web application
│   ├── app.py
│   └── requirements.txt
├── .gitlab-ci.yml  # GitLab CI Pipeline manifest
└── README.md

CI Pipeline Manifest
#

  • .gitlab-ci.yml
### Variables
variables:
  DEPLOY_IP: "192.168.70.6"  # Deployment server IP
  DEPLOY_USER: "gitlab-deployment"  # Deployment server SSH user
  DEPLOY_PORT_HOST: 8080  # Host port
  DEPLOY_PORT_CONT: 8080  # Container port
  CONTAINER_NAME: "flask-app"  # Name of the deployed container

  # Define the image name, tagging it with the GitLab CI registry and the current commit SHA
  IMAGE_SHA: $CI_REGISTRY_IMAGE/$CI_COMMIT_REF_SLUG:$CI_COMMIT_SHA
  # Define Dockerfile to use, for example: "Dockerfile_PythonCode", "Dockerfile_Multistage_Bytecode", "Dockerfile_Multistage_Binary"
  DOCKERFILE: Dockerfile_PythonCode


### Stages
stages:
  - build
  - deploy


### Build Container Image
build_image:
  image: docker:stable
  stage: build
  services:
    - docker:dind
  variables:
    DOCKER_TLS_CERTDIR: ""
  before_script:
    # Login to GitLab Container Registry using predefined CI/CD variables
    - docker login -u "$CI_REGISTRY_USER" -p "$CI_REGISTRY_PASSWORD" $CI_REGISTRY
  script:
    # Build the Docker image from the specified Dockerfile in the Dockerfiles directory
    - docker build --pull -t $IMAGE_SHA -f Dockerfiles/$DOCKERFILE .
    # Push the built Docker image to the GitLab Container Registry
    - docker push $IMAGE_SHA
  rules: 
    # Rule: Run this job only for the main branch and if the specified Dockerfile exists
    - if: $CI_COMMIT_BRANCH == "main"
      exists:
        - Dockerfiles/$DOCKERFILE


### Deploy Container to Virtual Machine
deploy_container:
  stage: deploy
  image: alpine:latest
  needs:
    - build_image  # Run this job only if the 'build_image' job succeeds
  before_script:
    # Update the package index, install the OpenSSH client for SSH connections
    - apk update && apk add openssh-client
    # If the private SSH key file ($ID_RSA) exists, set secure permissions (read/write for the owner only)
    - if [ -f "$ID_RSA" ]; then chmod og= $ID_RSA; fi
  script:
    #  SSH into the deployment server, log in to the GitLab Container registry
    - ssh -i $ID_RSA -o StrictHostKeyChecking=no $DEPLOY_USER@$DEPLOY_IP "docker login -u gitlab-ci-token -p $CI_JOB_TOKEN $CI_REGISTRY"
     # SSH into the deployment server, pull the image from the registry
    - ssh -i $ID_RSA -o StrictHostKeyChecking=no $DEPLOY_USER@$DEPLOY_IP "docker pull $IMAGE_SHA"
    # SSH into the deployment server, remove the existing container (if it exists)
    - ssh -i $ID_RSA -o StrictHostKeyChecking=no $DEPLOY_USER@$DEPLOY_IP "docker container rm -f $CONTAINER_NAME || true"
    # SSH into the deployment server, run the new container
    - ssh -i $ID_RSA -o StrictHostKeyChecking=no $DEPLOY_USER@$DEPLOY_IP "docker run -d -p $DEPLOY_PORT_HOST:$DEPLOY_PORT_CONT --restart=unless-stopped --name $CONTAINER_NAME $IMAGE_SHA"
  rules:
    # Rule: Run this job only for main branch
    - if: $CI_COMMIT_BRANCH == "main"

Dockerfile
#

Basic Version: Python Code
#

  • The following Dockerfile builds a Docker container that just runs a Python Flask web application.

  • This is a very basic setup intended for quick testing, the container image is about 134MB in size.

  • The python:slim image is based on Debian 12 Bookworm.


Dockerfile: “Dockerfile_PythonCode”

# Use the latest Python-slime (Debian 12) base image
FROM python:slim AS builder

# Set the working directory inside the container to /app
WORKDIR /app

# Copy the contents of the "flask-app" directory to "/app" inside the container
ADD flask-app/ /app/

# Install the dependencies listed in "requirements.txt"
RUN pip install -r requirements.txt

# Expose port 8080 for the application
EXPOSE 8080

# Create a non-root user and group
RUN groupadd appgroup && useradd -g appgroup appuser

# Set permissions for the application directory
RUN chown -R appuser:appgroup /app

# Switch to the non-root user
USER appuser

# Run the app.py file with Python when the container starts
ENTRYPOINT ["python", "app.py"]

Multistage Version: Bytecode
#

  • This Dockerfile compiles the previous Python Flask web application into Python bytecode via the first stage of the Dockerfile and creates a new Alpine based lightweight container that runs the application.

  • Python Bytecode .pyc files still require the Python interpreter to execute and are not standalone executables.

  • The final container image is about 57MB in size.

  • The python:3.13-rc-alpine image is based on Alpine Linux v3.20


Dockerfile: “Dockerfile_Multistage_Bytecode”

### Stage 1: Compile Python Application

# Use Python-slim image as the base image / Label image as "builder"
FROM python:3.13-slim AS builder

# Set the working directory inside the container to /app
WORKDIR /app

# Copy the contents of the "flask-app" directory to "/app" inside the container
ADD flask-app/. /app

# Install the dependencies listed in "requirements.txt"
RUN pip install -r requirements.txt

# Compile all Python files to bytecode and move the desired one to /app as app.pyc
RUN python -m compileall .



### Stage 2: Final Container with Bytecode

# Use a lightweight Python image for the final stage
FROM python:3.13-rc-alpine

# Copy requirements.txt from builder
COPY --from=builder /app/requirements.txt /tmp/   

# Install app requirements
RUN pip install -r /tmp/requirements.txt

# Create system user "appuser" / no PW
RUN adduser -S -D -H -h /app appuser

# Switch to "appuser"
USER appuser

# Copy compiled .pyc files and any necessary files from the builder stage
COPY --from=builder /app/__pycache__/* /app/

# Define working directory
WORKDIR /app

# Expose port 8080 for the application
EXPOSE 8080

# Execute Bytecode when the container starts
ENTRYPOINT ["python", "/app/app.cpython-313.pyc"]
  • app.cpython-313.pyc The name of the compiled Python bytecode must be adapted regarding the Python version, in this example it’s version 3.13.

Multistage Version: Binary
#

  • This Dockerfile compiles the previous Python Flask web application into binary via the first stage of the Dockerfile and creates a new Debian based lightweight container that runs the binary.

  • The final container is about 120MB in size.


Dockerfile: “Dockerfile_Multistage_Binary”

### Stage 1: Build and Compile with PyInstaller

# Use Python image as the base image / Label image as "builder"
FROM python:3.13 AS builder

# Set the working directory inside the container to /app
WORKDIR /app

# Copy the contents of the "flask-app" directory to "/app" inside the container
COPY flask-app/. /app

# Install the dependencies listed in "requirements.txt" and PyInstaller
RUN pip install -r requirements.txt pyinstaller

# Use PyInstaller to create a standalone binary for "app.py"
RUN pyinstaller --onefile app.py



### Stage 2: Final Container with Standalone Binary

# Use a lightweight Debian image for the final stage
FROM debian:bookworm-slim

# Install dependencies to run the Python binary
RUN apt-get update && apt-get install -y libstdc++6

# Define working directory
WORKDIR /app

# Copy the standalone binary from the builder stage
COPY --from=builder /app/dist/app /app/

# Create a non-root user
RUN useradd -m -d /app -s /bin/bash appuser

# Switch to "appuser"
USER appuser

# Expose port 8080 for the application
EXPOSE 8080

# Run the standalone binary when the container starts
ENTRYPOINT ["/app/app"]



Python Code
#

Flask Application
#

  • flask-app/app.py
# Import the Flask class from the Flask library
from flask import Flask

 # Create an instance of the Flask application
app = Flask(__name__)


# Define a route for the root URL ("/")
@app.route("/")
def main():   # Define a function that will execute when the root URL is accessed
    return "Hi there from Python Flask application\n"  # Return a simple string response to the client.


# Start the Flask app on all available network interfaces (0.0.0.0) and port 8080
app.run(host='0.0.0.0', port=8080)

Requirements
#

  • flask-app/requirements.txt
# Flask web framework version 3.1.0
Flask==3.1.0



Verify Deployment
#

Basic Dockerfile Version
#

Verify the container:

# List Docker containers
docker ps

# Shell output:
CONTAINER ID   IMAGE                                                                                                   COMMAND           CREATED          STATUS          PORTS                                       NAMES
4c991e03924b   gitlab-registry.jklug.work/python/flask-web-application/main:77c4f4c7f91fd8896776e3f2ab6e63d7fcc99c96   "python app.py"   25 seconds ago   Up 25 seconds   0.0.0.0:8080->8080/tcp, :::8080->8080/tcp   flask-app

Verify the container user:

# Access the container terminal
docker exec -it 4c991e03924b sh

# List details of current user
id

# Shell output:
uid=1000(appuser) gid=1000(appgroup) groups=1000(appgroup)

Verify the container image:

# List docker images
docker images

# Shell output:
REPOSITORY                                                     TAG                                        IMAGE ID       CREATED          SIZE
gitlab-registry.jklug.work/python/flask-web-application/main   77c4f4c7f91fd8896776e3f2ab6e63d7fcc99c96   586775fd1c42   2 minutes ago    134MB

Verify the Python Flask application:

# Curl the container
curl localhost:8080

# Shell output:
Hi there from Python Flask application

Multistage Dockerfile Version: Bytecode
#

Verify the container:

# List Docker containers
docker ps

# Shell output:
CONTAINER ID   IMAGE                                                                                                   COMMAND                  CREATED              STATUS              PORTS                                       NAMES
f59ebd46e291   gitlab-registry.jklug.work/python/flask-web-application/main:7f6d37fbc1aac00c89aba837b3ce083e75464290   "python /app/app.cpy…"   About a minute ago   Up About a minute   0.0.0.0:8080->8080/tcp, :::8080->8080/tcp   flask-app

Verify the container user:

# Access the container terminal
docker exec -it f59ebd46e291 sh

# List details of current user
id

# Shell output:
uid=100(appuser) gid=65533(nogroup) groups=65533(nogroup)

Verify the container image:

# List docker images
docker images

# Shell output:
REPOSITORY                                                     TAG                                        IMAGE ID       CREATED              SIZE
gitlab-registry.jklug.work/python/flask-web-application/main   7f6d37fbc1aac00c89aba837b3ce083e75464290   e6773b97bad5   About a minute ago   57.2MB

Verify the Python Flask application:

# Curl the container
curl localhost:8080

# Shell output:
Hi there from Python Flask application

Multistage Dockerfile Version: Binary
#

Verify the container:

# List Docker containers
docker ps

# Shell output:
CONTAINER ID   IMAGE                                                                                                   COMMAND      CREATED         STATUS         PORTS                                       NAMES
1e7e7654abfa   gitlab-registry.jklug.work/python/flask-web-application/main:7a2ca6c8f86b2a7966b3f65d1c817059dcd04bda   "/app/app"   2 minutes ago   Up 2 minutes   0.0.0.0:8080->8080/tcp, :::8080->8080/tcp   flask-app

Verify the container user:

# Access the container terminal
docker exec -it 1e7e7654abfa sh

# List details of current user
id

# Shell output:
uid=1000(appuser) gid=1000(appuser) groups=1000(appuser)

Verify the container image:

# List docker images
docker images

# Shell output:
REPOSITORY                                                     TAG                                        IMAGE ID       CREATED             SIZE
gitlab-registry.jklug.work/python/flask-web-application/main   7a2ca6c8f86b2a7966b3f65d1c817059dcd04bda   a172a8db5ebc   3 minutes ago       120MB

Verify the Python Flask application:

# Curl the container
curl localhost:8080

# Shell output:
Hi there from Python Flask application



Links #

# Latest Flask version
https://pypi.org/project/Flask/

jueklu/gitlab-ci-python-flask-web-application

GitLab CI Pipeline the containerizes and deploys a Python Flask web application.

Python
0
0