SSH
本文范围
这篇讲 SSH 在服务器运维里的落地用法:怎么登录、怎么配密钥、怎么识别主机指纹、怎么传文件、怎么做有限的多机器操作、怎么走跳板机,以及失败时怎么排查。重点不是背加密算法,而是把远程入口做成安全、可审计、可恢复的工程边界。
本文会覆盖:
- OpenSSH client、sshd、系统用户、用户密钥、主机密钥、
authorized_keys和known_hosts的关系。 - 密码登录、密钥免密、ssh-agent、私钥口令、主机指纹和
ssh_config。 scp、sftp、rsync、跳板机、多机器执行和发布脚本里的 SSH 模板。- 中国大陆公网、企业内网、离线环境里的访问路径、工具包和安全策略。
- SSH 安全加固、sudo 权限边界、日志审计和常见连接失败排查。
本文不展开 VPN、堡垒机产品选型、零信任网关、Ansible 体系,也不讲 Linux 用户体系的全部细节。涉及大规模批量变更时,SSH 可以作为底层通道,但不应该长期承担完整配置管理平台的职责。
前置条件
你需要一台可登录的 Linux 服务器和一台客户端机器。客户端可以是 Linux、macOS、Windows PowerShell 或 Git Bash;服务端建议使用 OpenSSH Server。生产环境操作前,要保留至少一个已验证的登录会话,避免改错 sshd_config 后把自己锁在门外。
先确认这些命令和服务:
ssh -V
command -v ssh scp sftp rsync ssh-keygen ssh-agent ssh-add ssh-keyscan
sudo sshd -t 2>/dev/null || true
systemctl status sshd --no-pager 2>/dev/null || systemctl status ssh --no-pager 2>/dev/null || true本文示例里的用户名、主机名、端口和跳板地址都是占位。正式环境不要把明文密码、私钥内容、真实堡垒机地址或客户内网 IP 写进文章、脚本和 CI 日志。
先说清楚:SSH 在现场到底干什么
服务器交付到你手里以后,第一件事通常不是装应用,而是先连上去。能不能登录、能不能传文件、能不能远程执行命令、出问题能不能定位,基本都绕不开 SSH。
这里先不要把 SSH 想得太抽象。日常运维里它主要做四件事:登录服务器、把文件发过去、在多台机器上执行命令、守住远程访问的安全边界。
这篇文章就按真实现场来走,默认你会遇到几类场景:
- 新服务器交付后,需要从本地或跳板机登录上去。
- 应用发布时,需要把 jar、前端 dist、配置文件或脚本分发到一台或多台机器。
- 数据库、中间件、应用节点之间需要做有限的机器间操作。
- 企业内网、离线环境或安全整改场景下,需要把 SSH 的权限、端口、密钥和日志治理清楚。
这里不展开完整 Linux 教程,不展开 VPN,也不讲堡垒机产品选型。Ansible 也不是这篇的主角,只会在“多机器操作”那里说清楚:什么时候继续用 SSH 脚本就够了,什么时候应该升级到专门的自动化工具。
命令示例使用这些占位:
# 客户端用户
local-user
# 服务器用户
deploy
# 服务器地址
10.0.0.10
server-a
# 非默认 SSH 端口
2222实际操作时,把这些占位替换成你的主机名、内网 IP、用户名和端口就行。这里要注意,生产环境不要把明文密码写进文档、脚本或 CI 日志里;如果为了临时初始化必须用密码,也要尽快切到密钥登录。
先把 SSH 的对象关系说清楚
正式敲命令前,先把几个对象分清楚。很多人排查 SSH 失败时会绕晕,根本原因就是把“用户密钥”和“主机密钥”混在了一起。
SSH 日常操作围绕五个对象展开:
- SSH client:你本地执行的
ssh、scp、sftp命令。 - SSH server:服务器上的
sshd服务,负责接受连接、认证用户、创建会话。 - 系统用户:SSH 登录后最终落到 Linux 用户,例如
deploy、app、admin。 - 用户密钥:客户端持有私钥,服务端保存公钥到
authorized_keys。 - 主机密钥:服务端用来证明“我就是这台服务器”的密钥,客户端记录在
known_hosts。
很多 SSH 问题都来自把这几类密钥混在一起:
- 用户密钥解决“我是谁,能不能登录这个账号”。
- 主机密钥解决“对面的服务器是不是我以为的那台机器”。
authorized_keys在服务端用户家目录里。known_hosts在客户端用户家目录里。sshd_config管服务端行为。ssh_config或~/.ssh/config管客户端行为。
理解这层关系后,排查会清楚很多。比如 Permission denied (publickey) 大概率是用户身份没通过;REMOTE HOST IDENTIFICATION HAS CHANGED 则是客户端不信任这台服务器的身份变化,这两个问题不是一回事。
SSH 登录链路中的对象关系与密钥边界
把用户密钥和主机密钥分开看,才能判断问题是“客户端身份不对”,还是“服务端身份不可信”。
排查免密失败时按这个顺序来:先确认客户端到底用了哪把私钥,再看目标系统用户的 authorized_keys,最后看 sshd 策略有没有禁止公钥登录。主机密钥异常则不要急着删 known_hosts,先确认服务器有没有重装、换 IP 或走了新的跳板链路。
安装与服务验证
大多数 Linux 服务器已经安装 OpenSSH Server,但新装的最小化系统、离线镜像、云厂商精简镜像不一定完整。所以第一步不是直接连,而是先确认三样东西:客户端命令有没有、服务端 sshd 有没有启动、后面要用的 rsync 有没有。
Ubuntu / Debian:
sudo apt update
sudo apt install -y openssh-client openssh-server rsync
ssh -V
sudo systemctl enable --now ssh
sudo systemctl status ssh --no-pager这几行做了三件事:安装客户端和服务端、查看客户端版本、启动并检查服务状态。执行完以后,systemctl status 里应该能看到 active (running)。如果这里不是 running,先不要继续折腾客户端,直接看服务端日志。
RHEL / CentOS Stream / Rocky Linux / AlmaLinux:
sudo dnf install -y openssh-clients openssh-server rsync
ssh -V
sudo systemctl enable --now sshd
sudo systemctl status sshd --no-pagerRHEL 系服务名一般是 sshd,Ubuntu 系常见是 ssh。排查时不要照抄服务名,先用 systemctl status sshd 和 systemctl status ssh 分别看一下,哪个存在用哪个。
CentOS 7 这类旧系统可能仍使用 yum:
sudo yum install -y openssh-clients openssh-server rsync
sudo systemctl enable --now sshd检查 SSH 是否监听端口:
sudo ss -lntp | grep -E ':22|:2222'这里看的是“服务有没有在本机端口上等连接”。正常情况下能看到 sshd 监听在 0.0.0.0:22、[::]:22 或你配置的自定义端口。如果什么都没有,说明服务没起来,或者端口改了但你还按 22 查。
从另一台机器检查端口连通:
nc -vz 10.0.0.10 22
nc -vz 10.0.0.10 2222这一步看的是“从你的机器到服务器端口能不能走通”。如果 ss 正常但 nc 超时,一般就是安全组、防火墙、路由、跳板机访问策略的问题。
如果没有 nc,临时用 telnet 也可以做连通性判断:
telnet 10.0.0.10 22服务端日志入口:
# Ubuntu / Debian
sudo journalctl -u ssh -n 100 --no-pager
sudo tail -f /var/log/auth.log
# RHEL / CentOS 系
sudo journalctl -u sshd -n 100 --no-pager
sudo tail -f /var/log/secure改 SSH 服务端配置前,先检查语法:
sudo sshd -t这条命令非常重要。远程改 sshd_config 时,如果配置写错再重启,最坏结果是把自己锁在服务器外面。一般做法是保留一个已登录窗口,另开一个窗口测试新连接,确认没问题再退出旧窗口。
第一条登录链路
先从最简单的登录开始。假设服务器地址是 10.0.0.10,登录用户是 deploy,直接执行:
ssh deploy@10.0.0.10第一次连接时,终端会让你确认主机指纹。确认前最好和资产清单、云控制台或运维同事给出的指纹核对一下。输入 yes 后,如果要求输入密码,说明网络和账号至少已经走到认证这一步了。
指定端口:
ssh -p 2222 deploy@10.0.0.10这里的 -p 是 SSH 端口,不是服务器业务端口。很多生产环境会把 SSH 从 22 改到其他端口,命令里就要跟着改。失败时先用 nc -vz 10.0.0.10 2222 看端口通不通,不要上来就怀疑密码错了。
指定用户的另一种写法:
ssh -l deploy -p 2222 10.0.0.10远程执行一条命令,不进入交互式 shell:
ssh deploy@10.0.0.10 'hostname && uptime && df -h'这个用法在巡检和发布脚本里很常见。正常输出应该能看到主机名、运行时间、磁盘使用率。这里要注意,远程命令最好用引号包起来,否则有些变量和通配符会先在本地展开,结果和你想的不一样。
查看远程服务状态:
ssh deploy@10.0.0.10 'systemctl status nginx --no-pager'--no-pager 是为了让输出直接打印出来,不进入分页器。脚本里尤其建议加上它,否则命令可能卡在交互界面里。
远程执行需要 sudo 的命令时,优先使用非交互方式,并让 sudo 失败得明确:
ssh deploy@10.0.0.10 'sudo -n systemctl restart app.service'sudo -n 表示不交互输入密码。它适合部署脚本:如果权限没有配置好,脚本应该立即失败,而不是卡在等待密码输入。看到 sudo: a password is required,就说明 sudoers 没配好,不是 SSH 连接问题。
只有远程命令确实需要 TTY 时,才使用 -t 或 -tt:
ssh -tt deploy@10.0.0.10 'sudo systemctl restart app.service'生产脚本里不要默认到处加 -tt。很多自动化命令不需要伪终端,强行分配反而会让日志、退出码和管道行为变复杂。
密钥免密登录
密码登录适合初始化和应急,不适合长期自动化。真正要发布、备份、批量巡检,就要配置密钥登录。密钥登录可以理解成:本地拿私钥,服务器保存公钥,登录时双方做一次校验,校验通过就不用再输密码。
生成 Ed25519 密钥:
ssh-keygen -t ed25519 -C "deploy@ops-20260709" -f ~/.ssh/id_ed25519_deploy执行后会提示你输入 passphrase。个人日常使用建议设置;如果是 CI/CD 机器使用的自动化密钥,通常没法交互输入,那就要靠限制账号权限、限制来源 IP、定期轮换来兜底。
说明:
-t ed25519:生成 Ed25519 密钥,日常新环境优先选它。-C:写入注释,方便以后知道这把钥匙给谁、做什么。-f:指定文件名,避免所有场景都混用默认id_ed25519。
如果必须兼容很老的系统,再考虑 RSA:
ssh-keygen -t rsa -b 4096 -C "deploy@legacy-20260709" -f ~/.ssh/id_rsa_legacy这里不要为了“看起来熟悉”就默认 rsa。新环境优先 Ed25519,老系统连接不上时再退回 RSA,并把原因写进运维记录里。
密钥生成后会得到两类文件:
~/.ssh/id_ed25519_deploy # 私钥,只能自己保管
~/.ssh/id_ed25519_deploy.pub # 公钥,可以放到服务器这里要注意,私钥不要发给别人,也不要上传到服务器上到处复用。真正要放到服务器的是 .pub 结尾的公钥内容。
分发公钥:
ssh-copy-id -i ~/.ssh/id_ed25519_deploy.pub deploy@10.0.0.10执行成功后,它会把公钥追加到远程 deploy 用户的 ~/.ssh/authorized_keys。如果这里让你输入密码,是正常的,因为这是最后一次用密码完成初始化。
指定 SSH 端口:
ssh-copy-id -i ~/.ssh/id_ed25519_deploy.pub -p 2222 deploy@10.0.0.10没有 ssh-copy-id 时,可以手工追加:
cat ~/.ssh/id_ed25519_deploy.pub | ssh -p 2222 deploy@10.0.0.10 \
'umask 077; mkdir -p ~/.ssh; cat >> ~/.ssh/authorized_keys'这条命令做了两件事:远程没有 .ssh 目录就新建,然后把本地公钥追加进去。umask 077 是为了让新建文件默认权限更收紧,避免后面因为权限太宽导致 SSH 拒绝读取。
修正权限:
# 客户端
chmod 700 ~/.ssh
chmod 600 ~/.ssh/id_ed25519_deploy
chmod 644 ~/.ssh/id_ed25519_deploy.pub
# 服务端,登录到 deploy 用户后执行
chmod 700 ~/.ssh
chmod 600 ~/.ssh/authorized_keys权限是免密登录里最常见的坑。一般需要记住三条:.ssh 目录是 700,私钥是 600,authorized_keys 是 600。如果服务端日志里看到 bad ownership or modes,优先查这里。
验证只走公钥,不回退密码:
ssh -i ~/.ssh/id_ed25519_deploy \
-o PreferredAuthentications=publickey \
-o PasswordAuthentication=no \
deploy@10.0.0.10 'whoami && hostname'如果这条能成功,并且输出的是 deploy 和目标主机名,才说明免密链路真的可用于自动化。如果失败,它不会退回密码输入,排查会更干净。
私钥口令和 ssh-agent
私钥文件一旦泄露,攻击者可能拿它登录服务器。因此,重要环境的私钥建议设置 passphrase。问题是每次输入口令会影响效率,这时可以使用 ssh-agent 缓存解锁后的私钥。
启动 agent 并添加私钥:
eval "$(ssh-agent -s)"
ssh-add ~/.ssh/id_ed25519_deploy
ssh-add -l清理 agent 中的密钥:
ssh-add -D生产建议:
- 个人交互式密钥建议设置 passphrase。
- CI/CD 自动化密钥如果不能设置 passphrase,必须限制用途、限制来源、限制账号权限,并定期轮换。
- 不要把同一把私钥同时用于个人登录、生产部署、备份同步和第三方平台。
- 离职、权限调整、机器报废时,要清理对应用户的
authorized_keys。
known_hosts 和主机指纹
第一次连接新服务器,经常会看到类似提示:
The authenticity of host '10.0.0.10 (10.0.0.10)' can't be established.
ED25519 key fingerprint is SHA256:...
Are you sure you want to continue connecting (yes/no/[fingerprint])?这不是多余提示,而是 SSH 在问:你是否确认对面的主机身份。确认后,客户端会把主机密钥写入:
~/.ssh/known_hosts以后再连接同一台服务器,SSH 会拿服务器当前主机密钥和本地记录做对比。对得上就继续,对不上就报警。这个机制就是为了防止你连到一台“看起来 IP 一样,但身份不一样”的机器。
查看已记录的主机:
ssh-keygen -F 10.0.0.10
ssh-keygen -F '[10.0.0.10]:2222'如果你连接的是非 22 端口,known_hosts 里通常会按 [host]:port 记录,所以排查时端口也要带上。
删除旧主机记录:
ssh-keygen -R 10.0.0.10
ssh-keygen -R '[10.0.0.10]:2222'查看服务器主机密钥指纹。服务端执行:
sudo ssh-keygen -lf /etc/ssh/ssh_host_ed25519_key.pub这条一般在服务器控制台、跳板机或已有可信会话里执行。把这里看到的指纹和客户端首次连接提示里的指纹对上,再输入 yes。
客户端预扫描主机密钥:
ssh-keyscan -p 2222 -t ed25519 10.0.0.10如果出现这个错误,不要直接删记录:
WARNING: REMOTE HOST IDENTIFICATION HAS CHANGED!先判断原因:
- 服务器是否重装过系统。
- IP 是否被换给了另一台机器。
- 是否经过了新的负载均衡、NAT 或跳板链路。
- 是否可能存在中间人攻击。
确认是计划内变更后,再执行 ssh-keygen -R 清理旧记录并重新连接。自动化脚本可以在受控环境使用:
ssh -o StrictHostKeyChecking=accept-new deploy@10.0.0.10 'hostname'accept-new 的含义是:新主机第一次自动接受,已有主机发生变化仍然报错。这个比直接关掉校验安全得多。
不要长期使用:
ssh -o StrictHostKeyChecking=no ...这会绕过主机身份校验,把重要安全边界拆掉。
scp:简单文件复制
如果只是临时传一个 jar、一个配置文件、一个日志包,用 scp 最顺手。它的思路很简单:本地路径、远程用户、远程地址、远程路径。
上传单个文件:
scp -P 2222 ./app.jar deploy@10.0.0.10:/opt/app/执行成功后不会有太多花哨输出,重点看进度条是否到 100%,最后有没有报错。传完以后建议立刻到远程确认文件大小和时间:
ssh -p 2222 deploy@10.0.0.10 'ls -lh /opt/app/app.jar'下载远程日志:
scp -P 2222 deploy@10.0.0.10:/var/log/app/app.log ./app.log这个适合临时把线上日志拉回本地分析。生产上如果日志量很大,不建议整目录硬拉,先在远程用 grep、tail、journalctl 缩小范围。
递归上传目录:
scp -P 2222 -r ./dist deploy@10.0.0.10:/opt/web/这里要注意,scp -r ./dist 会把 dist 目录本身复制过去。如果你想把 dist 里面的内容铺到 /opt/web/,用 rsync ./dist/ 更清楚。
指定私钥:
scp -i ~/.ssh/id_ed25519_deploy -P 2222 ./app.jar deploy@10.0.0.10:/opt/app/从 OpenSSH 9.0 开始,scp 默认使用 SFTP 协议传输。多数场景这是好事,因为文件名处理更可预测。但如果你连接的是老服务器、受限设备,或者服务端禁用了 SFTP 子系统,老脚本可能突然失败。
遇到这类兼容问题,优先修复服务端 SFTP 子系统:
grep -i '^Subsystem' /etc/ssh/sshd_config /etc/ssh/sshd_config.d/*.conf 2>/dev/null必要时才临时使用旧协议兼容选项:
scp -O ./app.jar deploy@10.0.0.10:/opt/app/生产脚本里,如果是目录级增量分发,优先考虑 rsync,而不是反复 scp -r。
sftp:交互式文件管理
如果你想像操作 FTP 一样先进去看看目录,再上传或下载文件,用 sftp 更合适。它通过 SSH 加密通道工作,不是老式明文 FTP。
连接:
sftp -P 2222 deploy@10.0.0.10进去以后提示符会变成 sftp>。这时你是在一个文件传输会话里,不是在远程 shell 里,不能执行 systemctl 这类系统命令。
常用交互命令:
pwd # 远程当前目录
lpwd # 本地当前目录
ls # 远程列表
lls # 本地列表
cd /opt/app # 切换远程目录
lcd ./dist # 切换本地目录
put app.jar
get app.log
mkdir backup
rm old.jar
exit批处理:
cat > sftp.batch <<'EOF'
cd /opt/app
put app.jar
ls -l
EOF
sftp -P 2222 -b sftp.batch deploy@10.0.0.10批处理适合“固定上传几个文件”的场景。执行完看最后的退出码和 ls -l 输出,确认文件确实到了目标目录。
如果企业只允许 SFTP 文件上传,不允许 shell 登录,可以在服务端做受限配置。这个属于更细的账号隔离话题,本文只给方向:使用专用系统用户、限制目录、限制命令、限制来源 IP,不要让上传账号同时拥有生产 shell 操作权限。
rsync:增量分发和备份同步
项目发布、静态资源同步、日志拉取、备份目录同步,一般优先考虑 rsync。它比 scp -r 更适合目录级分发,因为它会比较两边差异,只传需要变化的部分,还能先演练。
通过 SSH 推送目录:
rsync -avzP -e "ssh -p 2222 -i ~/.ssh/id_ed25519_deploy" \
./dist/ deploy@10.0.0.10:/opt/web/这里几个参数很常用:-a 保留目录结构和常见属性,-v 打印过程,-z 压缩传输,-P 显示进度并支持断点续传。-e 后面指定走哪个 SSH 命令,比如端口和私钥。
通过 SSH 拉取目录:
rsync -avzP -e "ssh -p 2222" \
deploy@10.0.0.10:/var/log/app/ ./logs/先演练,不实际改动:
rsync -avzP --dry-run --itemize-changes \
-e "ssh -p 2222" ./dist/ deploy@10.0.0.10:/opt/web/正式同步前建议先跑这条。--dry-run 不会真的改远程文件,--itemize-changes 会告诉你哪些文件准备新增、修改、删除。看明白输出以后,再去掉 --dry-run。
排除文件:
rsync -avzP --exclude='.git/' --exclude='node_modules/' \
-e "ssh -p 2222" ./dist/ deploy@10.0.0.10:/opt/web/同步删除目标端多余文件:
rsync -avzP --delete \
-e "ssh -p 2222" ./dist/ deploy@10.0.0.10:/opt/web/--delete 是高风险参数。它会把目标端有、源端没有的文件删掉。生产使用前必须先 --dry-run,并确认源目录末尾斜杠是否符合预期。
注意源路径末尾斜杠:
# 把 dist 目录本身复制到 /opt/web/dist
rsync -av ./dist deploy@10.0.0.10:/opt/web/
# 把 dist 目录里的内容复制到 /opt/web
rsync -av ./dist/ deploy@10.0.0.10:/opt/web/这个区别是 rsync 最常见的部署事故来源之一。
rsync 通过 SSH 工作时,两端都要安装 rsync。如果远程没有安装,常见报错是找不到远程 rsync 命令:
ssh deploy@10.0.0.10 'command -v rsync && rsync --version'如果没有输出路径,就先在目标机安装 rsync,不要只在本机装。
rsync daemon 和 873 端口
rsync daemon 和前面的 rsync -e ssh 不是一回事。它适合内网备份、镜像同步、大文件集中拉取这类场景。它不通过 SSH 登录,而是连接远程 rsync 服务,典型端口是 873。
拉取模块:
rsync -av backup@10.0.0.20::mysql /backup/mysql/注意这里是双冒号 ::,表示连接 rsync daemon 的模块,不是 SSH 路径。执行成功后,客户端会把服务端 mysql 模块里的文件同步到本地 /backup/mysql/。
等价 URL 写法:
rsync -av rsync://backup@10.0.0.20/mysql /backup/mysql/服务端配置通常包含这些控制点:
uid = nobody
gid = nobody
use chroot = no
max connections = 10
log file = /var/log/rsyncd.log
pid file = /var/run/rsyncd.pid
lock file = /var/run/rsync.lock
port = 873
[mysql]
path = /data/backup/mysql
read only = yes
auth users = backup
secrets file = /etc/rsyncd.secrets
hosts allow = 10.0.0.0/24
hosts deny = *
list = false密码文件权限必须收紧:
sudo chmod 600 /etc/rsyncd.secrets如果权限太宽,rsync daemon 可能拒绝读取密码文件。这里和 SSH 私钥权限类似,都是“太开放就不让用”。
客户端可以通过环境变量临时提供密码:
export RSYNC_PASSWORD='change-me'
rsync -av backup@10.0.0.20::mysql /backup/mysql/
unset RSYNC_PASSWORD生产建议:
rsync daemon只放在可信内网,不直接暴露公网。- 使用
hosts allow、防火墙和安全组限制来源。 - 只读备份模块和可写分发模块分开。
- 日志要保留,方便追踪谁同步了什么。
- 对重要目录启用恢复演练,不要只验证同步成功。
ssh config:把长命令变成可维护配置
当 SSH 命令开始变长,就不要每次都复制一大串参数了。一般项目机器超过两三台,就建议写 ~/.ssh/config,把主机名、用户、端口、私钥都固定下来。
创建配置:
mkdir -p ~/.ssh
chmod 700 ~/.ssh
touch ~/.ssh/config
chmod 600 ~/.ssh/config如果没有这个文件就新建;如果已经存在,就在后面追加主机配置。权限建议设为 600,避免里面的主机信息和私钥路径被其他用户读取。
示例:
Host prod-app-1
HostName 10.0.0.10
User deploy
Port 2222
IdentityFile ~/.ssh/id_ed25519_deploy
IdentitiesOnly yes
ServerAliveInterval 30
ServerAliveCountMax 3
Host prod-app-2
HostName 10.0.0.11
User deploy
Port 2222
IdentityFile ~/.ssh/id_ed25519_deploy
IdentitiesOnly yes以后可以直接:
ssh prod-app-1
scp ./app.jar prod-app-1:/opt/app/
rsync -avzP ./dist/ prod-app-1:/opt/web/这样写的好处很直接:脚本里只出现 prod-app-1,不会到处散落 IP、端口和私钥路径。服务器换端口、换 IP 时,改一处配置就行。
连接复用可以减少批量操作时的握手开销:
Host prod-*
ControlMaster auto
ControlPath ~/.ssh/cm-%C
ControlPersist 10m这个配置适合短时间内频繁连接同一批机器的场景,比如批量巡检或连续分发文件。它会复用已有连接,速度会明显舒服一些。
如果遇到 Too many authentication failures,通常是 agent 里加载了太多钥匙,服务端试几次就拒绝了。给目标主机加:
Host prod-app-1
HostName 10.0.0.10
User deploy
IdentityFile ~/.ssh/id_ed25519_deploy
IdentitiesOnly yes多机器执行
有时候只是三五台机器查个磁盘、重启个服务,直接上平台反而麻烦。小规模批量操作可以用 SSH 循环。先准备机器清单:
cat > hosts.txt <<'EOF'
prod-app-1
prod-app-2
prod-app-3
EOF串行执行:
while read -r host; do
[ -z "$host" ] && continue
echo "===== $host ====="
ssh -o BatchMode=yes "$host" 'hostname && uptime'
done < hosts.txt执行时应该能看到每台机器前面都有一行 ===== 主机名 =====,后面跟着对应输出。这样即使某台失败,也能知道是哪台机器出问题。
并发执行:
xargs -a hosts.txt -I{} -P 5 sh -c '
echo "===== $1 ====="
ssh -o BatchMode=yes "$1" "hostname && uptime"
' _ {}-P 5 表示最多 5 台并发。这里不要一上来就开很大,并发太高可能把跳板机、DNS、目标机器 SSH 连接数打满。生产上建议从 3 到 5 开始试。
BatchMode=yes 很适合脚本:如果不能免密登录,命令直接失败,不会卡住等待密码。
批量分发文件:
while read -r host; do
[ -z "$host" ] && continue
echo "sync to $host"
rsync -avzP --dry-run ./dist/ "$host:/opt/web/"
done < hosts.txt确认 --dry-run 输出后,再去掉演练参数:
while read -r host; do
[ -z "$host" ] && continue
echo "sync to $host"
rsync -avzP ./dist/ "$host:/opt/web/"
done < hosts.txt这里的顺序很重要:先演练,再执行。尤其同步静态资源、配置文件、脚本目录时,先看 --dry-run 输出能避免把错误目录同步到所有机器。
如果机器数超过十几台,或者操作需要幂等、回滚、变量分组、权限隔离,就不要继续堆 shell 循环了。这个边界应该升级到 Ansible、SaltStack、CI/CD 或企业自动化平台。
项目发布里的 SSH 模板
把 SSH 放进项目发布脚本时,不要只写“传文件 + 重启”。现场真正需要的是:构建前确认产物存在,覆盖前先备份,分发后再重启,重启后能验证,失败时脚本能停下来。
下面这个模板不追求复杂,重点是把一条发布链路串完整。
示例:
#!/usr/bin/env bash
set -euo pipefail
APP_NAME="demo-app"
APP_JAR="./target/demo-app.jar"
REMOTE_DIR="/opt/app/demo-app"
HOSTS=("prod-app-1" "prod-app-2")
test -f "$APP_JAR"
for host in "${HOSTS[@]}"; do
echo "===== deploy $host ====="
ssh -o BatchMode=yes "$host" "mkdir -p '$REMOTE_DIR/releases' '$REMOTE_DIR/backup'"
ssh "$host" "
if [ -f '$REMOTE_DIR/current/app.jar' ]; then
cp '$REMOTE_DIR/current/app.jar' '$REMOTE_DIR/backup/app.jar.$(date +%Y%m%d_%H%M%S)'
fi
"
rsync -avzP "$APP_JAR" "$host:$REMOTE_DIR/releases/app.jar"
ssh "$host" "
mkdir -p '$REMOTE_DIR/current'
cp '$REMOTE_DIR/releases/app.jar' '$REMOTE_DIR/current/app.jar'
sudo -n systemctl restart $APP_NAME
sleep 3
systemctl is-active $APP_NAME
"
done这段脚本跑完后,每台机器都会有一份新 jar、一份旧 jar 备份,并执行 systemctl is-active 验证服务状态。正常结果应该输出 active。如果某台机器重启失败,set -e 会让脚本停住,不会继续假装发布成功。
发布脚本要守住几个边界:
- 不在脚本里写明文密码。
- 不默认使用
root登录。 - 不把
rm -rf写成可变量扩展的危险路径。 - 不跳过备份和验证。
- 失败时要返回非 0 退出码,让流水线能感知失败。
- 执行高危命令前先打印目标主机和目标目录。
跳板机和内网主机
很多生产环境不能从办公网直接连应用服务器,只能先进入跳板机,再访问内网机器。这个时候不要手工先 ssh jump 再从跳板机继续 ssh,OpenSSH 原生就支持跳板。
临时命令:
ssh -J deploy@jump.example.com:2222 app@10.0.1.20-J 后面是跳板机,最后一个地址是内网目标机。执行成功后,你看到的 shell 应该是目标机的,不是跳板机的。
通过跳板机复制文件:
scp -J deploy@jump.example.com:2222 ./app.jar app@10.0.1.20:/opt/app/写进 ~/.ssh/config:
Host jump-prod
HostName jump.example.com
User deploy
Port 2222
IdentityFile ~/.ssh/id_ed25519_jump
IdentitiesOnly yes
Host prod-inner-*
User app
Port 22
IdentityFile ~/.ssh/id_ed25519_prod
IdentitiesOnly yes
ProxyJump jump-prod
Host prod-inner-app-1
HostName 10.0.1.20然后:
ssh prod-inner-app-1
rsync -avzP ./dist/ prod-inner-app-1:/opt/web/配置好以后,ssh、scp、rsync 都可以沿用这个别名。这里要注意,目标机的 known_hosts 记录仍然在客户端本地,不是让跳板机替你信任目标机。
P1 图解:跳板机与多机器分发链路
跳板机不是“先登录一台机器再手工操作下一台机器”,而是把办公网到内网目标机的 SSH 连接做成一条可审计、可复用的链路。批量发布时,客户端负责保管私钥和主机指纹,跳板机负责网络中转,目标机负责落盘、重启和本机验证。
这张图里最容易被忽略的是两条虚线。第一,known_hosts 是客户端对目标机身份的记录,不能因为经过跳板机就跳过主机指纹校验。第二,不要把 ForwardAgent yes 当成默认配置,否则跳板机一旦被拿下,攻击者可能借用你的 agent 继续横向登录。
链路调通以后,建议先做 dry-run 和配置展开验证:
ssh -G prod-inner-app-1 | grep -i proxyjump
ssh -vvv prod-inner-app-1 true
nc -vz 10.0.1.20 22
rsync -avzn --itemize-changes ./dist/ prod-inner-app-1:/opt/web/
ssh prod-inner-app-1 'systemctl is-active demo-app || journalctl -u demo-app -n 80 --no-pager'常见坑是:跳板机能连,但目标机安全组或本机防火墙没放行;~/.ssh/config 匹配顺序写错,导致用了错误私钥;首次连接时盲目接受指纹,没有和资产清单核对;批量脚本不记录每台机器的退出码,最后只知道“有几台失败”,不知道失败在哪里。
生产约束很简单:跳板机只做受控入口,不做长期私钥仓库;应用服务器 SSH 入口只对跳板网段开放;分发脚本要设置失败即停、日志落盘和目标主机回显;涉及 sudo 的命令优先用最小权限规则,不要把跳板链路变成“人人都能 root 到所有机器”的捷径。
跳板机场景的安全建议:
- 跳板机上不要长期保存能横向登录所有服务器的私钥。
- 不要随便开启
ForwardAgent yes。如果必须用 agent forwarding,限制目标和时长。 - 应用服务器只允许来自跳板机网段的 SSH 访问。
- 跳板机登录日志要集中采集。
- 跳板机账号和生产服务器账号要分层,不要所有地方都共用一个 root。
中国大陆、内网和离线环境
SSH 协议本身不依赖公网服务,但安装 OpenSSH、rsync、nc、系统补丁这些动作会受网络影响。很多现场不是命令不会敲,而是系统源不可达、包缺依赖、跳板策略没放开。
中国大陆公网环境:
- OpenSSH 和 rsync 优先使用发行版仓库或云厂商镜像源。
- 不要在正式文章或脚本里写死某个第三方镜像地址作为唯一方案。
- 安装前先确认源可用:
apt update、dnf makecache、yum makecache。 - 远程服务器安全组、防火墙、云防火墙要同时检查,不能只看 Linux 本机防火墙。
这里要注意,国内镜像源和云厂商仓库可用性会变化。正式落地时先验证当前源能不能用,不要把某个历史可用地址当成永久方案。
企业内网环境:
- 统一使用企业内部 apt/yum 仓库。
- 基线镜像里预装
openssh-server、openssh-clients、rsync、nc等基础工具。 - 使用统一的
sshd_config.d配置片段,避免每台机器手工改配置。 - 主机指纹应纳入资产交付清单,便于第一次连接时核对。
- 跳板机到业务网段的访问策略要按环境分组,例如测试、预发、生产分开。
离线环境:
Ubuntu 可以提前准备 deb 包:
apt download openssh-client openssh-server rsync这条是在联网机器上提前下载包,不是在离线服务器上执行。下载后还要把依赖和校验信息一起带进现场。
RHEL 系可以在联网构建机准备依赖包:
dnf install -y dnf-plugins-core
dnf download --resolve openssh-clients openssh-server rsync更稳的方式是做内网仓库或离线介质,而不是临时拷贝几个 rpm/deb。因为 OpenSSH 依赖系统库和加密库,系统版本不一致时,零散包很容易缺依赖。
Windows 客户端从 Windows 10 1809、Windows Server 2019 开始可以使用 OpenSSH 可选功能。离线 Windows Server 环境通常需要提前准备 Features on Demand 介质,而不是指望现场能访问外网安装。
离线环境的落地清单:
- 包文件和依赖版本提前校验。
- 每台目标机器验证
ssh -V、sshd -t、rsync --version。 - 防火墙策略提前固化,至少明确 22 或自定义端口是否开放。
- 跳板机和目标机之间先做
nc -vz连通性验证。 - 自动化脚本只使用已验证存在的命令,不临时从公网下载工具。
SSH 安全加固
安全加固不要只改端口。改端口可以减少低质量扫描噪音,但不能替代权限治理、密钥治理和日志审计。真正要做的是:谁能登录、用什么方式登录、能执行什么命令、日志能不能追。
建议把自定义配置放到独立片段中:
sudo mkdir -p /etc/ssh/sshd_config.d
sudo vi /etc/ssh/sshd_config.d/10-hardening.conf示例配置:
PermitRootLogin no
PubkeyAuthentication yes
PasswordAuthentication no
PermitEmptyPasswords no
MaxAuthTries 3
ClientAliveInterval 300
ClientAliveCountMax 2
X11Forwarding no
AllowTcpForwarding no
AllowUsers deploy appops这份配置的意思是:不允许 root 远程直登,允许公钥认证,关闭密码登录,限制最大尝试次数,并且只允许指定用户登录。第一次配置时不要一次性全改,建议先配普通用户密钥登录,验证成功后再关 root 和密码。
应用前先检查:
sudo sshd -t
sudo systemctl reload sshd || sudo systemctl reload sshsshd -t 没有输出通常就是语法通过;如果有错误,会直接指出哪一行有问题。先修配置,再 reload,不要硬重启。
如果系统不支持 reload,再用 restart,但务必保留当前已登录会话:
sudo systemctl restart sshd加固顺序建议:
- 创建普通运维用户,例如
deploy。 - 配置并验证密钥登录。
- 验证
deploy能通过受控 sudo 执行必要命令。 - 保留一个当前 SSH 会话。
- 禁止 root 远程登录。
- 禁止密码登录。
- 新开窗口测试登录、sudo、文件分发、服务重启。
- 确认日志正常后退出旧会话。
这个顺序看着啰嗦,但很实用。很多事故不是 SSH 不安全,而是改安全配置时把自己的运维入口关掉了。
如果安全扫描提示 SSH CBC 算法启用,先查看当前有效配置:
sudo sshd -T | grep -i '^ciphers'
ssh -Q cipherRHEL 系还要注意系统级加密策略。很多情况下不应该在 sshd_config 里硬写一串算法,而应按操作系统安全策略统一治理。只有在明确兼容边界后,才针对 SSH 服务单独配置 Ciphers、MACs、KexAlgorithms。
权限边界:用户、目录和 sudo
SSH 登录成功不代表部署权限设计正确。最常见的偷懒做法是所有机器都用 root 登录、所有脚本都用 root 执行,短期方便,长期风险很大。生产建议分三层:
- 登录用户:例如
deploy,负责上传文件、执行发布脚本。 - 运行用户:例如
app,负责运行应用进程。 - 管理权限:通过 sudo 精确授权,不直接给 root shell。
目录示例:
sudo mkdir -p /opt/app/demo-app
sudo chown -R deploy:deploy /opt/app/demo-appsystemd 服务可以用应用用户运行:
[Service]
User=app
Group=app
ExecStart=/usr/bin/java -jar /opt/app/demo-app/current/app.jarsudo 精确授权示例:
sudo visudo -f /etc/sudoers.d/deploy-demo-app写入:
deploy ALL=(root) NOPASSWD: /bin/systemctl restart demo-app.service, /bin/systemctl status demo-app.service, /bin/systemctl is-active demo-app.service验证:
ssh deploy@10.0.0.10 'sudo -n systemctl restart demo-app.service'这条能成功,说明 deploy 可以重启这个服务;如果换成别的命令失败,反而是符合预期的。sudo 权限就应该精确到任务,不应该变成无限制 root。
不要为了省事给:
deploy ALL=(ALL) NOPASSWD: ALL这等于把 deploy 变成无密码 root,自动化脚本一旦被改错或密钥泄露,影响会非常大。
常见故障排查
SSH 排障不要一上来就改配置。现场建议按四层顺序看:网络到没到、端口有没有服务、认证有没有通过、登录后权限够不够。前两层看 nc、ss、防火墙;认证问题看 ssh -vvv 和服务端认证日志;权限问题再看目录权限、sudo 和目标文件路径。
下面这张图把最常见的 SSH 报错放到一条决策链里。用它的好处是不会把 Permission denied 当成网络问题,也不会把主机指纹变化简单粗暴地 ssh-keygen -R 掉。
排障证据要两端一起看:客户端保留 ssh -vvv 的关键行,服务端保留 /var/log/auth.log、/var/log/secure 或 journalctl -u sshd 的认证日志。只看一端,很容易把私钥、用户、主机指纹和 sudo 权限混在一起。
连接超时
现象:
ssh: connect to host 10.0.0.10 port 22: Connection timed out常见原因:
- IP 不通或路由不通。
- 云安全组没放行。
- Linux 防火墙没放行。
- SSH 服务监听在其他端口。
- 只能通过跳板机访问。
排查:
ping 10.0.0.10
nc -vz 10.0.0.10 22
traceroute 10.0.0.10
sudo ss -lntp | grep ssh
sudo firewall-cmd --list-all
sudo ufw status verbose修复方向:先打通网络和端口,再看 SSH 认证。
连接被拒绝
现象:
ssh: connect to host 10.0.0.10 port 22: Connection refused说明 TCP 到了目标机器,但端口上没有服务或被主动拒绝。
排查:
sudo systemctl status sshd --no-pager
sudo systemctl status ssh --no-pager
sudo ss -lntp | grep ':22'
sudo sshd -t修复:
sudo systemctl enable --now sshd
# 或
sudo systemctl enable --now ssh执行后再跑一次 ss -lntp | grep ssh,确认端口真的监听起来,再从客户端重新 nc -vz 测。
公钥登录失败
现象:
Permission denied (publickey).客户端排查:
ssh -vvv -i ~/.ssh/id_ed25519_deploy deploy@10.0.0.10-vvv 输出很多,不要被吓到。重点看三行:读取了哪个 identity file、有没有 Offering public key、服务端有没有 Server accepts key。
重点看:
- 是否真的读取了你指定的私钥。
- 是否出现
Offering public key。 - 服务端是否接受该 key。
服务端排查:
whoami
ls -ld ~ ~/.ssh
ls -l ~/.ssh/authorized_keys
tail -f /var/log/auth.log
tail -f /var/log/secure修复:
chmod 700 ~/.ssh
chmod 600 ~/.ssh/authorized_keys
chown -R deploy:deploy ~/.ssh同时确认你登录的是正确用户。公钥放在 deploy 的 authorized_keys 里,就要用 deploy@host 登录,不能用 root@host 测。
私钥权限过宽
现象:
WARNING: UNPROTECTED PRIVATE KEY FILE!
Permissions 0644 for 'id_ed25519_deploy' are too open.修复:
chmod 600 ~/.ssh/id_ed25519_deploy
chmod 700 ~/.sshToo many authentication failures
现象:
Received disconnect from 10.0.0.10 port 22:2: Too many authentication failures原因通常是 ssh-agent 里加载了很多私钥,客户端一个个尝试,服务端达到 MaxAuthTries 后断开。
修复:
ssh -o IdentitiesOnly=yes -i ~/.ssh/id_ed25519_deploy deploy@10.0.0.10写入 ~/.ssh/config:
Host prod-app-1
HostName 10.0.0.10
User deploy
IdentityFile ~/.ssh/id_ed25519_deploy
IdentitiesOnly yes主机指纹变化
现象:
WARNING: REMOTE HOST IDENTIFICATION HAS CHANGED!先核对是否重装、换机、换 IP。确认后清理:
ssh-keygen -R 10.0.0.10
ssh-keygen -R '[10.0.0.10]:2222'重新连接并记录新指纹。
scp 失败但 ssh 能登录
可能原因:
- OpenSSH 新版
scp默认走 SFTP,但服务端没有启用 SFTP 子系统。 - 远程路径不存在。
- 目标目录无写权限。
- 老脚本依赖远程 shell 通配符行为。
排查:
ssh deploy@10.0.0.10 'grep -i "^Subsystem" /etc/ssh/sshd_config /etc/ssh/sshd_config.d/*.conf 2>/dev/null'
ssh deploy@10.0.0.10 'test -d /opt/app && test -w /opt/app'
sftp deploy@10.0.0.10临时兼容老协议:
scp -O ./app.jar deploy@10.0.0.10:/opt/app/rsync 同步失败
常见错误:
rsync: command not found
Permission denied
No such file or directory排查:
ssh deploy@10.0.0.10 'command -v rsync'
ssh deploy@10.0.0.10 'test -d /opt/web && test -w /opt/web'
rsync -avzP --dry-run --itemize-changes ./dist/ deploy@10.0.0.10:/opt/web/如果用了 --delete,先确认源目录不是空目录:
test -n "$(find ./dist -mindepth 1 -maxdepth 1 -print -quit)"远程命令卡住
常见原因:
- sudo 等待密码。
- 命令等待交互确认。
- SSH 没有 TTY,但远程命令强依赖 TTY。
- 网络中断后会话没有及时退出。
脚本里建议:
ssh -o BatchMode=yes -o ConnectTimeout=10 deploy@10.0.0.10 'sudo -n systemctl restart app.service'必要时增加服务端保活:
Host prod-*
ServerAliveInterval 30
ServerAliveCountMax 3生产最佳实践清单
上线前检查:
- 目标主机能从允许的来源访问 SSH 端口。
sshd -t检查通过。- 普通用户密钥登录成功。
- root 远程登录已禁止,或有明确例外审批。
- 密码登录已按环境要求关闭或限制。
authorized_keys无离职人员、废弃机器、未知平台密钥。known_hosts不用StrictHostKeyChecking=no粗暴绕过。- 跳板机、目标机、CI/CD 的密钥分开。
- 文件分发脚本先
--dry-run,涉及--delete必须二次确认。 - SSH 登录日志已纳入系统日志或集中日志。
日常操作建议:
- 临时传单文件用
scp。 - 交互式传文件用
sftp。 - 目录增量发布和备份用
rsync。 - 长命令写入
~/.ssh/config。 - 批量操作默认
BatchMode=yes。 - 高危操作打印主机、用户、目录、命令。
- 发布脚本不要保存明文密码。
- 定期轮换自动化密钥。
应急保底:
- 改 SSH 配置时保留一个已登录窗口。
- 有云控制台、VNC、串口或带外管理入口。
- 有恢复 root 或管理用户登录的方法。
- 有
sshd_config原始备份。 - 有明确的回滚命令。
落地工程深水区
SSH 是生产环境的门。门能打开只是第一步,更关键的是谁能开、从哪里开、开门以后能做什么、出问题以后能不能追溯。
第一是密钥生命周期。免密登录不是把公钥丢进 authorized_keys 就完事。要有密钥生成、发放、轮换、吊销和离职回收流程。公钥建议按人或机器标注 comment,重要环境加 passphrase,自动化环境用专用密钥,不和个人日常密钥混用。离职或权限变更时要能定位并删除对应公钥。
第二是主机指纹治理。known_hosts 报变化时不要条件反射 ssh-keygen -R。先确认服务器是否重装、IP 是否复用、DNS 是否切换、跳板链路是否变化。关键服务器可以提前保存主机指纹:
ssh-keyscan -t ed25519 server-a
ssh-keygen -lf /etc/ssh/ssh_host_ed25519_key.pub第三是访问路径。企业内网常见路径是“本地 -> VPN/堡垒机 -> 跳板机 -> 目标机器”。一旦链路多了,超时、DNS、端口、权限、代理转发都会叠在一起。排障时每一跳单独验证,禁止为了省事把目标机器 SSH 直接暴露到公网。
第四是 sudo 和目录边界。SSH 登录成功不代表可以发布。部署用户应该只能写应用目录、读必要日志、执行受控的 systemctl restart 某服务。用 sudo -l 看清楚权限,不要让部署脚本获得无限 root。
第五是批量操作风险。for host in ...; do ssh ...; done 适合查询和小范围动作,不适合无保护地批量改配置。批量执行要有并发上限、失败停止策略、日志分主机保存和回滚入口。超过几十台机器,应该升级到 Ansible、Salt、运维平台或发布系统。
第六是审计。生产 SSH 至少要保留登录日志、失败登录、sudo 记录、发布脚本日志和操作人。没有审计时,事故复盘只能靠聊天记录和记忆,这是远程入口治理里最危险的状态。
常用命令速查
登录:
ssh deploy@10.0.0.10
ssh -p 2222 deploy@10.0.0.10
ssh -i ~/.ssh/id_ed25519_deploy deploy@10.0.0.10
ssh deploy@10.0.0.10 'hostname && uptime'调试:
ssh -vvv deploy@10.0.0.10
sudo sshd -t
sudo sshd -T | less
journalctl -u sshd -n 100 --no-pager
journalctl -u ssh -n 100 --no-pager密钥:
ssh-keygen -t ed25519 -C "deploy@ops" -f ~/.ssh/id_ed25519_deploy
ssh-copy-id -i ~/.ssh/id_ed25519_deploy.pub deploy@10.0.0.10
ssh-add ~/.ssh/id_ed25519_deploy
ssh-add -l主机指纹:
ssh-keygen -F 10.0.0.10
ssh-keygen -R 10.0.0.10
ssh-keyscan -p 2222 -t ed25519 10.0.0.10
ssh-keygen -lf /etc/ssh/ssh_host_ed25519_key.pub文件传输:
scp -P 2222 ./app.jar deploy@10.0.0.10:/opt/app/
sftp -P 2222 deploy@10.0.0.10
rsync -avzP -e "ssh -p 2222" ./dist/ deploy@10.0.0.10:/opt/web/
rsync -avzP --dry-run --itemize-changes ./dist/ deploy@10.0.0.10:/opt/web/跳板机:
ssh -J deploy@jump.example.com:2222 app@10.0.1.20
scp -J deploy@jump.example.com:2222 ./app.jar app@10.0.1.20:/opt/app/端口和服务:
nc -vz 10.0.0.10 22
sudo ss -lntp | grep ssh
sudo systemctl status sshd --no-pager
sudo systemctl status ssh --no-pager官方文档入口
- OpenSSH Release Notes: https://www.openssh.com/releasenotes.html
- OpenSSH Manual Pages: https://www.openssh.com/manual.html
- OpenBSD
ssh(1): https://man.openbsd.org/ssh.1 - OpenBSD
scp(1): https://man.openbsd.org/scp.1 - OpenBSD
sftp(1): https://man.openbsd.org/sftp.1 - OpenBSD
ssh_config(5): https://man.openbsd.org/ssh_config - OpenBSD
sshd_config(5): https://man.openbsd.org/sshd_config - OpenBSD
ssh-keygen(1): https://man.openbsd.org/ssh-keygen.1 - Ubuntu OpenSSH Server: https://ubuntu.com/server/docs/how-to/security/openssh-server/
- Red Hat Securing Networks: https://docs.redhat.com/en/documentation/red_hat_enterprise_linux/10/html-single/securing_networks/index
- rsync 官方 manpage: https://download.samba.org/pub/rsync/rsync.1
- NIST IR 7966: https://csrc.nist.gov/pubs/ir/7966/final
