GitLab CI: zero downtime docker deployment
Jul 13, 2017 09:58 · 889 words · 5 minute read
Не так много времени прошло с момента завершения цикла статей о настройке процесса 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-контейнеров.