GitLab CI: zero downtime docker deployment

Jul 13, 2017 09:58 · 889 words · 5 minute read gitlab gitlab ci

Не так много времени прошло с момента завершения цикла статей о настройке процесса CI (continuous integration) с помощью Gitlab в реальном проекте, как мы вновь возвращаемся к данной теме.

Как говорится, нет предела совершенству, поэтому этой статье мы значительно улучшим описанный ранее этап деплоя — давайте разберемся!

После публикации статьи с описанием моего варианта деплоя контейнеров, один из читателей написал мне следующее:

"…if we want to make some changes to our app, there is no reason that we need to restart all of the containers. Instead we should just update the container running our web app and then update the Nginx upstream directive…"

И действительно, я не могу с ним не согласиться. Тем более, что используя docker-compose down && docker-compose up -d мы неминуемо получаем время простоя (downtime). Для улучшения ситуации необходимо проделать несколько шагов:

  • немного подправить файл docker-compose-staging.yml (используется для создания и запуска docker-контейнеров на ревью);
  • изменить конфигурационные файлы web-сервера Nginx;
  • изменить Dockerfile (инструкции по сборке) для контейнера Nginx;
  • полностью переписать скрипт деплоя.

Начнем с первого пункта. Для всех сервисов, кроме php-fpm заменяем строки

volumes_from:
    - applications
    - applications1

на строки:

volumes_from:
    - php-fpm

Строки вида

links:
    - php-fpm

нам больше не понадобятся, удаляем.

Также для сервиса php-fpm удаляем строку

container_name: php-fpm

В итоге теперь наш конфигурационный файл docker-compose-staging.yml выглядит так:

version: '2'
services:
### Applications Code Container #############################
    applications:
        container_name: application
        image: registry.gitlab.lc:5000/develop/ed:develop.sources.last
### Applications1 Code Container ############################
    applications1:
        container_name: application1
        image: registry.gitlab.lc:5000/develop/ed:docker.sources.last
### PHP-FPM Container #######################################
    php-fpm:
        image: registry.gitlab.lc:5000/develop/ed:php-fpm-ed-sq
        volumes_from:
            - applications
            - applications1
        expose:
            - "9000"
### Nginx Server Container ##################################
    nginx:
        container_name: nginx
        image: registry.gitlab.lc:5000/develop/ed:nginx-ed-sq
        volumes_from:
            - php-fpm
        depends_on:
            - "websocket"
        ports:
            - "${HTTP_PORT}:80"
            - "443:443"
### Redis Container #########################################
    redis:
        container_name: redis
        image: registry.gitlab.lc:5000/develop/ed:redis-ed-sq
        volumes:
            - redis:/data
        ports:
            - "6379:6379"
### Memcached Container #####################################
    memcached:
        container_name: memcached
        image: registry.gitlab.lc:5000/develop/ed:memcached-ed-sq
        volumes:
            - memcached:/var/lib/memcached
        ports:
            - "11211:11211"
### Websocket Container ######################################
    websocket:
        container_name: websocket
        restart: always
        image: registry.gitlab.lc:5000/develop/ed:websocket-ed-sq
        depends_on:
            - "redis"
        environment:
            - WS_PORT=${WS_PORT}
            - REDIS_HOST=${REDIS_HOST}
            - REDIS_PORT=${REDIS_PORT}
            - REDIS_PASS=${REDIS_PASS}
        volumes_from:
            - php-fpm
        ports:
            - "${WS_PORT}:${WS_PORT}"
### SDCV Container ##########################################
    sdcv:
        container_name: sdcv
        image: registry.gitlab.lc:5000/develop/ed:sdcv-ed-sq
        ports:
            - "9095:9095"
### Volumes Setup ###########################################
volumes:
    memcached:
        driver: "local"
    redis:
        driver: "local"

Правим конфигурационные файлы web-сервера Nginx. Ранее в настройках сайта, в локейшене (location) который занимается обработкой php, директива fastcgi_pass выглядела так:

...
      fastcgi_pass php-fpm:9000;
...

Меняем ее на такую:

...
      fastcgi_pass backend;
...

В основном конфигурационном файле (nginx.conf) в секции http добавляем строку:

...
      include include/upstream.include;
...

Создаем файл include/upstream.include следующего содержания:

upstream backend {
      server docker_php-fpm_1:9000;
}

