外观
项目 3:2048 远程自动化发布
约 4474 字大约 15 分钟
SSHDockerUbuntuscp
2026-05-24
项目目标
以 将 2048 游戏从 WSL 控制端发布到远程 Ubuntu 服务器 为情境,使用 Docker 在本地模拟两台远程服务器,逐步编写 remote_deploy_2048.sh。
完成本项目后,你不仅能手写出工业级的自动化部署流水线脚本,还将深刻理解网络安全中的中间人攻击(MITM)、以及网络传输工具在算力与 I/O 上的极致博弈。
一、 本地到远程的跨越:我们需要自动化发布
在上一课中,我们编写了极其健壮的本地部署脚本。但在真实的互联网公司里,真正的需求是:在控制机(如你的 WSL)上一键执行脚本,将代码推送到远端服务器并触发部署。
这个过程被称为发布(Publish / Release)。
请看下方的动画演示,直观感受本地控制机与远程目标机之间的边界,以及我们即将构建的自动化流水线全貌:
WSL 控制端 (开发机)
deploy.sh
2048/index.html
SSH 客户端
公网 / 局域网
📦 静态资源
⌨️ sudo cp ...
⌨️ systemctl reload
Ubuntu 远程服务器
/tmp/2048_upload
/var/www/2048
Nginx 服务
点击“开始推送”,观察脚本是如何跨越网络边界执行动作的。
二、 核心底层理论支撑:翻越两座大山
在编写跨机器脚本之前,我们必须跨越两座大山:网络身份认证的安全性 与 网络传输的可靠性。
1. SSH 握手大山 (联动网络安全)
你们在《网络安全协议》课程中一定学过,在互联网上明文传输密码是致命的,因为很容易遭到 MITM(中间人攻击) 窃听。 为了应对这种威胁,现代 SSH 的安全底座建立在非对称加密与精妙的数学协议之上:
- Diffie-Hellman (DH) 密钥交换:这是一种极其神奇的算法。它允许两台从未见过的机器,在完全公开、甚至被黑客全程监听的互联网上,互相交换一堆看似毫无意义的数字,最终在两端各自计算出一个绝对相同的“对称密钥(如 AES)”。
- 非对称加密(如 ed25519):传统 RSA 密钥仍然可用,但密钥长度更长、签名计算更重;在新建 SSH 密钥时,很多现代环境会优先推荐更短、更快的 ed25519 算法。服务器持有你的公钥(公开的锁),你持有私钥(绝对保密的钥匙)。登录时,服务器抛出一个随机数,你用私钥进行签名,服务器验证无误后放行,自动化脚本执行阶段无需再输入登录密码。
2. 传输带宽瓶颈大山:算力与 I/O 的博弈
当你需要将 500MB 的编译产物推送到 100 台服务器时,网络 I/O 带宽将成为最恐怖的物理极限。 如果采用无脑的全量覆盖传输,假设千兆网卡,百台机器可能需要数分钟。这在敏捷开发流水线中是绝对不可接受的。 真正的 SRE 架构师,懂得利用本地多核 CPU 的算力(哈希计算),去换取昂贵的网络带宽,实现“只传差异数据”。
三、 传输工具深度解剖
在正式敲代码前,我们要认识一下即将使用的网络传输工具。
1. scp 是什么?
顾名思义,基于 SSH 的安全拷贝工具(Secure Copy Protocol)。它就像你平时在电脑里用的复制粘贴(cp 命令),只不过被套上了一层加密的网络管道。 来个最简单的示例:假设你想把本地的 test.txt 传到远程服务器上:
# 基本语法:scp 源文件 目标地址
scp -P 2221 ./test.txt deploy@127.0.0.1:/tmp/致命缺点:它是“瞎子”。无论对端存不存在该文件,无论文件是否修改过,scp 都会固执地把 500MB 的包完整重新传一遍。它极其简单,但也极其笨重。
2. rsync 是什么?
Linux 领域的增量传输神作(Remote Sync)。它不是无脑拷贝,而是引入了滚动哈希(Rolling Hash)算法机制。 它在传输前,会让目标服务器把旧文件切成小块,算出哈希值发过来。控制机拿着哈希表一对比,只挑出那些发生了改变的“差集数据块”发过去,目标端再像拼图一样还原。 来个最简单的示例:如果我们要增量同步整个目录:
# 加上 -a (保留权限) -v (显示日志) -z (压缩网络传输)
rsync -avz -e "ssh -p 2221" ./2048/ deploy@127.0.0.1:/tmp/2048/在增量发布的场景下,如果 500MB 目录里只改了少量文件,使用 rsync 就能只传变化部分,传输量可能从数百 MB 降到很小。
四、 演练靶场搭建:使用 Docker 模拟生产集群
为了让大家能亲手验证上述工具,我们在你的电脑里虚拟出两台 Ubuntu 靶机:
在项目目录中创建 docker/target/Dockerfile:
FROM ubuntu:22.04
ENV DEBIAN_FRONTEND=noninteractive
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
openssh-server sudo nginx rsync curl ca-certificates \
&& rm -rf /var/lib/apt/lists/*
RUN useradd -m -s /bin/bash deploy \
&& echo "deploy:deploy123" | chpasswd \
&& usermod -aG sudo deploy \
&& echo "deploy ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/deploy \
&& chmod 0440 /etc/sudoers.d/deploy
RUN mkdir -p /run/sshd \
&& sed -i 's/^#\?PasswordAuthentication .*/PasswordAuthentication yes/' /etc/ssh/sshd_config \
&& sed -i 's/^#\?PubkeyAuthentication .*/PubkeyAuthentication yes/' /etc/ssh/sshd_config \
&& sed -i 's/^#\?PermitRootLogin .*/PermitRootLogin no/' /etc/ssh/sshd_config
EXPOSE 22 8081 8082
CMD ["/usr/sbin/sshd", "-D", "-e"]再创建 docker-compose.yml:
services:
web01:
build:
context: ./docker/target
image: atsb-ubuntu-ssh-nginx:22.04
container_name: atsb-web01
ports:
- "2221:22"
- "8081:8081"
web02:
build:
context: ./docker/target
image: atsb-ubuntu-ssh-nginx:22.04
container_name: atsb-web02
ports:
- "2222:22"
- "8082:8082"1. 目录结构示意
完成靶场文件、目标清单和脚本文件后,建议你的实验目录保持成下面这个结构:
remote-2048-lab/
├── docker-compose.yml # 定义 web01、web02 两台 Docker Ubuntu 靶机
├── docker/
│ └── target/
│ └── Dockerfile # 构建带 SSH、Nginx、rsync 的 Ubuntu 靶机镜像
├── hosts.conf # 远程目标清单:主机名、SSH 端口、Web 端口、私钥路径
├── remote_deploy_2048.sh # 本节课最终要完成的远程自动化发布脚本
├── 2048/
│ ├── index.html # 2048 首页,脚本会先检查它是否存在
│ ├── style/
│ └── js/
└── logs/ # 脚本运行后自动生成,保存每次部署日志这张目录树的关键是把“控制端文件”和“远端文件”分清楚:hosts.conf、remote_deploy_2048.sh、2048/、logs/ 都在 WSL 控制端;真正的网站目录会由脚本远程创建到目标服务器的 /var/www/2048-web01 或 /var/www/2048-web02。
执行启动命令:
docker compose up -d --build现在,靶场搭建完毕。宿主机的 2221 和 2222 端口,就分别对应着这两台 Ubuntu 服务器的 SSH 大门。
五、 打通密码学桥梁 (免密登录)
自动化脚本执行时,遇到要求输入密码的黑框框就会卡死,所以必须配置免密登录。
请结合下方的动画,理解这场没有密码传递的终极安全握手:
💻
SSH 客户端 (WSL)
🗝️ 私钥 (绝不离开本地)
🔒 公钥 (.pub)
🖥️
SSH 服务端 (Ubuntu)
authorized_keys
等待公钥...
了解为什么配置了 SSH 密钥后就可以免密安全登录。
1. 动手实践:构建 V1 认证基石
我们在 WSL 上生成密钥对,并分发到两台靶机中。目标用户是 deploy,初始密码是 deploy123。 我们采用前文提到的 ed25519 算法:
问题:现代 SSH 推荐使用的更短、更快、更安全的椭圆曲线算法名称是什么?
验证免密登录(请注意我们用了 -p 参数指定了 Docker 映射出的端口):
ssh -i ~/.ssh/atsb_ed25519 -p 2221 deploy@127.0.0.1 "hostname && whoami"
ssh -i ~/.ssh/atsb_ed25519 -p 2222 deploy@127.0.0.1 "hostname && whoami"六、 准备目标清单:hosts.conf
工业级脚本绝不把服务器 IP 甚至端口硬编码写死在代码里。我们新建一个 hosts.conf 文件,用来记录我们要发布的集群名单:
# alias host ssh_user ssh_port web_port identity_file
web01 127.0.0.1 deploy 2221 8081 ~/.ssh/atsb_ed25519
web02 127.0.0.1 deploy 2222 8082 ~/.ssh/atsb_ed25519七、 手把手拼装远程发布流水线脚本
接下来是重头戏。新建 remote_deploy_2048.sh 文件。 我们将把这个复杂的工业级脚本拆解为 3 块积木。请跟着学长的思路,一块块将其拼凑起来。
第一块积木:参数游标与配置提取
我们需要解析诸如 --target web01 这种动态参数。但用户如果传入了 10 个参数,我们该怎么遍历呢? 在 Bash 中,有一个神奇的游标向左漂移指令:shift。 我们先来看个极简的 3 行例子:
# 假设你执行了 ./test.sh A B C
echo "第一个参数是:$1" # 输出 A
shift # 参数全体向左移动一格!
echo "现在的第一个参数是:$1" # 输出 B!理解了吗?每一次调用 shift,参数队列就会像传送带一样向左滑动,原本的第二个参数就变成了第一个参数。利用这个特性,配合 while 循环,我们就能无限解析参数了!
问题:在 Bash 中,将所有传入参数向左侧偏移一位的核心游标指令是什么?
命令行输入:
./script.sh --target web01 --port 8080$1
--target
$2
web01
$3
--port
$4
8080
$5
空 (Empty)
while [ "$#" -gt 0 ]; do
case "$1" in
--target) TARGET="$2"; shift 2 ;;
--port) PORT="$2"; shift 2 ;;
esac
done
TARGET=""
PORT=""
剩余参数 $#: 4
在 Shell 中处理多个参数时,直接使用 $1, $2 容易错位。点击“下一步”看标准解析逻辑是如何工作的。
代码原理解释:
while [ $# -gt 0 ]:只要参数个数大于 0 就一直循环。case "$1" in --target):检查第一个参数是不是--target。TARGET="$2":如果是,就把紧跟在后面的第二个参数(即web01)赋值给变量。- 接着调用游标漂移指令把前两个已经处理完的参数从队列里踢走。非常优雅!
- 在参数解析完毕后,我们利用
awk文本处理神器,去刚才写的hosts.conf里,把对应的主机地址、登录账号、SSH 端口、Web 端口和私钥路径抓取到变量里。
这一块在完整脚本中负责 7 件事:
| 小块 | 关键变量或语句 | 功能说明 |
|---|---|---|
| 默认值 | TARGET=""、CONFIG="hosts.conf"、SOURCE_DIR="./2048"、LOG_DIR="logs" | 先给脚本一套默认运行环境,用户没有指定 --config 或 --source 时也有合理值。 |
| 帮助信息 | usage() | 用户参数写错时,直接打印正确用法,避免只抛出一串看不懂的 Shell 错误。 |
| 参数循环 | while [ $# -gt 0 ] | 用“剩余参数个数”作为循环条件,避免 set -u 下直接访问不存在的 $1 导致脚本崩溃。 |
| 分支识别 | case "$1" in | 判断当前参数是 --target、--config、--source 还是未知参数。 |
| 参数推进 | shift 2 | 每处理完一组“选项 + 值”,就把它们从参数队列里移走,让下一组选项成为新的 $1。 |
| 前置检查 | if [ ! -f "$CONFIG" ]、if [ ! -f "${SOURCE_DIR}/index.html" ] | 在真正连接远端前先确认配置文件和 2048 首页存在,错误越早暴露越好排查。 |
| 目标读取 | awk -v target="$TARGET" | 从 hosts.conf 中找到目标行,把 web01 对应的主机、端口、用户、私钥路径拆成变量。 |
随后脚本会把 ~/.ssh/atsb_ed25519 展开成真实的 $HOME/.ssh/atsb_ed25519,再组装 SSH_OPTS。这样后面所有 ssh、rsync 都使用同一套端口、私钥和主机指纹策略,不会出现不同命令参数写法不一致的问题。
请将组件展示的代码块复制进你的 remote_deploy_2048.sh 开头。
第二块积木:降维同步模块
拿到端口和密钥路径后,我们要把代码推送过去。目标 Docker 镜像已经预装 rsync,控制端如果也安装了 rsync,脚本就会使用包含 -avz 和 --delete 的黄金参数组合;如果控制端暂时没有这个工具,就退回使用古老的全量覆盖命令 scp 作为兜底策略。
问题:Linux 中基于 SSH 的古老的、全量覆盖的远程安全复制命令是什么?
代码原理解释:
ssh "${SSH_OPTS[@]}" "$REMOTE" "rm -rf '$REMOTE_TMP' && mkdir -p '$REMOTE_TMP'":先在远端清理并创建临时目录,确保本次同步从干净状态开始。if command -v rsync:在控制端检测是否存在rsync命令。如果存在,就走增量同步路径。rsync -avz --delete:-a保留文件属性,-v显示过程,-z压缩传输,--delete删除远端多余旧文件,让远端临时目录和本地2048/保持一致。-e "ssh ...":告诉rsync底层还是走 SSH 通道,并且沿用私钥、端口和主机指纹策略。| tee -a "$LOG_FILE":把同步过程同时打印到屏幕和日志文件里,方便学生截图,也方便失败后回看。else scp ...:如果控制端没有rsync,脚本仍能使用scp完成全量上传。注意scp指定端口使用大写-P,而ssh指定端口使用小写-p。
这一块只负责“把本地素材送到远端临时目录”,不会直接改 Nginx 网站目录。这样做的好处是:传输失败时不会破坏线上目录,真正替换网站文件要等第三块远程部署逻辑确认无误后再执行。
请将这段组件展示的代码追加到你的脚本中。
第三块积木:远程触发执行模块
最后一步,代码传过去了,但这并不意味着网站跑起来了。我们需要在远程主机上安装并配置 Nginx。 如何用本地脚本控制远端?使用 ssh <<'EOF' 语法,这相当于我们用 SSH 打开了一个远程通道,然后把一大段代码当作炮弹打过去执行。
传统 Ubuntu 服务器通常会用 systemctl reload nginx 管理后台服务,但 Docker 靶机默认不运行 systemd。所以本脚本采用容器里更直接的 Nginx 控制方式:如果 Nginx 已经运行,就执行 nginx -s reload 平滑重载配置;如果还没有运行,就直接启动 nginx。
问题:本脚本中用于让已运行的 Nginx 平滑重载配置的命令是什么?
代码原理解释:
ssh "${SSH_OPTS[@]}" "$REMOTE" ... bash -s <<'REMOTE_SCRIPT':这行长长的代码就是“发射大炮”,将<<'REMOTE_SCRIPT'之间的所有代码扔给远端服务器去执行。REMOTE_TMP、REMOTE_WEB_ROOT、WEB_PORT、TARGET:这些变量由控制端传给远端,用来告诉远端“素材在哪里、网站放到哪里、监听哪个端口、当前是哪台机器”。set -euo pipefail:远端脚本也开启防御模式,任何关键步骤失败都立即停止,避免明明失败却继续打印成功。if [ ! -f "${REMOTE_TMP}/index.html" ]:确认远端临时目录中真的有 2048 首页。没有首页就不继续覆盖网站目录。command -v nginx:目标机如果还没有 Nginx,就先安装。我们的 Dockerfile 已经预装 Nginx,这一步在正常课堂环境中会直接跳过。BACKUP_DIR=...:如果旧的网站目录存在,先复制成带时间戳的备份目录。这就是任务卡里“简单回滚提示”的来源。sudo rm -rf "${REMOTE_WEB_ROOT:?}/"*:清理旧页面文件。这里使用${REMOTE_WEB_ROOT:?}是防御性写法,如果变量为空,Shell 会直接报错,避免误删系统目录。sudo cp -a "${REMOTE_TMP}/." "$REMOTE_WEB_ROOT/":把本次上传的 2048 素材从远端临时目录复制到 Nginx 网站目录。sudo tee "/etc/nginx/sites-available/2048-${TARGET}.conf":用 Here Document 生成 Nginx 站点配置,TARGET让web01、web02的配置文件互不冲突。sudo ln -sfn ... sites-enabled ...:启用站点配置。-sfn表示创建或覆盖软链接,重复执行也不会报错。sudo nginx -t:在真正重载前做语法检查,这是远程配置类脚本必须保留的安全门。pgrep -x nginx与nginx -s reload:如果 Nginx 已经在运行,就平滑重载;如果没有运行,就启动它。curl -I --max-time 5:在目标服务器内部访问本机端口,确认 Nginx 已经能返回 HTTP 响应。回滚提示:最后输出备份目录位置,学生能在日志里看到失败后该如何恢复旧版本。
将最后一段代码追加到脚本末尾。至此,你的脚本已经拼凑成了一个完整的自动发布流水线!赋予它执行权限:
chmod +x remote_deploy_2048.sh八、 执行发布与验收
现在,我们准备 2048 源码素材。请大家统一使用 wget 从 Github 官方主干分支拉取最纯净的代码:
rm -rf /tmp/2048-master /tmp/2048.zip ./2048
wget -O /tmp/2048.zip https://github.com/gabrielecirulli/2048/archive/refs/heads/master.zip
unzip /tmp/2048.zip -d /tmp
mkdir -p ./2048
cp -a /tmp/2048-master/. ./2048/执行发布!依次推送到我们在 hosts.conf 里配置好的两台机器:
./remote_deploy_2048.sh --target web01 --config hosts.conf --source ./2048
./remote_deploy_2048.sh --target web02 --config hosts.conf --source ./2048打开浏览器分别访问:
http://127.0.0.1:8081http://127.0.0.1:8082如果两个端口都能看到游戏,说明你已经成功翻越了两座大山,掌握了分布式发布技术!
九、 故障排查与深度探索任务
| 现象 | 底层原因深度剖析 | 排查与处理方式 |
|---|---|---|
docker compose up 报错端口占用 | 宿主机已有程序占用了 2221/2222/8081/8082。 | 修改 docker-compose.yml 左侧宿主机端口,并同步修改 hosts.conf。 |
| 发布后浏览器无法访问 | hosts.conf 的 web_port 与 Docker 端口映射不一致,或 Nginx 配置未通过。 | 查看 logs/ 中的 nginx -t 和 curl 输出。 |
| 页面没有更新 | 本地素材目录写错,或远程临时目录同步失败。 | 查看日志中的输出,确认变更文件被成功同步。 |
十、 进阶验收标准
| 验收维度 | 达标要求与技术深度指标 |
|---|---|
| 安全与架构理论 | 能够清晰口述 Diffie-Hellman 原理及 MITM 威胁,对比差集同步与全量覆盖的底层算法差异。 |
| 容器集群认知 | 能够看懂 docker-compose.yml 中的端口映射规则,解释宿主机如何触达容器内网。 |
| 脚本模块拆分能力 | 能够指着脚本代码,清晰分辨出哪一部分是参数解析,哪一部分是远程推送,哪一部分是远程执行触发。 |
| 任务提交材料 | 将 remote_deploy_2048.sh、hosts.conf、logs/ 部署日志和两个远程访问截图一起压缩后上传。 |
