Blue-Green Deployment на минималках

В этой статье мы с помощью bash, ssh, docker и nginx организуем бесшовную выкладку веб-приложения. Blue-green deployment — это техника, позволяющая мгновенно обновлять приложение, не отклоняя ни одного запроса. Она является одной из стратегий zero downtime deployment и лучше всего подходит для приложений, у которых один инстанс, но есть возможность загрузить рядом второй, готовый к работе инстанс.


Допустим, у Вас есть веб-приложение, с которым активно работает множество клиентов, и ему совершенно никак нельзя на пару секунд прилечь. А Вам очень нужно выкатить обновление библиотеки, фикс бага или новую крутую фичу. В обычной ситуации, потребуется остановить приложение, заменить его и снова запустить. В случае докера, можно сначала заменить, потом перезапустить, но всё равно будет период, в котором запросы к приложению не обработаются, ведь обычно приложению требуется некоторое время на первоначальную загрузку. А если оно запустится, но окажется неработоспособным? Вот такая задача, давайте её решать минимальными средствами и максимально элегантно.


Disclaimer: Большая часть статьи представлена в экспериментальном формате — в виде записи консольной сессии. Надеюсь, это будет не очень сложно воспринимать, и этот код сам себя документирует в достаточном объёме. Для атмосферности, представьте, что это не просто кодсниппеты, а бумага из "железного" телетайпа.



Интересные техники, которые сложно нагуглить просто читая код описаны в начале каждого раздела. Если будет непонятно что-то ещё — гуглите и проверяйте в explainshell (благо, он снова работает, в связи с разблокировкой телеграма). Что не гуглится — спрашивайте в комментах. С удовольствием дополню соответствующий раздел "Интересные техники".


Приступим.


$ mkdir blue-green-deployment && cd $_

Сервис


Сделаем подопытный сервис и поместим его в контейнер.


Интересные техники


  • cat << EOF > file-name (Here Document + I/O Redirection) — способ создать многострочный файл одной командой. Всё, что bash прочитает из /dev/stdin после этой строчки и до строчки EOF будет записано в file-name.
  • wget -qO- URL (explainshell) — вывести полученный по HTTP документ в /dev/stdout (аналог curl URL).

Распечатка


Я специально разрываю сниппет, чтобы включить подсветку для Python. В конце будет ещё один такой кусок. Считайте, что в этих местах бумагу разрезали для передачи в отдел хайлайтинга (где код раскрашивали вручную хайлайтерами), а потом эти куски вклеили обратно.

$ cat << EOF > uptimer.py

from http.server import BaseHTTPRequestHandler, HTTPServer
from time import monotonic

app_version = 1
app_name = f'Uptimer v{app_version}.0'
loading_seconds = 15 - app_version * 5

class Handler(BaseHTTPRequestHandler):
    def do_GET(self):
        if self.path == '/':
            try:
                t = monotonic() - server_start
                if t < loading_seconds:
                    self.send_error(503)
                else:
                    self.send_response(200)
                    self.send_header('Content-Type', 'text/html')
                    self.end_headers()
                    response = f'<h2>{app_name} is running for {t:3.1f} seconds.</h2>\n'
                    self.wfile.write(response.encode('utf-8'))
            except Exception:
                self.send_error(500)
        else:
            self.send_error(404)

httpd = HTTPServer(('', 8080), Handler)
server_start = monotonic()
print(f'{app_name} (loads in {loading_seconds} sec.) started.')
httpd.serve_forever()

EOF

$ cat << EOF > Dockerfile
FROM python:alpine
EXPOSE 8080
COPY uptimer.py app.py
CMD [ "python", "-u", "./app.py" ]
EOF

$ docker build --tag uptimer .
Sending build context to Docker daemon  39.42kB
Step 1/4 : FROM python:alpine
 ---> 8ecf5a48c789
Step 2/4 : EXPOSE 8080
 ---> Using cache
 ---> cf92d174c9d3
Step 3/4 : COPY uptimer.py app.py
 ---> a7fbb33d6b7e
Step 4/4 : CMD [ "python", "-u", "./app.py" ]
 ---> Running in 1906b4bd9fdf
Removing intermediate container 1906b4bd9fdf
 ---> c1655b996fe8
Successfully built c1655b996fe8
Successfully tagged uptimer:latest

$ docker run --rm --detach --name uptimer --publish 8080:8080 uptimer
8f88c944b8bf78974a5727070a94c76aa0b9bb2b3ecf6324b784e782614b2fbf

