Skip to main content

GitLab CI Pipeline: C++ Application Container Deployment, Container Image Size Comparison between Dynamic Linking and Static Compilation with Debian Slim and Alpine Images

1755 words·
GitLab GitLab CI CI Pipeline C++ Multistage Dockerfile Debian Alpine
Table of Contents

Overview
#

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



GitLab Repository
#

Overview
#

In this tutorial I’m comparing different Dockerfiles for the Deployment of a C++ application. The container image sizes of the deployed containers are as follows:

  • Singlestage Dockerfile with Debian Slim: 586MB

  • Multistage Dockerfile with Debian Slim (Dynamic Linking): 336MB

  • Multistage Dockerfile with Debian Slim (Static Compilation): 78.3MB

  • Multistage Dockerfile with Alpine (Static Compilation): 11.3MB


File & Folder Structure
#

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

GitLab-Repository
├── Dockerfile
├── example-app
│   └── example-app.cpp
├── .gitlab-ci.yml
└── README.md

Dockerfile
#

Singlestage: Debian Slim
#

  • Dockerfile
# Use Debian as the base image
FROM debian:bookworm-slim

# Set the working directory in the container
WORKDIR /app

# Install necessary dependencies in a single RUN command to reduce image layers
RUN apt update && apt install -y --no-install-recommends \
    g++ \
    libboost-system-dev \
    libboost-thread-dev \
    libboost-chrono-dev \
    libboost-random-dev \
    libssl-dev \
    cmake \
    && apt clean && rm -rf /var/lib/apt/lists/*

# Copy the source code into the container
COPY example-app/example-app.cpp .

# Compile the C++ code
RUN g++ -o example-app example-app.cpp -lboost_system -lboost_thread -lssl -lcrypto -lpthread

# Expose the port on which the API will listen
EXPOSE 8080

# Run the compiled application when the container starts
ENTRYPOINT  ["./example-app"]

Multistage Version: Debian Slim (Dynamic Linking)
#

  • This Dockerfile uses dynamic linking / dynamic compilation in the build stage. The binary does not include all the code from the libraries it depends on.

  • Dockerfile
# Use Debian as the base image / Label image as "builder"
FROM debian:bookworm-slim AS builder

# Set the working directory in the container
WORKDIR /app

# Install necessary dependencies in a single RUN command to reduce image layers
RUN apt update && apt install -y --no-install-recommends \
    g++ \
    libboost-system-dev \
    libboost-thread-dev \
    libboost-chrono-dev \
    libboost-random-dev \
    libssl-dev \
    cmake \
    && apt clean && rm -rf /var/lib/apt/lists/*

# Copy the source code into the container
COPY example-app/example-app.cpp .

# Compile the C++ code
RUN g++ -o example-app example-app.cpp -lboost_system -lboost_thread -lssl -lcrypto -lpthread



# Second stage: Use a minimal base image for the final runtime container
FROM debian:bookworm-slim

# Set the working directory in the container
WORKDIR /app

# Copy the compiled binary from the builder stage
COPY --from=builder /app/example-app .

# Install runtime dependencies (only what's needed to run the binary)
RUN apt update && apt install -y --no-install-recommends \
    libboost-system-dev \
    libboost-thread-dev \
    libssl-dev \
    && apt clean && rm -rf /var/lib/apt/lists/*

# Expose the port on which the API will listen
EXPOSE 8080

# Run the compiled application when the container starts
ENTRYPOINT  ["./example-app"]

Multistage Version: Debian Slim (Static Compilation)
#

  • This Dockerfile uses static compilation in the build stage. The Compiled binary includes all necessary libraries within itself, making it independent of the runtime environment.

  • With a statically compiled binary, the second / runtime stage becomes very small. The image is about 78MB.


  • Dockerfile
# Use Debian as the base image / Label image as "builder"
FROM debian:bookworm-slim AS builder

# Set the working directory in the container
WORKDIR /app

# Install necessary dependencies in a single RUN command to reduce image layers
RUN apt update && apt install -y --no-install-recommends \
    g++ \
    libboost-system-dev \
    libboost-thread-dev \
    libboost-chrono-dev \
    libboost-random-dev \
    libssl-dev \
    cmake \
    && apt clean && rm -rf /var/lib/apt/lists/*

# Copy the source code into the container
COPY example-app/example-app.cpp .

# Compile the C++ code with static linking
RUN g++ -static -o example-app example-app.cpp -lboost_system -lboost_thread -lssl -lcrypto -lpthread



# Second stage: Use a minimal base image for the final runtime container
FROM debian:bookworm-slim

# Set the working directory in the container
WORKDIR /app

# Copy the compiled binary from the builder stage
COPY --from=builder /app/example-app .

# Expose the port on which the API will listen
EXPOSE 8080

# Run the compiled application when the container starts
ENTRYPOINT  ["./example-app"]

Multistage Version: Alpine (Static Compilation)
#

  • This Dockerfile uses static compilation in the build stage. The Compiled binary includes all necessary libraries within itself, making it independent of the runtime environment.

  • With a statically compiled binary, the second / runtime stage becomes very small. The image is about 11MB.


  • Dockerfile
# Use Debian as the base image / Label image as "builder"
FROM debian:bookworm-slim AS builder

# Set the working directory in the container
WORKDIR /app

# Install necessary dependencies in a single RUN command to reduce image layers
RUN apt update && apt install -y --no-install-recommends \
    g++ \
    libboost-system-dev \
    libboost-thread-dev \
    libboost-chrono-dev \
    libboost-random-dev \
    libssl-dev \
    cmake \
    && apt clean && rm -rf /var/lib/apt/lists/*

# Copy the source code into the container
COPY example-app/example-app.cpp .

# Compile the C++ code with static linking
RUN g++ -static -o example-app example-app.cpp -lboost_system -lboost_thread -lssl -lcrypto -lpthread



# Second stage: Use Alpine for the final runtime container
FROM alpine:latest AS runtime

# Set the working directory in the container
WORKDIR /app

# Copy the statically compiled binary from the builder stage
COPY --from=builder /app/example-app .

# Expose the port on which the application listens
EXPOSE 8080

# Run the compiled application when the container starts
ENTRYPOINT  ["./example-app"]



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: 8080  # Port for the container to expose
  CONTAINER_NAME: "cpp-example-app"  # Name of the deployed container


### 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 Dockerfile in the current directory
    - docker build --pull -t "$CI_REGISTRY_IMAGE:$CI_COMMIT_REF_SLUG" .
    # Push the built Docker image to the GitLab Container Registry
    - docker push "$CI_REGISTRY_IMAGE:$CI_COMMIT_REF_SLUG"
  rules: 
    # Rule: Run this job only for main branch and if the Dockerfile exists
    - if: $CI_COMMIT_BRANCH == "main"
      exists:
        - Dockerfile


### Deploy Container to Virtual Machine
deploy_container:
  stage: deploy
  image: alpine:latest
  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 $CI_REGISTRY_IMAGE:$CI_COMMIT_REF_SLUG"
    # 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:$DEPLOY_PORT --restart=unless-stopped --name $CONTAINER_NAME $CI_REGISTRY_IMAGE:$CI_COMMIT_REF_SLUG"
  rules:
    # Rule: Run this job only for main branch
    - if: $CI_COMMIT_BRANCH == "main"

C++ Application
#

  • example-app/example-app.cpp
#include <boost/asio.hpp>      // Include Boost.Asio for networking (TCP/IP handling)
#include <boost/beast.hpp>     // Include Boost.Beast for HTTP and WebSocket protocols
#include <iostream>            // Include the standard I/O library for printing to the console

namespace beast = boost::beast; // Create an alias for Boost.Beast to simplify usage
namespace http = beast::http;   // Create an alias for Boost.Beast's HTTP module
namespace net = boost::asio;    // Create an alias for Boost.Asio to simplify networking code


int main() {
    try {
        // The I/O context (required for all I/O in Boost.Asio)
        net::io_context ioc;

        // Create an endpoint to listen on port 8080 on all network interfaces
        net::ip::tcp::acceptor acceptor(ioc, {net::ip::tcp::v4(), 8080});

        std::cout << "Server is listening on http://0.0.0.0:8080\n";

        while (true) {
            // Create a socket for the next connection
            net::ip::tcp::socket socket(ioc);

            // Block until a connection is made
            acceptor.accept(socket);

            // Create an HTTP response
            http::response<http::string_body> res{http::status::ok, 11};
            res.set(http::field::server, "SimpleServer");
            res.set(http::field::content_type, "text/plain");
            res.body() = "Hello from C++ Example Application";
            res.prepare_payload();

            // Write the response to the socket
            http::write(socket, res);
        }
    } catch (const std::exception& e) {
        std::cerr << "Error: " << e.what() << std::endl;
    }

    return 0;
}



Verify Container Deployment
#

Basic Dockerfile Version: Debian Slim
#

# List Docker containers
docker ps

# Shell output:
CONTAINER ID   IMAGE                                              COMMAND           CREATED              STATUS              PORTS                                       NAMES
68701f8eb75e   gitlab-registry.jklug.work/root/cpp-project:main   "./example-app"   About a minute ago   Up About a minute   0.0.0.0:8080->8080/tcp, :::8080->8080/tcp   cpp-example-app
# List docker images
docker images

# Shell output:
REPOSITORY                                    TAG       IMAGE ID       CREATED         SIZE
gitlab-registry.jklug.work/root/cpp-project   main      b28ac1535f9f   6 minutes ago   586MB

Multistage Dockerfile Version: Debian Slim (Dynamic Linking)
#

# List Docker containers
docker ps

# Shell output:
CONTAINER ID   IMAGE                                              COMMAND           CREATED         STATUS         PORTS                                       NAMES
709481066c25   gitlab-registry.jklug.work/root/cpp-project:main   "./example-app"   8 seconds ago   Up 8 seconds   0.0.0.0:8080->8080/tcp, :::8080->8080/tcp   cpp-example-app
# List docker images
docker images

# Shell output:
REPOSITORY                                    TAG       IMAGE ID       CREATED          SIZE
gitlab-registry.jklug.work/root/cpp-project   main      dc935327a1ac   43 seconds ago   336MB

Multistage Dockerfile Version: Debian Slim (Static Compilation)
#

# List Docker containers
docker ps

# Shell output:
CONTAINER ID   IMAGE                                              COMMAND           CREATED          STATUS          PORTS                                       NAMES
f80f11e382d2   gitlab-registry.jklug.work/root/cpp-project:main   "./example-app"   23 seconds ago   Up 23 seconds   0.0.0.0:8080->8080/tcp, :::8080->8080/tcp   cpp-example-app
# List docker images
docker images

# Shell output:
REPOSITORY                                    TAG       IMAGE ID       CREATED              SIZE
gitlab-registry.jklug.work/root/cpp-project   main      948c46af05bf   About a minute ago   78.3MB

Multistage Dockerfile Version: Alpine (Static Compilation)
#

# List Docker containers
docker ps

# Shell output:
CONTAINER ID   IMAGE                                              COMMAND               CREATED              STATUS              PORTS                                       NAMES
105ae1654ec6   gitlab-registry.jklug.work/root/cpp-project:main   "tail -f /dev/null"   About a minute ago   Up About a minute   0.0.0.0:8080->8080/tcp, :::8080->8080/tcp   cpp-example-app
# List docker images
docker images

# Shell output:
REPOSITORY                                    TAG       IMAGE ID       CREATED          SIZE
gitlab-registry.jklug.work/root/cpp-project   main      e52e7245c868   2 minutes ago    11.3MB

Curl the Container
#

# Curl the container
curl localhost:8080

# Shell output:
Hello from C++ Example Application