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). Поэтому считаю сомнительными все эти похороны докера.

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

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