$ docker ps
CONTAINER ID        IMAGE               COMMAND                CREATED             STATUS              PORTS                    NAMES
8f88c944b8bf        uptimer             "python -u ./app.py"   3 seconds ago       Up 5 seconds        0.0.0.0:8080->8080/tcp   uptimer

$ docker logs uptimer
Uptimer v1.0 (loads in 10 sec.) started.

$ wget -qSO- http://localhost:8080
  HTTP/1.0 503 Service Unavailable
  Server: BaseHTTP/0.6 Python/3.8.3
  Date: Sat, 22 Aug 2020 19:52:40 GMT
  Connection: close
  Content-Type: text/html;charset=utf-8
  Content-Length: 484

$ wget -qSO- http://localhost:8080
  HTTP/1.0 200 OK
  Server: BaseHTTP/0.6 Python/3.8.3
  Date: Sat, 22 Aug 2020 19:52:45 GMT
  Content-Type: text/html
<h2>Uptimer v1.0 is running for 15.4 seconds.</h2>

$ docker rm --force uptimer
uptimer

Реверс-прокси


Чтобы наше приложение имело возможность незаметно поменяться, необходимо, чтобы перед ним была ещё какая-то сущность, которая скроет его подмену. Это может быть веб-сервер nginx в режиме реверс-прокси. Реверс-прокси устанавливается между клиентом и приложением. Он принимает запросы от клиентов и перенаправляет их в приложение а ответы приложения направляет клиентам.


Приложение и реверс-прокси можно связать внутри докера с помощью docker network. Таким образом, контейнеру с приложением можно даже не пробрасывать порт в хост-системе, это позволяет максимально изолировать приложение от угроз из внешки.


Если реверс-прокси будет жить на другом хосте, придётся отказаться от docker network и связать приложение с реверс-прокси через сеть хоста, пробросив порт приложения параметром --publish, как при первом запуске и как у реверс-прокси.


Реверс-прокси будем запускать на порту 80, ибо это именно та сущность, которой следует слушать внешку. Если 80-й порт у Вас на тестовом хосте занят, поменяйте параметр --publish 80:80 на --publish ANY_FREE_PORT:80.


Интересные техники



Распечатка


$ docker network create web-gateway
5dba128fb3b255b02ac012ded1906b7b4970b728fb7db3dbbeccc9a77a5dd7bd

$ docker run --detach --rm --name uptimer --network web-gateway uptimer
a1105f1b583dead9415e99864718cc807cc1db1c763870f40ea38bc026e2d67f

$ docker run --rm --network web-gateway alpine wget -qO- http://uptimer:8080
<h2>Uptimer v1.0 is running for 11.5 seconds.</h2>

$ docker run --detach --publish 80:80 --network web-gateway --name reverse-proxy nginx:alpine
80695a822c19051260c66bf60605dcb4ea66802c754037704968bc42527bf120

$ docker ps
CONTAINER ID        IMAGE               COMMAND                  CREATED              STATUS              PORTS                NAMES
80695a822c19        nginx:alpine        "/docker-entrypoint.…"   27 seconds ago       Up 25 seconds       0.0.0.0:80->80/tcp   reverse-proxy
a1105f1b583d        uptimer             "python -u ./app.py"     About a minute ago   Up About a minute   8080/tcp             uptimer

$ cat << EOF > uptimer.conf
server {
    listen 80;
    location / {
        proxy_pass http://uptimer:8080;
    }
}
EOF

$ docker cp ./uptimer.conf reverse-proxy:/etc/nginx/conf.d/default.conf

$ docker exec reverse-proxy nginx -s reload
2020/06/23 20:51:03 [notice] 31#31: signal process started

$ wget -qSO- http://localhost
  HTTP/1.1 200 OK
  Server: nginx/1.19.0
  Date: Sat, 22 Aug 2020 19:56:24 GMT
  Content-Type: text/html
  Transfer-Encoding: chunked
  Connection: keep-alive
<h2>Uptimer v1.0 is running for 104.1 seconds.</h2>

Бесшовный деплоймент


Выкатим новую версию приложения (с двухкратным бустом startup performance) и попробуем бесшовно её задеплоить.


Интересные техники


  • echo 'my text' | docker exec -i my-container sh -c 'cat > /my-file.txt' — Записать текст my text в файл /my-file.txt внутри контейнера my-container.
  • cat > /my-file.txt — Записать в файл содержимое стандартного входа /dev/stdin.

Распечатка


$ sed -i "s/app_version = 1/app_version = 2/" uptimer.py

$ docker build --tag uptimer .
Sending build context to Docker daemon  39.94kB
Step 1/4 : FROM python:alpine
 ---> 8ecf5a48c789
