Deploying Containers

Series: Drupal Docker

tl;dr: Using GitLab CI/CD to deploy new containers with Docker swarm, and run the Drupal database update script.

This post is one of several in a series detailing my development environment and CI/CD deployment pipeline. The code for the developer environment can be seen on my GitLab. Other posts in the series can be seen by checking the Drupal Docker series links in the sidebar. I provided an overview in the first post of the series.

In this post, I will look at the core of the process for deploying updates to a Docker swarm server.

We're starting to reach some of the parts of this series that I cannot give full code that I know works in other environments, because I do not have personally have my own Docker swarm servers to test things with. This component is based on what works with some of my work infrastructure. On a related note, I have structured the demo project so that jobs I don't actually have the environment to run are in the swarm.gitlab-ci.yml, as opposed to the main .gitlab-ci.yml which will actually run when I make new changes.

Deploy Job

First up, we need to deploy the updated service to the swarm.

General Deployment Template

The core function to be able to deploy a stack to a Drupal swarm is docker stack deploy but this gets more complicated because of another requirement: sometimes there is another docker-compose required to define differences for the other environments. I don't have those in my example, but they might be things like designating where to find the volumes, since the file structure on all environments is not always going to be the same. Because of that, it needs to first merge together more than one docker-compose file, before it deploys the stack of that combined file. It also will provide that combined file as an artifact, in case it's helpful for debugging issues.

.deploy_template_swarm:
  stage: deploy
  retry:
    max: 2
    when:
      - script_failure
  variables:
    STACK_NAME: ""
    COMPOSE_FILES: docker-compose.yml
  script:
    - echo "$CI_REGISTRY_PASSWORD" | docker login $CI_REGISTRY -u $CI_REGISTRY_USER --password-stdin
    - if [ -z $COMPOSE_FILES ]; then export COMPOSE_FILES=docker-compose.yml; fi
    - >-
      docker --log-level fatal
      compose
      $(printf ' -f %s ' $(echo $COMPOSE_FILES | sed 's/:/ /g'))
      -p $STACK_NAME
      config
      -o docker-compose.rendered.yml
    - sed -i '/^name:.*$/d' docker-compose.rendered.yml
    - docker stack deploy
      -c docker-compose.rendered.yml
      --with-registry-auth
      --detach=false $STACK_NAME
    - docker logout
  artifacts:
    when: always
    paths:
      - docker-compose.rendered.yml

Project Implementation for Deployment

To implement that in a particular project, it would look something like:

prod_deploy:
  stage: deploy
  extends: .deploy_template_swarm
  allow_failure: false
  environment: Production
  variables:
    STACK_NAME: library
    COMPOSE_FILES: docker-compose.yml:docker-compose.local.storage.yml
    WEB_REPLICA_COUNT: 3
    DB_REPLICA_COUNT: 1
  tags:
    - prod
  only:
    refs:
      - main

This extends the template job. It requires combining two docker-compose files. It also passes through a couple of variables that we saw back in the docker-compose file to declare how many of each service we want to deploy to the swarm. This one runs on the prod server's tag, and only when merges are made into the main branch.

Database Update Job

Next, with Drupal specifically, we need to update the database to be in line with the new code. Clearing caches is also an essential step in Drupal deployments.

General Single-Use Template

To do this, I have a general "single use" job. At its simplest, it runs a script. Like the deployment, though, it gets more complicated, in this case because I wanted to be able to see the logs of that script running so that we know if there are any problems. That is what makes this a much longer script.


