最低工资蓝绿部署

在这篇文章中我们使用 打坏, SSH, 搬运工人 и nginx的 我们将组织 Web 应用程序的无缝布局。 蓝绿部署 是一种允许您立即更新应用程序而无需拒绝单个请求的技术。 它是零停机部署策略之一,最适合具有一个实例但能够加载附近第二个可立即运行的实例的应用程序。

假设您有一个 Web 应用程序,许多客户端正在使用该应用程序,并且它绝对不可能停下来几秒钟。 您确实需要推出库更新、错误修复或新的酷功能。 在正常情况下,您需要停止该应用程序,替换它并重新启动它。 对于docker,您可以先替换它,然后重新启动它,但仍然会有一段时间不会处理对应用程序的请求,因为通常应用程序需要一些时间来初始加载。 如果启动了但结果无法运行怎么办? 这就是问题所在,让我们用最少的手段,尽可能优雅地解决它。

免责声明:本文的大部分内容均以实验格式呈现 - 以控制台会话录制的形式。 希望这不会太难理解,并且代码能够充分记录自己。 为了营造气氛,想象一下这些不仅仅是代码片段,而是来自“铁”电传打字机的纸张。

最低工资蓝绿部署

每节的开头都描述了仅通过阅读代码很难通过 Google 搜索到的有趣技术。 如果还有什么不清楚的,可以谷歌一下并检查一下。 解释shell (幸运的是,由于电报的解锁,它再次起作用)。 如果您无法通过 Google 搜索任何内容,请在评论中提问。 我很乐意添加到相应的“有趣的技术”部分。

让我们开始吧

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

服务

让我们制作一个实验性服务并将其放入容器中。

有趣的技巧

  • cat << EOF > file-name (这里文件 + 输入/输出重定向) 是一种使用一个命令创建多行文件的方法。 bash 读取的所有内容 /dev/stdin 在此行之后和该行之前 EOF 将被记录在 file-name.
  • wget -qO- URL (解释shell) — 将通过 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 内部链接应用程序和反向代理 码头网络。 因此,带有应用程序的容器甚至不需要转发主机系统上的端口;这使得应用程序能够最大程度地隔离外部威胁。

如果反向代理位于另一台主机上,则必须放弃 docker 网络并通过主机网络将应用程序连接到反向代理,并转发端口 应用 范围 --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>

无缝部署

让我们推出应用程序的新版本(启动性能提高两倍)并尝试无缝部署它。

有趣的技巧

  • 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 系统),然后将其传输到服务器。

传输图像

不幸的是,将图像从本地主机传输到本地主机是没有意义的,因此只有当您手头有两台带有 Docker 的主机时才能探索本节。 至少它看起来像这样:

$ 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. 容器注册表(行业标准)。
  2. 从另一台主机连接到 docker 守护进程服务器:
    1. 环境变量 DOCKER_HOST.
    2. 命令行选项 -H или --host 工具 docker-compose.
    3. docker context

文章中详细描述了第二种方法(具有三种实现选项) 如何使用 docker-compose 在远程 Docker 主机上部署.

deploy.sh

现在,让我们将手动执行的所有操作收集到一个脚本中。 我们先从顶层函数开始,然后看看其中使用的其他函数。

有趣的技巧

  • ${parameter?err_msg} - bash 魔法咒语之一(又名 参数替换)。 如果一个 parameter 未指定,输出 err_msg 并使用代码 1 退出。
  • docker --log-driver journald — 默认情况下,docker 日志记录驱动程序是一个没有任何旋转的文本文件。 使用这种方法,日志很快就会填满整个磁盘,因此对于生产环境,有必要将驱动程序更改为更智能的驱动程序。

部署脚本

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

    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 "'$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 service=${1?"Usage: ${FUNCNAME[0]} service_name"}

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

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

    case $service in
        # Add specific healthcheck paths for your services here
        *) local health_check_port_path=":8080/" ;;
    esac
    local health_check_address="http://${service}_${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 需要一点解释:

为什么它返回一个数字而不输出一个字符串?

无论如何,在调用函数中我们检查其工作结果,并且使用 bash 检查退出代码比检查字符串要容易得多。 另外,从中获取字符串也非常简单:
get-active-slot service && echo BLUE || echo GREEN.

三个条件真的足以区分所有状态吗?

最低工资蓝绿部署

两个就够了,最后一个只是为了完整,免得写下来 else.

只有返回 nginx 配置的函数保持未定义: get-nginx-config service_name deployment_slot。 与健康检查类似,在这里您可以为任何服务设置任何配置。 有趣的事情 - 仅 cat <<- EOF,它允许您删除开头的所有选项卡。 确实,良好格式的代价是混合制表符和空格,这在今天被认为是非常糟糕的形式。 但是 bash 强制使用制表符,并且在 nginx 配置中具有正常格式也很好。 简而言之,在这里混合制表符和空格似乎确实是最坏的解决方案中最好的解决方案。 然而,您不会在下面的代码片段中看到这一点,因为 Habr 通过将所有制表符更改为 4 个空格并使 EOF 无效而“做得很好”。 这里值得注意的是.

为了避免起两次床,我马上告诉你 cat << 'EOF',后面会遇到。 如果你简单地写 cat << EOF,然后在heredoc中对字符串进行插值(变量被扩展($foo),命令调用($(bar)) 等),如果您将文档末尾括在单引号中,则插值将被禁用并且符号 $ 按原样显示。 将一个脚本插入另一个脚本需要什么。

get-nginx-config() {
    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

    local container_name=${service}_${slot}
    case $service 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 (|)

优点:

  • 真正的无国籍
  • 无样板实体
  • 感觉很酷

让我们在没有 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...
Yay! The 'deploy' function is executing on 'hut' with argument 'magic-porridge-pot'

但是,我们无法确定远程主机是否有足够的 bash,因此我们将在开头添加一个小检查(这代替 炮轰):

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/JUURc

$ 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/ 在浏览器中,再次运行部署,并在布局期间根据 CD 更新页面,确保其无缝运行。

下班后别忘了清理: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

来源: habr.com