Step 2/4 : EXPOSE 8080
 ---> Using cache
 ---> cf92d174c9d3
Step 3/4 : COPY uptimer.py app.py
 ---> 3eca6a51cb2d
Step 4/4 : CMD [ "python", "-u", "./app.py" ]
 ---> Running in 8f13c6d3d9e7
Removing intermediate container 8f13c6d3d9e7
 ---> 1d56897841ec
Successfully built 1d56897841ec
Successfully tagged uptimer:latest

$ docker run --detach --rm --name uptimer_BLUE --network web-gateway uptimer
96932d4ca97a25b1b42d1b5f0ede993b43f95fac3c064262c5c527e16c119e02

$ docker logs uptimer_BLUE
Uptimer v2.0 (loads in 5 sec.) started.

$ docker run --rm --network web-gateway alpine wget -qO- http://uptimer_BLUE:8080
<h2>Uptimer v2.0 is running for 23.9 seconds.</h2>

$ sed s/uptimer/uptimer_BLUE/ uptimer.conf | docker exec --interactive reverse-proxy sh -c 'cat > /etc/nginx/conf.d/default.conf'

$ docker exec reverse-proxy cat /etc/nginx/conf.d/default.conf
server {
    listen 80;
    location / {
        proxy_pass http://uptimer_BLUE:8080;
    }
}

$ docker exec reverse-proxy nginx -s reload
2020/06/25 21:22:23 [notice] 68#68: signal process started

$ wget -qO- http://localhost
<h2>Uptimer v2.0 is running for 63.4 seconds.</h2>

$ docker rm -f uptimer
uptimer

$ wget -qO- http://localhost
<h2>Uptimer v2.0 is running for 84.8 seconds.</h2>

$ docker ps
CONTAINER ID        IMAGE               COMMAND                  CREATED              STATUS              PORTS                NAMES
96932d4ca97a        uptimer             "python -u ./app.py"     About a minute ago   Up About a minute   8080/tcp             uptimer_BLUE
80695a822c19        nginx:alpine        "/docker-entrypoint.…"   8 minutes ago        Up 8 minutes        0.0.0.0:80->80/tcp   reverse-proxy

На данном этапе образ билдится прямо на сервере, что требует наличия там исходников приложения, а также нагружает сервер лишней работой. Следующим шагом будет выделение сборки образа на отдельную машину (например, в CI-систему) с последующей передачей его на сервер.


Перекачка образов


К сожалению, перекачивать образа с localhost на localhost не имеет смысла, так что этот раздел можно пощупать только имея под рукой два хоста с докером. На минималках это выглядит примерно так:


$ ssh production-server docker image ls
REPOSITORY          TAG                 IMAGE ID            CREATED             SIZE

$ docker image save uptimer | ssh production-server 'docker image load'
Loaded image: uptimer:latest

$ ssh production-server docker image ls
REPOSITORY          TAG                 IMAGE ID            CREATED             SIZE
uptimer             latest              1d56897841ec        5 minutes ago       78.9MB

Команда docker save сохраняет данные образа в .tar архив, то есть он весит примерно в 1.5 раза больше, чем мог бы весить в сжатом виде. Так пожмём же его во имя экономии времени и трафика:


$ docker image save uptimer | gzip | ssh production-server 'zcat | docker image load'
Loaded image: uptimer:latest

А ещё, можно наблюдать за процессом перекачки (правда, для этого нужна сторонняя утилита):


$ docker image save uptimer | gzip | pv | ssh production-server 'zcat | docker image load'
25,7MiB 0:01:01 [ 425KiB/s] [                   <=>    ]
Loaded image: uptimer:latest

Совет: Если Вам для соединения с сервером по SSH требуется куча параметров, возможно вы не используете файл ~/.ssh/config.

Передача образа через docker image save/load — это наиболее минималистичный метод, но не единственный. Есть и другие:


  1. Container Registry (стандарт отрасли).
  2. Подключиться к docker daemon сервера с другого хоста:
    1. Переменная среды DOCKER_HOST.
    2. Параметр командной строки -H или --host инструмента docker-compose.
    3. docker context

Второй способ (с тремя вариантами его реализации) хорошо описан в статье How to deploy on remote Docker hosts with docker-compose.


deploy.sh


Теперь соберём всё, что мы делали вручную в один скрипт. Начнём с top-level функции, а потом посмотрим на остальные, используемые в ней.