## This is for single-use services, to run a specific script one time, before or after deployment. ##
.docker_single_use:
  variables:
    STACK_NAME: ""
    SERVICE_NAME: ""
    SERVICE_UP_WAIT_REPEAT: 12
    SERVICE_UP_WAIT_TIME: 5
    SERVICE_CREATE_WAIT_REPEAT: 60
    SERVICE_CREATE_WAIT_TIME: 5
  script:
    - echo "$CI_DEPLOY_PASSWORD" | docker login -u $CI_DEPLOY_USER --password-stdin $CI_REGISTRY
    - DATE="$(date --iso-8601=seconds)"
    - docker service update --with-registry-auth --replicas 1 --force ${STACK_NAME}_${SERVICE_NAME} --update-monitor 1s
    - |
      EXIT_VALUE=0
      SERVICE_UP_WAIT_CHECK=0
      while [ -z "$(docker service ps --format 'json' ${STACK_NAME}_${SERVICE_NAME})" ]; do
        SERVICE_UP_WAIT_CHECK=$((SERVICE_UP_WAIT_CHECK+1))
        if [ "${SERVICE_UP_WAIT_CHECK}" -eq "${SERVICE_UP_WAIT_REPEAT}"]; then
          echo Service failed to be created within $((SERVICE_UP_WAIT_REPEAT * SERVICE_UP_WAIT_TIME)) seconds.
          exit 1
        else
          echo Service ${STACK_NAME}_${SERVICE_NAME} has not been created yet. Attempt ${SERVICE_UP_WAIT_CHECK}/${SERVICE_UP_WAIT_REPEAT}
          sleep ${SERVICE_UP_WAIT_TIME}
        fi
      done
      SERVICE_CREATE_WAIT_CHECK=0

      for task in $(docker service ps -q ${STACK_NAME}_${SERVICE_NAME}); do
        # Get the CreatedAt of the current task for comparison
        created_at=$(docker inspect --format '{{.CreatedAt}}' $task)

        # Check if this task is more recent than the current most recent task
        if [[ -z "$most_recent_created_at" || "$created_at" > "$most_recent_created_at" ]]; then
          LATEST_TASK=$(docker inspect --format '{{.ID}}' $task)
          most_recent_created_at="$created_at"
        fi
      done

      while true; do
        NEW_DATE="$(date --iso-8601=seconds)"
        docker service logs --since="${DATE}" ${STACK_NAME}_${SERVICE_NAME}
        DATE="${NEW_DATE}"
        DOCKER_STATUS=$(docker inspect --format '{{.Status.State}}' $LATEST_TASK)
        echo "Latest task status is ${DOCKER_STATUS}"
        if [ "${DOCKER_STATUS}" = "failed" ]; then
          EXIT_VALUE=1
          break
        elif [ "${DOCKER_STATUS}" = "complete" ]; then
          break
        elif [ "${DOCKER_STATUS}" = "running" ]; then
          sleep ${SERVICE_CREATE_WAIT_TIME}
        else
          SERVICE_CREATE_WAIT_CHECK=$((SERVICE_CREATE_WAIT_CHECK+1))
          if [ "${SERVICE_CREATE_WAIT_CHECK}" -eq "${SERVICE_CREATE_WAIT_REPEAT}" ]; then
            echo Service failed to start within $((SERVICE_CREATE_WAIT_REPEAT * SERVICE_CREATE_WAIT_TIME)) seconds.
            EXIT_VALUE=1
            break
          fi
          sleep ${SERVICE_CREATE_WAIT_TIME}
        fi
      done
      docker service logs --since="${DATE}" ${STACK_NAME}_${SERVICE_NAME}
    - docker logout
    - exit $EXIT_VALUE

This allows it to loop on the service, returning its status and any logs that have been added since the last check.

Project Implementation for Database Update

To run this now on a specific project, you need a few things.

First is the script that it is going to run, in a separate file under the scripts folder. As with the start script, it will first wait to confirm that it is able to connect to the database, returning an error if it didn't work after enough tries. Then it will use drush commands to import the latest configuration - twice, because if config_split is involved it might only apply the split in the first run and then change the split configurations on the second - then run any updates needed to the database and clear the caches.

#!/bin/bash

wait_for_db() {
  local db_retries=10
  local db_wait_time=5
  local db_attempt=1

  source /opt/drupal/scripts/settings-file.sh

  while [ $db_attempt -le $db_retries ]; do
    if drush sqlq "SELECT 1" > /dev/null 2>&1; then
      return 0
    else
      echo "Database is not ready yet. Attempt $db_attempt/$db_retries. Waiting for $db_wait_time seconds..."
      sleep $db_wait_time
      db_attempt=$((db_attempt + 1))
      db_wait_time=$((db_wait_time * 2))
    fi
  done

  return 1
}

if wait_for_db; then

  set -e
  drush config-import -y
  # Run again to catch extra configuration in the config_split
  drush config-import -y

  drush updb -y
  drush cr

else
  echo "Database never came up."
  exit 1
fi
sleep 30

Next is the service declared in the docker-compose. This runs the main web image, but replacing the command with the update script we just wrote. It will normally not replicate at all in a general deploy, because we do not want this to be able to run until after we know the new web service is fully in place.

  db_update:
    image: ${CI_REGISTRY_IMAGE:-registry.gitlab.com/ryan-l-robinson/drupal-dev-environment/web}:${CI_COMMIT_REF_NAME:-main}
    command: /opt/drupal/scripts/update.sh
    environment:
      <<: [*mysql, *config-split, *deploy-env]
    volumes:
      - private:/opt/drupal/private
      - public:/opt/drupal/web/sites/default/files
    deploy:
      mode: replicated
      replicas: 0
      restart_policy:
        condition: on-failure
        max_attempts: 1

Finally is the GitLab CI/CD job that will extend the single-use template and specify which service (running which script) it needs to run. This will start up that service just once, after we know the other deployment job is done, run its script and then shut down again.

prod_update:
  stage: deploy
  extends: .docker_single_use
  environment: Production
  variables:
    STACK_NAME: library
    SERVICE_NAME: db_update
  tags:
    - prod
  only:
    refs:
      - dev
  needs: [dev_deploy]

I know that seems like it is much more complicated than "run a script" sounds like it should be, but it's designed to be flexible for other services running any scripts as well as user-friendly with the logs visible.