蓝/绿 Node.js 使用 NGINX 进行部署
我最近遇到一个情况,需要将 Node.js 应用程序部署到我自己的服务器1 上。一开始,我尝试寻找一些有用的资料,这样就不用自己摸索了,但我找到的只有“使用 NGINX”和“可能使用 pm2”。这些建议很有帮助,但仍然有很多细节需要我去弄清楚。在这篇文章中,我将讨论我遇到的问题以及我选择的解决方案,希望能够帮助到将来遇到类似问题的人。
我们将讨论以下主题:
- 验证主机密钥
- 在虚拟机上远程执行部署脚本
- 使用 PM2 管理 Node.js 进程
- 使用 NGINX 进行蓝绿部署
- 并行部署
- 可重复使用的私有 GitHub Action
- 清除 GitHub 操作日志中的机密
要求
- 零停机部署。我本来可以很容易地向管理层解释,这太复杂了,我们必须设置一个维护窗口,但如今零停机部署已成常态,尤其是对于前端应用而言。为了我自己(我的自尊和良心),我想实现这一点。
- 每当主分支更新时自动部署。我不知道这有多普遍,但我用 Heroku 已经这样做了很多年,我实在想不出还有什么其他的开发方式。手动触发部署感觉有点过时了。
- 部署到现有机器。部署目标将是一组当前正在使用的生产虚拟机。我没有使用新虚拟机并替换旧虚拟机的选项。
执行
我们已经使用 GitHub Actions 针对所有 PR 运行测试,因此我认为我们也会在主分支更新时使用它们来触发部署。
从概念上讲,我想象这个过程看起来是这样的:
- 推送到 master 会触发部署
- 连接到所有部署目标(服务器)并运行安装和运行新代码的脚本
- 将流量从旧代码转移到新代码
- 清理旧代码
从最初的提纲到最终的实现,我花了三四天时间。我会解释我最终的成果以及为什么做出这些选择。
验证主机密钥
我遇到的第一个问题是验证主机密钥。首次通过 SSH 连接到一台机器时,会弹出一个提示符询问您是否信任远程服务器的密钥。但我是在脚本中运行这个提示符,所以需要避免这个提示。您可以禁用它,但这被认为是危险的,因为潜在的中间人攻击。另一种方法是使用ssh-keyscan
自动将远程密钥添加到您的信任列表中。
ssh-keyscan "$IP" >> ~/.ssh/known_hosts
但我看不出这有什么更安全的地方。不管怎样,你都盲目地信任了 IP 地址。有什么替代方案吗?或许你可以ssh-keyscan
为每个主机手动运行一次,然后将结果存储在配置中,然后将其添加到known_hosts
……
在虚拟机上远程执行部署脚本
我有一个部署目标 IP 列表和一个 SSH 密钥。我需要在实际执行部署的虚拟机上运行一组命令。由于命令数量很少,所以我先用了appleboy/ssh-action。
- name: SSH Commands
uses: appleboy/ssh-action@v0.1.3
env:
GH_TOKEN: ${{ secrets.GH_TOKEN }}
with:
host: ${{ secrets.DEPLOY_IP }}
username: ${{ secrets.DEPLOY_USERNAME }}
key: ${{ secrets.SSH_KEY }}
script_stop: true
envs: GH_TOKEN
script: |
cd /srv/bg
git clone --depth 1 "https://${GH_TOKEN}@github.com/Org/Repo.git"
cd bg-web
npm i
npm run build
npm run start
但我的命令列表很快就变长了,我很快就想维护一个可以远程执行的 bash 脚本。所以我改用了如下脚本:
- name: Deploy
run: |
KEY_FILE=$(mktemp)
echo "${{ secrets.SSH_KEY }}" > "$KEY_FILE"
ssh -i $KEY_FILE ubuntu@${{ secrets.DEPLOY_IP }} -- < deploy.sh
效果很好。我特别喜欢在编写部署脚本时使用语法高亮功能。但最终我想要更多功能,比如将部署脚本的输出记录到临时日志文件,并将环境变量传递给脚本。我决定在执行之前将部署脚本复制到虚拟机上。我手头已经有可用的 SSH 密钥,使用 scp 命令可以轻松完成:
# Transfer the deploy script onto the VM so that we can execute it later.
# If we have previously deployed to the VM, an older version of the script will be there and be overwritten with the latest version.
scp -i $KEY_FILE /scripts/deploy.sh ubuntu@$IP:~/
# Execute the deploy script and save the logs to a temp file.
ssh -i $KEY_FILE ubuntu@$IP "tmpfile=$(mktemp /tmp/deploy.XXXX); echo \"Deploy log for $IP saved in \$tmpfile\"; GH_TOKEN=$GH_TOKEN IP=$IP REPO=$REPO bash deploy.sh > \$tmpfile 2>&1"
这就是我最终的方案。唯一让我不太满意的是环境变量列表(在我使用的版本中,环境变量列表实际上要长得多)。如果你知道更好的方法,请告诉我。
使用 PM2 管理 Node.js 进程
Node.js 是单线程的,这意味着你需要运行同一进程的多个实例才能使用所有可用的 CPU 核心。这通常是通过Cluster API来实现的。我以前用过它,但不想再用了。你必须设置一个主文件来生成进程并管理它们的生命周期、处理错误、重新生成终止的进程等等。我没有自己处理所有这些,而是选择使用pm2。现在,将应用程序集群化非常简单:
pm2 start -i max --name $PROCESS_NAME $START_COMMAND
稍后,当我需要清理旧代码时,我可以使用pm2 list
来查找任何与新代码不匹配的进程$PROCESS_NAME
,并用 终止它们pm2 delete
。下一节将详细介绍。
蓝绿部署
蓝绿部署是实现零停机部署的一种方法,即启动一台新服务器,然后在淘汰旧服务器之前将流量路由到新服务器。但是,我没有能力使用新服务器,所以只能在现有服务器上完成同样的操作。
流量会从端口 80 或 443 进入。绑定到这些端口需要 root 权限。但您不希望您的 Web 应用拥有 root 权限。因此,您可以使用iptables将端口 80 重定向到您的应用,也可以使用 NGINX。我们选择 NGINX 是因为它提供了更多我们预计未来会用到的 HTTP 配置功能(SSL 证书、标头等)。
/etc/nginx/site-enabled
我们从一个如下所示的 conf 文件开始:
server {
listen 80;
server_name domain.com;
location / {
proxy_pass http://localhost:3000;
}
}
稍后,当我们部署新脚本时,端口 3000 已被占用,因此我们需要使用其他端口。我们可以不断地在端口 3000 和 3001 之间切换,但跟踪当前正在使用的端口需要状态信息,这感觉很不可靠。因此,我选择每次随机生成一个端口,然后检查该端口是否未被使用。
# Picks a random number between 3000 and 3999.
function random-number {
floor=3000
range=3999
number=0
while [ "$number" -le $floor ]
do
number=$RANDOM
let "number %= $range"
done
echo $number
}
# Pick a random port between 3000 and 3999 that isn't currently being used.
PORT=$(random-number)
while [[ $(lsof -i -P -n | grep :$PORT) ]]
do
PORT=$(random-number)
done
echo "Ready to deploy on port $PORT"
我还使用了安装代码的目录中的端口号(以确保与以前的安装没有任何冲突)并通过在 pm2 中注册来识别进程。
现在我们更新 NGINX 配置:
sudo cat << EOF | sudo tee /etc/nginx/sites-enabled/site.conf > /dev/null
server {
listen 80;
server_name domain.com;
location / {
proxy_pass http://localhost:$PORT;
}
}
EOF
虽然配置文件已经改变,但 NGINX 还不知道。我们可以发送 reload 信号让它重新加载配置文件:
sudo nginx -s reload
NGINX 文档说这应该优雅地发生:
它启动新的工作进程,并向旧的工作进程发送消息,请求它们正常关闭。旧的工作进程会关闭监听套接字并继续为旧的客户端提供服务。当所有客户端都服务完毕后,旧的工作进程将被关闭。
这太棒了。它负责优雅地传输流量,这样我们就不用费心了。但是,传输完成后它不会发出信号。那么,我们如何知道何时可以淘汰并清理旧代码呢?
一种方法是监控进程的流量。但这听起来很复杂。因为有多个进程。我怎么知道所有进程的流量都处理完了呢?如果你有什么想法,我很乐意听听。但我选择了另一种解决方案。
我意识到 NGINX 的工作进程数量是固定的(似乎与 CPU 核心数相关)。但我上面引用的关于重新加载的段落提到,它会与旧工作进程并行启动新的工作进程,所以在重新加载期间,工作进程的数量是原来的两倍。因此,我想到可以在重新加载之前统计工作进程的数量,然后等到工作进程数量恢复正常。结果成功了。
function nginx-workers {
echo $(ps -ef | grep "nginx: worker process" | grep -v grep | wc -l)
}
# Reload (instead of restart) should keep traffic going and gracefully transfer
# between the old server and the new server.
# http://nginx.org/en/docs/beginners_guide.html#control
echo "Reloading nginx..."
numWorkerProcesses=$(nginx-workers)
sudo nginx -s reload
# Wait for the old nginx workers to be retired before we kill the old server.
while [ $(nginx-workers) -ne $numWorkerProcesses ]
do
sleep 1;
done;
# Ready to retire the old code
这并非 100% 零宕机。我做了负载测试,确认大约有一秒钟的宕机时间。我不知道这是因为我过早地终止了旧进程,还是因为 NGINX 拒绝了连接。我尝试在sleep
循环后添加更多进程,以确保所有连接都已耗尽并终止,但毫无效果。我还注意到(在负载测试期间)错误是关于无法建立连接(而不是连接提前终止),这让我相信这是由于 NGINX 重新加载并非 100% 优雅造成的。但目前看来,一切都足够好了。
现在我们准备清理旧代码:
# Delete old processes from PM2. We're assuming that traffic has ceased to the
# old server at this point.
# These commands get the list of existing processes, pair it down to a unique
# list of processes, and then delete all but the new one.
pm2 list | grep -o -P "$PROCESS_NAME-\d+" | uniq | while IFS=$'\n' read process; do
if [[ $process != $PROCESS_NAME-*$PORT ]];
then
pm2 delete $process
fi
done
# Delete old files from the server. The only directory that needs to remain
# is the new directory for the new server. So we loop through a list of all
# directories in the deploy location (currently /srv/bg) and delete all
# except for the new one.
echo "Deleting old directories..."
for olddir in $(ls -d /srv/bg/*); do
if [[ $olddir != /srv/bg/$PORT ]];
then
echo "Deleting $olddir"
rm -rf $olddir
else
echo "Saving $olddir"
fi
done;
并行部署
我首先在一台机器上完成了蓝绿部署。我想通过循环遍历 IP 地址列表,可以很容易地将其更改为在多台机器上运行。如果我按顺序进行部署,可能很容易,但我希望并行进行部署以减少部署所花费的时间。我希望可以直接在后台执行 ssh 命令。ssh &
但是我收到了一些错误消息,提示这样做是错误的。搜索互联网后,我发现了许多替代方案,这些方案要么不起作用,要么无法轻松提供子进程 ID(稍后会详细介绍我们为什么需要它)。我最终只创建了另一个包含 scp 和 ssh 命令的 bash 脚本。然后,我可以轻松地在后台执行该 bash 脚本。
# Turn the list of IPs into an array
IPS=( $DEPLOY_IPS )
for IP in "${IPS[@]}"; do
echo "Preparing to connect to $IP"
# Here's that list of env vars again
KEY_FILE=$KEY_FILE GH_TOKEN=$GH_TOKEN IP=$IP REPO=$GITHUB_REPOSITORY bash /scripts/connect.sh &
done
所以我最终得到了这三个脚本:
deploy-manager.sh -> connect.sh -> deploy.sh
但是我如何知道部署何时完成,以及如果其中一个部署失败了,我该如何知道呢?我在 Unix & Linux StackExchange 网站上找到了一个不错的解决方案。你只需收集子进程 ID,然后等待所有子进程的退出代码为 0。
如果部署在一台机器上失败,但在另一台机器上成功,该怎么办?我还没解决这个问题。有什么想法吗?
可重复使用的私有 GitHub Action
在将所有这些功能整合到一个包含多个部署目标的仓库后,我决定将其移至一个私有的 GitHub Action 中,以便可以在多个 Node.js 应用之间共享。我以为这会很容易,因为我已经拥有了所有可用的代码。但一如既往,我错了。
首先,GitHub 不正式支持私人操作,但您可以使用方便的解决方案来解决这个问题。
GitHub 为自定义操作提供了两种实现方案:Node.js或Docker。我之前写过 Node.js 操作,但体验不如预期。它要求你将打包好的代码提交到仓库,因为它不会自动安装依赖项。如果你努力的话,或许可以不用 deps,但如果不使用@actions/core会更加不方便。编写一个只执行 bash 脚本的 Node 脚本也感觉不太好。所以我决定创建一个 Docker 操作。
我以为只需要一个可以执行deploy-manager.sh
脚本的原生 dockerfile。但很快就遇到了问题。我的脚本是为在 GitHub 工作流运行器上执行而开发的。我指定了 ubuntu-latest,并以为这是一个非常原生的安装。但结果发现,他们安装了大量软件,但不幸的是,没有提供 docker 容器。幸运的是,我只需要安装openssh-server
。这是我最终的 Dockerfile:
FROM ubuntu:18.04
RUN apt update && apt install -y openssh-server
COPY scripts/*.sh /scripts/
ENTRYPOINT ["/scripts/deploy-manager.sh"]
我又遇到了一个问题。当我切换到 Docker 操作时,主机密钥验证开始失败。这是因为 Docker GitHub Actions 是以root 身份运行的,而我开发的脚本是以 ubuntu 用户身份运行的。用户有自己的known_hosts
文件,位于~/.ssh/known_hosts
。但对于 root 用户,我需要修改位于 的全局文件/etc/ssh/ssh_known_hosts
。
我很高兴学习了 Docker,但我可能会重新考虑是否使用它。每次运行操作时都构建一个容器,还是将打包好的代码提交到操作仓库中更好?😬
清除 GitHub 操作日志中的机密
如果您想在 GitHub Workflows 中使用自定义环境变量,唯一的选择是使用Secrets。我的一个 Secret 存储了部署目标的 IP 列表。但这实际上并不是我需要保密的东西,而且通常用于调试日志。
GitHub 会清理操作日志,自动屏蔽机密信息。由于我的 IP 地址在列表中,而我只打印一个,所以我以为它不会被屏蔽。结果它被屏蔽了!他们肯定对机密信息进行了部分匹配(我琢磨着他们用的字符长度)。为了解决这个问题,我使用了一个$UNSECRET_IP
变量,将$IP
所有的点替换成破折号。果然,它没有被屏蔽。
UNSECRET_IP=$(echo $IP | tr . -)
结论
这工作量很大,而且它甚至无法处理部分部署失败、回滚或日志管理。我想我会花不少时间来维护这个作品。这源于我对 PaaS 提供商价值的信念。我宁愿花钱请人帮我做这件事,而且做得比我好得多。
-
我更喜欢使用 Heroku、Netlify 和 Vercel 等 PaaS 提供商,这样我就不必做这里讨论的所有事情 😂。↩