Интересные техники


  • ${parameter?err_msg} — одно из заклинаний bash-магии (aka parameter substitution). Если parameter не задан, вывести err_msg и выйти с кодом 1.
  • docker --log-driver journald — по-умолчанию, драйвером логирования докера является текстовый файл без какой-либо ротации. С таким подходом логи быстро забивают весь диск, поэтому для production-окружения необходимо менять драйвер на более умный.

Скрипт деплоймента


deploy() {
    local image_name=${1?"Usage: ${FUNCNAME[0]} image_name"}

    ensure-reverse-proxy || return 2
    if get-active-slot $image_name
    then
        local OLD=${image_name}_BLUE
        local new_slot=GREEN
    else
        local OLD=${image_name}_GREEN
        local new_slot=BLUE
    fi
    local NEW=${image_name}_${new_slot}
    echo "Deploying '$NEW' in place of '$OLD'..."
    docker run \
        --detach \
        --restart always \
        --log-driver journald \
        --name $NEW \
        --network web-gateway \
        $image_name || return 3
    echo "Container started. Checking health..."
    for i in {1..20}
    do
        sleep 1
        if get-service-status $image_name $new_slot
        then
            echo "New '$NEW' service seems OK. Switching heads..."
            sleep 2  # Ensure service is ready
            set-active-slot $image_name $new_slot || return 4
            echo "The '$NEW' service is live!"
            sleep 2  # Ensure all requests were processed
            echo "Killing '$OLD'..."
            docker rm -f $OLD
            docker image prune -f
            echo "Deployment successful!"
            return 0
        fi
        echo "New '$NEW' service is not ready yet. Waiting ($i)..."
    done
    echo "New '$NEW' service did not raise, killing it. Failed to deploy T_T"
    docker rm -f $NEW
    return 5
}

Использованные функции:


  • ensure-reverse-proxy — Убеждается, что реверс-прокси работает (полезно для первого деплоя)
  • get-active-slot service_name — Определяет какой сейчас слот активен для заданного сервиса (BLUE или GREEN)
  • get-service-status service_name deployment_slot — Определяет готов ли сервис к обработке входящих запросов
  • set-active-slot service_name deployment_slot — Меняет конфиг nginx в контейнере реверс-прокси

По порядку:


ensure-reverse-proxy() {
    is-container-up reverse-proxy && return 0
    echo "Deploying reverse-proxy..."
    docker network create web-gateway
    docker run \
        --detach \
        --restart always \
        --log-driver journald \
        --name reverse-proxy \
        --network web-gateway \
        --publish 80:80 \
        nginx:alpine || return 1
    docker exec --interactive reverse-proxy sh -c "> /etc/nginx/conf.d/default.conf"
    docker exec reverse-proxy nginx -s reload
}

is-container-up() {
    local container=${1?"Usage: ${FUNCNAME[0]} container_name"}

    [ -n "$(docker ps -f name=${container} -q)" ]
    return $?
}

get-active-slot() {
    local image=${1?"Usage: ${FUNCNAME[0]} image_name"}

    if is-container-up ${image}_BLUE && is-container-up ${image}_GREEN; then
        echo "Collision detected! Stopping ${image}_GREEN..."
        docker rm -f ${image}_GREEN
        return 0  # BLUE
    fi
    if is-container-up ${image}_BLUE && ! is-container-up ${image}_GREEN; then
        return 0  # BLUE
    fi
    if ! is-container-up ${image}_BLUE; then
        return 1  # GREEN
    fi
}

get-service-status() {
    local usage_msg="Usage: ${FUNCNAME[0]} image_name deployment_slot"
    local image=${1?usage_msg}
    local slot=${2?$usage_msg}

    case $image in
        # Add specific healthcheck paths for your services here
        *) local health_check_port_path=":8080/" ;;
    esac
    local health_check_address="http://${image}_${slot}${health_check_port_path}"
    echo "Requesting '$health_check_address' within the 'web-gateway' docker network:"
    docker run --rm --network web-gateway alpine \
        wget --timeout=1 --quiet --server-response $health_check_address
    return $?
}

set-active-slot() {
    local usage_msg="Usage: ${FUNCNAME[0]} service_name deployment_slot"
    local service=${1?$usage_msg}
    local slot=${2?$usage_msg}
    [ "$slot" == BLUE ] || [ "$slot" == GREEN ] || return 1

    get-nginx-config $service $slot | docker exec --interactive reverse-proxy sh -c "cat > /etc/nginx/conf.d/$service.conf"
    docker exec reverse-proxy nginx -t || return 2
    docker exec reverse-proxy nginx -s reload
}

Функция get-active-slot требует небольших пояснений:


Почему она возвращает число, а не выводит строку?

