Setting Up a CI/CD Pipeline for Docker Images with GitLab

Introduction

Hello, tech enthusiasts! Today, we’re diving into the world of Continuous Integration and Continuous Deployment (CI/CD) for Docker images. We’ll explore how to set up a robust pipeline using GitLab, covering everything from building images with Docker Buildx or Buildah to tagging, pushing, and automating cleanup. Whether you’re a seasoned developer or just starting, this guide will help streamline your workflow.

Building Docker Images: Docker Buildx vs. Buildah

When it comes to building Docker images, you have two powerful tools at your disposal: Docker Buildx and Buildah. Here’s a quick comparison to help you decide:

  • Docker Buildx:

    • Integrates seamlessly with the Docker ecosystem.
    • Supports building images for multiple architectures.
    • Ideal for those already familiar with Docker.
  • Buildah:

    • Offers a rootless mode for enhanced security.
    • Suitable for environments where security is a priority.

Example: Building with Docker Buildx

.build_template:
  image: docker:latest
  services:
    - docker:dind
  variables:
    DOCKER_DRIVER: overlay2
    DOCKER_TLS_CERTDIR: ""
  before_script:
    - docker info
    - docker buildx create --use
  script:

    - docker buildx build --platform linux/amd64 -t $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA .
  • docker:dind: Enables Docker-in-Docker to build images directly in CI.
  • DOCKER_DRIVER: overlay2: Specifies the storage driver for layers.
  • docker buildx create —use: Sets up the builder for handling requests.
  • —platform linux/amd64: Specifies the architecture explicitly.

Tagging: Assigning the Right Tags

Tagging is crucial for identifying and managing your Docker images. Use GitLab environment variables to automate this process:

  • CI_COMMIT_SHORT_SHA: Short hash of the commit.
  • CI_COMMIT_REF_NAME: Name of the branch.

Tagging Example

docker buildx build -t $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA -t $CI_REGISTRY_IMAGE:$CI_COMMIT_REF_NAME .
  • The first tag uniquely identifies the image by commit.
  • The second tag helps track images built from specific branches.

To automate adding the latest tag for the main branch:

script:
  - |
    TAGS="-t $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA -t $CI_REGISTRY_IMAGE:$CI_COMMIT_REF_NAME"
    if [ "$CI_COMMIT_REF_NAME" = "main" ]; then
      TAGS="$TAGS -t $CI_REGISTRY_IMAGE:latest"
    fi
    docker buildx build --platform linux/amd64 $TAGS .

Pushing to Registries: GitLab, DockerHub, GHCR

Once built, your images need a home. GitLab Registry is a great choice due to its seamless integration. However, DockerHub and GitHub Container Registry (GHCR) are also viable options.

GitLab Registry Example

docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
docker push $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA
docker push $CI_REGISTRY_IMAGE:$CI_COMMIT_REF_NAME
  • docker login: Authenticates using GitLab-provided variables.
  • Push images with both commit and branch tags for flexibility.

DockerHub Example

variables:
  DOCKERHUB_IMAGE: yourdockerhubuser/yourproject
  DOCKERHUB_USERNAME: yourdockerhubuser
  DOCKERHUB_PASSWORD: $DOCKERHUB_PASSWORD

script:
  - echo "$DOCKERHUB_PASSWORD" | docker login -u "$DOCKERHUB_USERNAME" --password-stdin
  - docker tag $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA $DOCKERHUB_IMAGE:$CI_COMMIT_SHORT_SHA
  - docker push $DOCKERHUB_IMAGE:$CI_COMMIT_SHORT_SHA
  • DockerHub is straightforward for small projects but watch out for request limits in large CI/CD systems.

Complete .gitlab-ci.yml

Here’s a comprehensive example of a .gitlab-ci.yml file that includes caching, artifacts, testing, and deployment:

stages:
  - build
  - test
  - deploy

variables:
  IMAGE_TAG: $CI_COMMIT_SHORT_SHA
  DOCKER_DRIVER: overlay2
  DOCKER_TLS_CERTDIR: ""