Создаем bash-скрипт (назовем его update_upstream_directive.sh), который будет по нашему требованию обновлять директиву upstream (содержимое файла include/upstream.include). Выглядеть он будет так:

#!/bin/bash
NGINX_INCLUDE_PATH=/opt/nginx/include
 
echo "Updating nginx upsteam directive..."
# change string of ip addresses separated by / into an array
IFS="/" read -r -a addresses <<< $CONTAINERS
 
# update the upsteam directive contained within the upstream.include file
echo "upstream backend {" > $NGINX_INCLUDE_PATH/upstream.include
for container in $addresses; do
  echo "  server $container:9000;" >> $NGINX_INCLUDE_PATH/upstream.include
done
echo "}" >> $NGINX_INCLUDE_PATH/upstream.include

Данный скрипт (как и новые конфиги) должен находиться внутри docker-контейнера, поэтому правим Dockerfile, добавляя в него строки:

...
COPY ./nginx.conf /opt/nginx/nginx.conf
COPY ./conf.d/site.conf /opt/nginx/conf.d/site.conf
COPY ./include/upstream.include /opt/nginx/include/upstream.include
COPY ./update_upstream_directive.sh /usr/local/bin/
RUN chmod +x /usr/local/bin/*.sh
...

После внесенных изменений пересобираем docker-контейнер и пушим его в локальный docker registry.

Наконец, переписываем скрипт деплоя deploy.sh (или, что лучше, создаем новый с именем update_containers_staging.sh). Выглядеть он будет так:

#!/bin/bash
DIRECTORY=~/docker
DC_FILE=docker-compose-staging.yml
ENV_FILE=.env.staging.lc
PROJECT_NETWORK=docker_default
SERVICES="applications applications1 php-fpm"
 
cd $DIRECTORY;
set -o allexport; . $ENV_FILE;
docker-compose -f $DC_FILE pull;
 
for service in $SERVICES; do
  # split needed container names that are already running into an array
  old_cont=($(docker-compose -f $DC_FILE ps $service | awk '{ print $1 }' | tail -n +3))
 
  # create the new containers with latest code changes
  docker-compose -f $DC_FILE up --scale $service=2 -d --no-deps $service
 
  if [ "$service" == "php-fpm" ]; then
    # create array with new containers only
    all_cont=($(docker-compose -f $DC_FILE ps $service | awk '{ print $1 }' | tail -n +3))
    new_cont=($(echo ${old_cont[*]} ${all_cont[*]} | tr ' ' '\n' | sort | uniq -u))
 
    # loop through containers and get IP addresses separated by / so they can be exported below
    IP_ADDRESSES=""
    for element in ${new_cont[*]}; do
      IP_ADDRESSES+="$(docker inspect -f {{.NetworkSettings.Networks.$PROJECT_NETWORK.IPAddress}} $element)/"
    done
 
    # run script to update the upstream.include file in nginx container
    docker-compose -f $DC_FILE exec -T nginx bash -c "export CONTAINERS=$IP_ADDRESSES && /usr/local/bin/update_upstream_directive.sh"
    sleep 5
    # Reload nginx service to update the upstream.include file in nginx container
    docker-compose -f $DC_FILE exec -T nginx bash -c "nginx -s reload"
  fi
  # stops the old containers
  for container in $old_cont; do
    docker kill $container
  done
 
  # deletes the old containers
  docker rm $(docker ps -aqf status=exited)
 
  # rename new containers to start at 1 and go up sequentially
  newly_started_cont=($(docker-compose -f $DC_FILE ps $service | awk '{ print $1 }' | tail -n +3))
  counter=0
  while [ $counter -lt ${#newly_started_cont[*]} ]; do
    for container in $newly_started_cont; do
      new_name=$(echo "${container%?}$(($counter+1))")
      docker rename $container $new_name
    done
    let counter=counter+1
  done
done
docker-compose -f $DC_FILE up -d --force-recreate --no-deps nginx

Скрипт на первый взгляд выглядит страшно, но на самом деле здесь нет никакой магии — просто запускается еще один, обновленный экземпляр сервиса php-fpm, выясняется его ip-адрес, который затем используется для обновления директивы upstream в docker-контейнере Nginx. После проделанных действий более старый экземпляр сервиса php-fpm удаляется, а новый переименовывается.

Таким образом, пусть и не самым элегантным способом, можно избавиться от простоя (downtime) при деплое docker-контейнеров.

tweet Share