Всё равно в вызывающей функции мы проверяем результат её работы, а проверять exit code средствами bash намного проще, чем строку. К тому же, получить из неё строку очень просто:
get-active-slot service && echo BLUE || echo GREEN.


А трёх условий точно хватает, чтобы различить все состояния?


Даже двух хватит, последнее тут просто для полноты, чтобы не писать else.


Осталась неопределённой только функция, возвращающая конфиги nginx: get-nginx-config service_name deployment_slot. По аналогии с хелсчеком, тут можно задать любой конфиг для любого сервиса. Из интересного — только cat <<- EOF, что позволяет убрать все табы в начале. Правда, цена благовидного форматирования — смешанные табы с пробелами, что сегодня считается очень дурным тоном. Но bash форсит табы, а в конфиге nginx тоже было бы неплохо иметь нормальное форматирование. Короче, тут смешение табов с пробелами кажется действительно лучшим решением из худших. Однако, в сниппете ниже Вы этого не увидите, так как хабр "делает хорошо", меняя все табы на 4 пробела и делая невалидным EOF. А вот тут заметно.


Чтоб два раза не вставать, сразу расскажу про cat << 'EOF', который ещё встретится далее. Если писать просто cat << EOF, то внутри heredoc производится интерполяция строки (раскрываются переменные ($foo), вызовы команд ($(bar)) и т.д.), а если заключить признак конца документа в одинарные кавычки, то интерполяция отключается и символ $ выводится как есть. То что надо для вставки скрипта внутрь другого скрипта.

get-nginx-config() {
    local usage_msg="Usage: ${FUNCNAME[0]} image_name deployment_slot"
    local image=${1?$usage_msg}
    local slot=${2?$usage_msg}
    [ "$slot" == BLUE ] || [ "$slot" == GREEN ] || return 1

    local container_name=${image}_${slot}
    case $image in
        # Add specific nginx configs for your services here
        *) nginx-config-simple-service $container_name:8080 ;;
    esac
}

nginx-config-simple-service() {
    local usage_msg="Usage: ${FUNCNAME[0]} proxy_pass"
    local proxy_pass=${1?$usage_msg}

cat << EOF
server {
    listen 80;
    location / {
        proxy_pass http://$proxy_pass;
    }
}
EOF
}

Это и есть весь скрипт. И вот гист с этим скриптом для скачки через wget или curl.


Выполнение параметризированных скриптов на удалённом сервере


Пришло время стучаться на целевой сервер. В этот раз localhost вполне подойдёт:


$ ssh-copy-id localhost
/usr/bin/ssh-copy-id: INFO: attempting to log in with the new key(s), to filter out any that are already installed
/usr/bin/ssh-copy-id: INFO: 1 key(s) remain to be installed -- if you are prompted now it is to install the new keys
himura@localhost's password: 

Number of key(s) added: 1

Now try logging into the machine, with:   "ssh 'localhost'"
and check to make sure that only the key(s) you wanted were added.

Мы написали скрипт деплоймента, который перекачивает предварительно собранный образ на целевой сервер и бесшовно подменяет контейнер сервиса, но как его выполнить на удалённой машине? У скрипта есть аргументы, так как он универсален и может деплоить сразу несколько сервисов под один реверс-прокси (конфигами nginx можно разрулить по какому url какой будет сервис). Скрипт нельзя хранить на сервере, так как в этом случае мы не сможем его автоматически обновлять (с целью багфиксов и добавления новых сервисоы), да и вообще, стэйт = зло.


Решение 1: Таки хранить скрипт на сервере, но копировать его каждый раз через scp. Затем подключиться по ssh и выполнить скрипт с необходимыми аргументами.


Минусы:


  • Два действия вместо одного
  • Места куда вы копируете может не быть, или не быть к нему доступа, или скрипт может выполняться в момент подмены.
  • Желательно убрать за собой (удалить скрипт).
  • Уже три действия.

Решение 2:


  • В скрипте держать только определения функций и вообще ничего запускать
  • С помощью sed дописывать в конец вызов функции
  • Отправлять всё это прямо в shh через pipe (|)

Плюсы:


  • Truely stateless
  • No boilerplate entities
  • Feeling cool

Вот давайте только без Ansible. Да, всё уже придумано. Да, велосипед. Смотрите, какой простой, элегантный и минималистичный велосипед:


$ cat << 'EOF' > deploy.sh

#!/bin/bash

usage_msg="Usage: $0 ssh_address local_image_tag"
ssh_address=${1?$usage_msg}
image_name=${2?$usage_msg}