build:
  stage: build
  image: docker:latest
  services:
    - docker:dind
  before_script:
    - docker info
    - docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
    - docker buildx create --use
  script:
    - |
      TAGS="-t $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA -t $CI_REGISTRY_IMAGE:$CI_COMMIT_REF_NAME"
      if [ "$CI_COMMIT_REF_NAME" = "main" ]; then

        TAGS="$TAGS -t $CI_REGISTRY_IMAGE:latest"
      fi
      docker buildx build --platform linux/amd64 $TAGS --push .
  artifacts:
    expire_in: 1 hour
    paths:
      - docker-compose.yml
  cache:
    key: ${CI_COMMIT_REF_SLUG}
    paths:
      - .cache

test:
  stage: test
  image: docker:latest
  services:
    - docker:dind
  script:
    - echo "Running tests..."

    - docker pull $CI_REGISTRY_IMAGE:$IMAGE_TAG
    - docker run --rm $CI_REGISTRY_IMAGE:$IMAGE_TAG pytest tests/
  artifacts:
    paths:
      - test-reports/
    when: always

deploy:
  stage: deploy
  image: alpine:latest
  script:
    - echo "Starting deployment..."
    - apk add --no-cache curl
    - curl -X POST "https://my-deploy-endpoint/internal/redeploy?image=$CI_REGISTRY_IMAGE:$IMAGE_TAG"
  environment:
    name: production
    url: https://my-production-app.example.com
  • Stages: Defined as build, test, and deploy for a streamlined process.
  • Artifacts: Save useful files like docker-compose.yml and test reports for analysis.
  • Cache: Retain intermediate data between builds.
  • Environment: Track deployment environments with GitLab.

Cleanup: Automating Image Cleanup

Over time, your registry may accumulate outdated images. Automate cleanup using the GitLab API.

Python Script for Cleanup

import requests

# Set your values
GITLAB_TOKEN = "your_token"
PROJECT_ID = 12345
REGISTRY_URL = f"https://gitlab.com/api/v4/projects/{PROJECT_ID}/registry/repositories"

headers = {"PRIVATE-TOKEN": GITLAB_TOKEN}

def get_repositories():
    response = requests.get(REGISTRY_URL, headers=headers)
    response.raise_for_status()
    return response.json()

def get_tags(repo_id):
    tags_url = f"{REGISTRY_URL}/{repo_id}/tags"

    response = requests.get(tags_url, headers=headers)
    response.raise_for_status()
    return response.json()

def delete_tag(repo_id, tag_name):
    delete_url = f"{REGISTRY_URL}/{repo_id}/tags/{tag_name}"
    response = requests.delete(delete_url, headers=headers)
    if response.status_code == 204:
        print(f"Tag {tag_name} successfully deleted from repository {repo_id}")
    else:
        print(f"Error deleting tag {tag_name} from repository {repo_id}: {response.text}")

def cleanup_old_tags():
    repositories = get_repositories()
    for repo in repositories:

        repo_id = repo['id']
        tags = get_tags(repo_id)
        # Delete all tags except 'latest' and 'main'
        tags_to_delete = [tag['name'] for tag in tags if tag['name'] not in ('latest', 'main')]
        for tag in tags_to_delete:
            delete_tag(repo_id, tag)

if __name__ == "__main__":
    cleanup_old_tags()
  • Fetch all container registry repositories using the API.
  • Retrieve tags for each repository and filter out non-essential ones.
  • Sequentially delete unnecessary tags.

Conclusion

Setting up a CI/CD pipeline for Docker images with GitLab can significantly enhance your development workflow. By automating builds, tagging, and cleanup, you ensure a smooth and efficient process. If you have any questions or want to share your experiences, feel free to comment below. For those eager to delve deeper into automation, consider joining Otus’s open lessons to explore real-world cases and tools that complement GitLab and Docker.

  • April 3: Terraform: Working with it through GitLab. Learn More
  • April 16: Setting up GitLab Runner without regrets. Learn More

Happy coding!