echo "Connecting to '$ssh_address' via ssh to seamlessly deploy '$image_name'..."
( sed "\$a deploy $image_name" | ssh -T $ssh_address ) << 'END_OF_SCRIPT'
deploy() {
    echo "Yay! The '${FUNCNAME[0]}' function is executing on '$(hostname)' with argument '$1'"
}
END_OF_SCRIPT

EOF

$ chmod +x deploy.sh

$ ./deploy.sh localhost magic-porridge-pot
Connecting to 'localhost' via ssh to seamlessly deploy 'magic-pot'...
Yay! The 'deploy' function is executing on 'hut' with argument 'magic-porridge-pot'

Однако, мы не можем быть уверены, что на удалённом хосте есть адекватный bash, так что добавим в начало небольшую проверочку (это вместо shellbang):


if [ "$SHELL" != "/bin/bash" ]
then
    echo "The '$SHELL' shell is not supported by 'deploy.sh'. Set a '/bin/bash' shell for '$USER@$HOSTNAME'."
    exit 1
fi

А теперь всё по-настоящему:


$ docker exec reverse-proxy rm /etc/nginx/conf.d/default.conf

$ wget -qO deploy.sh https://git.io/JUc2s

$ chmod +x deploy.sh

$ ./deploy.sh localhost uptimer
Sending gzipped image 'uptimer' to 'localhost' via ssh...
Loaded image: uptimer:latest
Connecting to 'localhost' via ssh to seamlessly deploy 'uptimer'...
Deploying 'uptimer_GREEN' in place of 'uptimer_BLUE'...
06f5bc70e9c4f930e7b1f826ae2ca2f536023cc01e82c2b97b2c84d68048b18a
Container started. Checking health...
Requesting 'http://uptimer_GREEN:8080/' within the 'web-gateway' docker network:
  HTTP/1.0 503 Service Unavailable
wget: server returned error: HTTP/1.0 503 Service Unavailable
New 'uptimer_GREEN' service is not ready yet. Waiting (1)...
Requesting 'http://uptimer_GREEN:8080/' within the 'web-gateway' docker network:
  HTTP/1.0 503 Service Unavailable
wget: server returned error: HTTP/1.0 503 Service Unavailable
New 'uptimer_GREEN' service is not ready yet. Waiting (2)...
Requesting 'http://uptimer_GREEN:8080/' within the 'web-gateway' docker network:
  HTTP/1.0 200 OK
  Server: BaseHTTP/0.6 Python/3.8.3
  Date: Sat, 22 Aug 2020 20:15:50 GMT
  Content-Type: text/html

New 'uptimer_GREEN' service seems OK. Switching heads...
nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
nginx: configuration file /etc/nginx/nginx.conf test is successful
2020/08/22 20:15:54 [notice] 97#97: signal process started
The 'uptimer_GREEN' service is live!
Killing 'uptimer_BLUE'...
uptimer_BLUE
Total reclaimed space: 0B
Deployment successful!

Теперь можно открыть http://localhost/ в браузере, запустить деплоймент ещё раз и убедиться, что он проходит бесшовно путём обновления страницы по КД во время выкладки.


Не забываем убираться после работы :3


$ docker rm -f uptimer_GREEN reverse-proxy 
uptimer_GREEN
reverse-proxy

$ docker network rm web-gateway 
web-gateway

$ cd ..

$ rm -r blue-green-deployment



Disclaimer: Этот скрипт не является готовым ко внедрению решением. Статья написана исключительно в образовательных целях, чтобы поделиться с Вами эстетическим удовольствием от bash-скриптинга. Каждый скрипт на bash — это произведение искусства, и чем больше на этом языке пишешь, тем лучше можно понять тех, кто был против systemd, ведь с приходом systemd галерею по адресу /etc/init.d/ навсегда закрыли. Если Вы стремитесь к унификации и отдаёте предпочтение готовым поддерживаемым инструментам, то для Вас есть Docker Swarm Mode (пока) и множество мощных оркестраторов с кучей готовых стратегий бесшовной выкладки. Но готовые инструменты никогда не являются панцеей. Этот скрипт родился не только из любви к bash-скриптингу, но и потому что давным-давно, в далёкой-далёкой галактике написать его оказалось проще, чем внедрить оркестратор. А ещё, его легко модицифировать под особые нужды особых приложений.

AdBlock похитил этот баннер, но баннеры не зубы — отрастут

Подробнее
Реклама

Комментарии 35

    0
    Интересная реализация! Есть над чем подумать)
      0

      спасибо ^^

      0
      После поднятия второго контейнера и исправления файла в nginx старый контейнер убивается. Как в таком случаи поведут себя активные соединения с этим контейнером?
      Тот случай, когда запрос может выполнятся 10-20 секунд.
        0

        на запросы по 10-20 секунд, конечно, не рассчитано ((
        при docker rm -f приложение получит SIGKILL и всё оборвёт. В таком случае, скорее всего лучше явно делать docker stop && docker rm, тогда приложение получит SIGTERM и сможет обработать завершение при наличии необработанных запросов.


        А по простому — если известно сколько примерно может длится запрос, можно поднять цифру в sleep на удвоенное время среднего запроса.

          –1

          Делали подобное. Nginx при команде reload сбрасывает все активные подключения и это не лечится. Если ресурсов не жалко — просто ставим сверху второй и получается совсем бесшовно — проверяли свою реализацию под лоад тестами.

            0

            Ого, спасибо за то что поделились опытом, мы только предполагали, что nginx по идее должен подождать ответов на активные запросы. Если есть такая проблема, то наверно можно его настроить на балансирование трафика с коэффициентами до переключения 1:0, а потом 0:1 после переключения. А ещё, мы Traefik пробовали, но он тогда сыроват был и пришлось отказаться из за бага в хелсчеке.


            Или он вообще при любом reload сбрасывает, даже при смене коэффициентов?


            Тогда это очень странно

              –2
              > мы только предполагали, что nginx по идее должен подождать ответов на активные запросы
              Вот и мы предпологали, но проверили это предположение.

              > Или он вообще при любом reload сбрасывает, даже при смене коэффициентов?
              При любом reload. Он убивает старые воркеры и поднимает новые, с новым конфигом.
              А если поставить nginx перед ним, он сделает retry и для клиента это не будет заметно.
                0

                мда, c такими особенностями nginx уже не кажется идеально подходящим на роль балансера или реверс-прокси… Очень полезный опыт, реально спасибо )

                  +1

                  Ничего он не убивает, а ждет завершении работы клиента с воркером.

                +4
                > Nginx при команде reload сбрасывает все активные подключения и это не лечится.

                Смотря что подразумевается под активными. Открытые коннекты, в которых не выполняется никакой запрос, он действительно сбрасывает. Однако если какой-то запрос выполняется, то нет.

                Я специально потестил: попробовал медленно качать большой файл, и сделать в этот момент reload. Скачка не прервалась, и воркер, на котором остался этот коннект, висит в списке процессов под именем «nginx: worker process is shutting down». Как только скачивание заканчивается, он выходит.
                  0
                  Спасибо. Но у нас коннекты действительно падали, надо разобраться почему.
                  +3

                  Это не так, nginx при reload ждёт окончания соединений или отвала по таймауту. При этом запускает новые воркеры для обслуживания новых соединений. Старые воркеры будут остановлены только когда все текущие соединения будут завершены или по таймауту worker_shutdown_timeout

                0
                А зачем у вас в тексте цвет, если нужно представлять, как бы это выглядело на «железном» терминале?
                  0

                  это хайлайтеры )) кто-то очень хорошо оформил эти старинные вклейки, чтобы казалось будто смотришь код на цветном телевизоре)

                    0
                    Никто и никогда не смотрел вывод UNIX на цветном телевизоре.
                    Человек, который мог себе позволить компьютер, на котором запускалась Unix — мог позволить себе и монитор.

                    Какой-то школьный утренник, честное слово :(
                0

                Простите, но зачем велосипеды(сложные), когда blue-green из коробки:
                docker service update --image imagename:tag servicename

                  –1

                  This is a cluster management command, and must be executed on a swarm manager node.


                  А swarm мёртв (тащем то сам docker тоже мёртв, а этот скрипт скорее всего нормально на podman работает).
                  Так то и k8s можно юзать, но это уже не интересно.
                  Статья показывает как готовить blue-green вручную, а если у Вас есть оркестратор, то там уже всё приготовлено

                    +1

                    Docker Swarm — мёртв.
                    Docker Swarm Mode — живой, из коробки, не требует доп ресурсов и траты времени на баш-костыли.
                    Для чего вообще подобное может понадобиться, если давно все стараются(должны) унифицировать окружения? Ваши скрипты не поедут в прод, так зачем локально управлять иначе?
                    Swarm мёртв, k8s сложно — и потом в прод едут велосипеды, я это вижу в 2020.

                      +1
                      1. Это отличная практика баш-скриптинга
                      2. Оно может пригодиться в проде как минимум в двух случаях:
                        • Частично: например, заменить тяжелый и сложный Ansible на представленный способ параметризованного удалённого выполнения функций в каких-то суперпростых задачах.
                        • Полностью: если приложения требуют какой-то сложной логики хелсчека или переключения слотов, а docker swarm это не поддерживает.

                      Вот, например, https://github.com/p8952/bocker — тоже народ бесполезно время потратил и никому это не надо?

                      +1

                      Юзаю docker swarm mode 3 года в прод — полёт отличный, бесшовное обновление контейнеров(при условии нормально настроенного healthcheck), быстрый откат(rollback). А так интересно написанное решение. Спасибо!

                    +1

                    Вот за это:
                    service=${1?$usage_msg}
                    Спасибо! Не знал и писал отдельную обертку.

                      0

                      Ага, это уже высший эльфийский bash-скриптинга, там ещё очень много всякого можно делать со строками прямо на выводе, и всё это крайне неинтуитивно, сразу же забывается, но чертовски привлекательно )

                      –1

                      А что, если:


                      1. поднять контейнер приложения с примонтированными файлами
                        docker run ... -v /app/current:/var/www ...
                      2. любым capistrano like инструментом делать current release методом ссылок
                        ln -s /app/current /var/www/releases/100500

                      Контейнер никогда не останавливается, файлы обновляются. Все довольны.
                      Ещё больше минималок для blue-green deployment

                        +1

                        прикольная идея. Вроде Visual Studio подобным образом делает для отладки в докере. Но опять же подходит далеко не всегда:


                        1. Не всё можно обновить таким образом, если надо бампнуть версию самого HTTP сервера, то его всё-таки придётся останавливать. А современные приложения обычно сами себе HTTP сервер (как в примере), и никакого /var/www вообще нету ))
                        2. Засорять сервер исходниками вне контейнеров, бэээ. Зачем тогда вообще контейнер, если можно просто HTTP сервер на bare metal поднять и то же самое делать )
                          +1

                          Конечно.


                          По первому пункту: если файлов нет, то сразу в swarm идём и получаем удовольствие.
                          По второму пункту: нужно обратиться к здравомыслию. Если контейнер используется только как HTTP слой, то можно и самому поднимать nginx и проксировать на файлы, без контейнеров. Но чаще всего, спасение от контейнеров в том, что в нём стоит over 100500 зависимостей для обработки картинок и прочего дерьма, которое сложновато поставить на bare.


                          ИМХО. Не являюсь носителем истины последней инстанции.

                            +1
                            сразу в swarm идём и получаем удовольствие

                            Было уже про swarm выше. Я честно не знал, что swarm ещё хоть в каком-то виде жив и был немало удивлён тем, что есть некий swarm mode и он вроде как норм. Но вообще, даже есть и так, то swarm — это поделка чисто докера, а докер уже давно мёртв не только как компания, но и как реализация контейнерной технологии. В RHEL-полушарии линуксов уже даже полностью отказались от докера, заменив его на CLI-совместимый podman без центрального демона, работающего от рута, который является очень узким местом. Есть основания полагать, что скоро этот тренд и до Debian-полушария дойдёт, и тогда все точно слезут с докера. А в podman уже нет никакого недооркестратора, только k8s, только хардкор. А этого монстра далеко не везде возможно внедрить...

                              0

                              Разговоры о том, кто мёртв, а кто жив, будут до тех пор, пока наконец-то все не начнут использовать terraform.

                                0
                                Разговоры о том, кто мёртв, а кто жив, будут до тех пор, пока наконец-то все не начнут использовать %MY_FAVORITE_TECH%.
                                  0

                                  Я про то, что нужно переходить на какой-либо инструмент унификации и не зависеть от технологий.

                                    0

                                    Я уже добавил дисклеймер про это в конец статьи. Моё мнение, что для подобных штук надо использовать k8s, но я могу ошибаться, так как нормально пощупать k8s у меня ещё не было возможности, к сожалению. Это прям стандарт отрасли сейчас, лучше придерживаться решений такого класса, чтобы не переделывать всё каждые 2 года

                                  0
                                  А каким образом терраформ спасает от докера..?
                                  +2

                                  Как раз для k8s нужен именно демон, который реализует cri интерфейс, podman не реализует cri интерфейс, k8s с ним не работает. На текущий момент их не так много, основных: containerd, docker, cri-o (https://kubernetes.io/docs/setup/production-environment/container-runtimes/). Docker внутри использует containerd, то есть по сути это containerd vs cri-o, и я бы не сказал что cri-o обладает супер преимуществами, чтобы делать выбор в его сторону, более того с ним я испытал больше проблем чем с containerd (от docker). Поэтому считаю сомнительными все эти похороны докера.

                          Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

                          Самое читаемое