SSH 登录卡 20 秒?一次 UseDNS 引发的排查记录

有一台 CentOS 7 的虚拟机,平时用 SSH 连它,每次都要等上二十来秒才弹出密码提示或者直接连上。等得久了会形成一种错觉:这机器“网络不好”。但 ping 一下延迟只有十几毫秒,流量也正常,问题显然不在网络上。

这篇文章记录了完整的排查过程。结论是:OpenSSH 默认开启的 UseDNS 选项,在 DNS 反向解析无法得到应答时,会让每次登录固定阻塞约 20 秒。文中的每一步都有实测数据支撑,最后也验证了“网上流传的那四行优化配置”里,真正起作用的只有一行。


一、环境信息

项目内容
服务端CentOS 7 (Core)
服务端 SSHOpenSSH_7.4p1, OpenSSL 1.0.2k-fips
客户端Windows 11, OpenSSH_for_Windows_9.5p2
服务端 IP192.168.1.174
服务端看到的客户端 IP192.168.1.23(经过一层 NAT)
服务端 DNS114.114.114.114、222.5.5.5
nsswitch.confhosts: files dns myhostname

一个容易被忽略的细节:这台虚拟机跑在另一台局域网机器的虚拟化环境里,客户端到服务端之间还隔着一层地址转换。所以服务端日志里看到的客户端地址是 192.168.1.23,并不是客户端网卡上的真实地址。这个细节后面会成为关键。


二、第一步:先确认不是网络问题

排查网络类故障,第一步永远是把“网络差”这个假设证伪或者证实。先用 ping 看基础连通性和延迟:

Pinging 192.168.1.174 with 32 bytes of data:
Reply from 192.168.1.174: bytes=32 time=14ms TTL=63
Reply from 192.168.1.174: bytes=32 time=15ms TTL=63
Reply from 192.168.1.174: bytes=32 time=16ms TTL=63
... (共 10 次,0% 丢包)

Approximate round trip times in milli-seconds:
    Minimum = 9ms, Maximum = 27ms, Average = 14ms

10 个包零丢失,平均 14 毫秒。网络层完全健康,可以排除链路质量、丢包、路由异常这些常见嫌疑。

顺手在客户端查了一下服务端地址(192.168.1.174)的反向解析,是能查到的:

Name                           Type   TTL   Section    NameHost
174.1.168.192.in-addr.arpa.    PTR    50584 Answer     wuxin.tools

这里要特别说明一下,免得混淆:上面这条记录是服务端的地址反解,不是客户端的。它说明两件事:一是 DNS 解析能力本身是正常的;二是一个 RFC1918 私网地址居然能在公网 DNS 里查到 PTR,而且 TTL 还剩 50584 秒(将近 14 小时,明显是某个公共解析器的缓存),这本身就有点反常。

先记住这个对比:有些地址能反解,不代表所有地址都能。真正要查的是服务端收到的那个客户端源地址,而这要等我们连上服务器之后才能测。


三、第二步:用 ssh -vvv 找到卡在哪一步

网络没问题,那就得看 SSH 协议层到底停在哪里。开最高级别调试:

ssh -vvv root@192.168.1.174

输出会很长,关键看它停在哪一行。反复测试多次,每次都是同样的结果:

debug1: Local version string SSH-2.0-OpenSSH_for_Windows_9.5
debug1: Remote protocol version 2.0, remote software version OpenSSH_7.4
debug1: Authenticating to 192.168.1.174:22 as 'root'
debug1: SSH2_MSG_KEXINIT sent
debug1: SSH2_MSG_KEXINIT received
debug1: kex: algorithm: curve25519-sha256
...
debug1: SSH2_MSG_NEWKEYS sent
debug1: SSH2_MSG_NEWKEYS received
debug1: SSH2_MSG_EXT_INFO received
debug1: SSH2_MSG_SERVICE_ACCEPT received     ← 卡在这里,然后就没有然后了

关键信息:卡点在 SSH2_MSG_SERVICE_ACCEPT received 之后。

这一步的协议含义很重要。密钥交换(KEX)已经全部完成,客户端和服务器之间的加密通道已经建好了,客户端也告诉服务器“我要开始认证了”。按协议流程,客户端接下来会发一个 method none 的认证探测请求,服务器应该立刻回复一份“支持哪些认证方式”的列表(正常情况下会打印 Authentications that can continue: publickey,password 这一行)。

但这行迟迟不出现。也就是说,服务器收到了请求,却拖着不回复。这不是认证失败,是服务器在认证阶段卡住了。

对比一下修好之后同一位置的输出,差别一目了然:

debug1: SSH2_MSG_SERVICE_ACCEPT received
debug1: Authentications that can continue: publickey,gssapi-keyex,gssapi-with-mic,password
debug1: Next authentication method: publickey
debug1: Offering public key: C:\Users\lifen\.ssh\id_rsa RSA SHA256:f31M+...
debug1: Server accepts key: ...
Authenticated to 192.168.1.174 ([192.168.1.174]:22) using "publickey".
Transferred: sent 3456, received 3156 bytes, in 0.5 seconds

从 SERVICE_ACCEPT 到认证完成,只花了 0.5 秒。

另外值得一提的是,这个卡顿和认证方式无关。公钥认证、密码认证,加上 GSSAPIAuthentication=no、PubkeyAuthentication=no、BatchMode=yes 的各种组合都试过,每一种都在同一个位置卡住,同样 20 秒左右。这一点非常有价值:它说明问题不在“哪一种认证方式”,而在所有认证方式之前的那段公共逻辑里。


四、第三步:把嫌疑指向 DNS 反向解析

服务器在认证阶段会干什么?除了查用户、查 PAM,还有一件事:解析客户端的 IP 地址。

OpenSSH 手册里对 UseDNS 的说明是这样的:

UseDNS — Specifies whether sshd(8) should look up the remote host name, and to check that the resolved host name for the remote IP address maps back to the very same IP address. The default is “yes”.

If this option is set to no then only addresses and not host names may be used in ~/.ssh/authorized_keys from and sshd_config Match Host directives.

也就是说,默认情况下 sshd 会对连进来的客户端 IP 做一次反向解析(PTR 查询),拿到主机名之后还要再做一次正向解析,确认正向结果和原 IP 一致。这叫 forward-confirmed reverse DNS,目的是把客户端主机名记进日志,顺便做一层粗粒度的身份校验。

于是到服务器上直接测一下反向解析要多久:

$ time getent hosts 192.168.1.23
                                       # 没有任何输出

real    0m20.036s

$ time getent hosts 192.168.1.1
192.168.1.1     gateway

real    0m20.029s

每一次私网地址的反向解析,都要整整 20 秒才返回。

作为对照,再看一次正向解析公网域名:

$ time getent hosts mirrors.aliyun.com
124.239.239.77  mirrors.aliyun.com.w.alikunlun.com mirrors.aliyun.com

real    0m0.057s

0.057 秒。DNS 服务本身是好的,快得很。

再直接问配置里那台 DNS 服务器:

$ time nslookup 192.168.1.23 114.114.114.114
;; connection timed out; no servers could be reached

real    0m15.038s

超时。

到这里整个因果链就清楚了:

114.114.114.114 这类公共 DNS 不会为 RFC1918 私网地址提供 PTR 记录。查询请求发出去之后,服务器只能干等,直到解析器超时。而 OpenSSH 在超时之前会一直阻塞,不会往下走。


五、第四步:对照实验,把 20 秒钉死在 UseDNS 上

到这一步,UseDNS 的嫌疑已经很大了,但“嫌疑大”和“确认”是两回事。最干净的做法是做一个单变量对照实验。

问题是不能直接改线上配置来测,改完要重启 sshd,万一出问题就把自己关在门外了。更稳的办法是:在另一个端口上,用一份单独的配置文件,起一个临时的 sshd 实例。

# 复制现网配置,改端口、改 pid 文件,把 UseDNS 设成 yes
cp /etc/ssh/sshd_config /tmp/sshd_usedns_test.conf
sed -i '/^[[:space:]]*UseDNS/d' /tmp/sshd_usedns_test.conf
sed -i '1i Port 2222\nPidFile /tmp/sshd_usedns_test.pid\nUseDNS yes' /tmp/sshd_usedns_test.conf

# 检查配置语法,并确认实际生效值
/usr/sbin/sshd -t -f /tmp/sshd_usedns_test.conf
sshd -T -f /tmp/sshd_usedns_test.conf | grep -iE '^port|usedns'

# 启动
/usr/sbin/sshd -f /tmp/sshd_usedns_test.conf

现网 sshd 完全不受影响,风险为零。然后分别测三次:

UseDNS yes(端口 2222):

第几次耗时
120.874 s
220.817 s
320.939 s

UseDNS no(端口 22,现网配置):

第几次耗时
10.767 s
20.758 s
30.810 s

约 27 倍的差距,而且三次结果高度稳定。 单变量对照做到这个程度,结论已经没有悬念了。

顺手还测了第三组,验证 GSSAPI 是不是共犯:

UseDNS no + GSSAPIAuthentication yes(端口 2223):

第几次耗时
10.823 s
20.870 s
30.897 s

依然很快。有意思的地方来了:单独打开 GSSAPI 并不会造成延迟。原因也不难理解,GSSAPI 是 Kerberos 相关的认证机制,只有在客户端主动尝试发起 GSSAPI 认证时,服务端才会去做域名解析、找 KDC 那一套动作。而 Windows 版 OpenSSH 客户端在没有 Kerberos 票据的情况下根本不会去试,服务端也就没有机会卡住。

所以在这台机器上,GSSAPI 不是元凶。它确实是“SSH 登录慢”这个问题的经典嫌疑犯之一,尤其是在域环境(AD)里,但在这次的场景中,它是冤枉的。


六、第五步:在 sshd 调试日志里亲眼看到那一行

实验数据已经足够,但还可以更彻底一点:直接看服务器端的日志,确认那 20 秒究竟花在哪个函数调用上。

用 LogLevel DEBUG3 起一个调试实例,连一次,然后查日志:

debug1: userauth-request for user root service ssh-connection method none [preauth]
debug1: attempt 0 failures 0 [preauth]
debug3: mm_getpwnamallow entering [preauth]
debug3: mm_request_send entering: type 8 [preauth]
debug3: mm_getpwnamallow: waiting for MONITOR_ANS_PWNAM [preauth]
debug3: monitor_read: checking request 8
debug3: mm_answer_pwnamallow
debug3: Trying to reverse map address 192.168.1.23.     ← 就在这里卡了 20 秒
debug2: parse_server_config: config reprocess config len 794
debug3: mm_answer_pwnamallow: sending MONITOR_ANS_PWNAM: 1
debug3: mm_request_send entering: type 9 [preauth]
debug2: input_userauth_request: setting up authctxt for root [preauth]
debug3: mm_start_pam entering [preauth]
...
debug3: userauth_finish: failure partial=0 next methods="publickey,password" [preauth]
debug3: send packet: type 51 [preauth]

Trying to reverse map address 192.168.1.23. 这行就是答案。

把服务端日志和客户端 -vvv 输出对起来看,两边严丝合缝:

  • 服务端:mm_answer_pwnamallow → Trying to reverse map → 阻塞 20 秒 → 返回
  • 客户端:SERVICE_ACCEPT received → 等待 20 秒 → 收到 Authentications that can continue 列表

服务端在这个阶段要调用 getpwnamallow() 去确认“这个用户在这个来源地址上允许登录吗”,而这一步会重新解析一遍配置。因为 UseDNS yes,解析配置时就顺手触发了对客户端 IP 的反向解析。整个认证流程被这一个 DNS 查询堵死。


七、那四行配置里,真正起作用的是哪一行

问题解决之后,回过头看被广泛转发的“SSH 优化四件套”:

UseDNS no
GSSAPIAuthentication no
MaxStartups 10:30:60
LoginGraceTime 30

从上面的实验可以给出明确判断:四行里只有 UseDNS no 真正解决了这次的延迟,其余三行不是无关,但它们的价值不在“让单次连接变快”。

逐行说一下。

UseDNS no:唯一直接生效的一行。
关掉认证阶段的反向解析,省掉那 20 秒的 DNS 等待。这一行的效果是“每次连接省 20 秒”,量级最大。

GSSAPIAuthentication no:这次没起作用,但值得保留。
实验已经证明,在本次环境中它不影响速度。之所以仍然建议加上,是因为它的默认值虽然是 no,但很多发行版(尤其是 RHEL / CentOS 系的模板配置)会在配置文件里把它显式写成 yes。这台机器就是这样,配置文件里明明白白写着 GSSAPIAuthentication yes。一旦客户端环境里有 Kerberos 相关配置(比如加过域、装过企业认证软件),服务端就会尝试 GSSAPI 协商,去查 _kerberos._udp.<realm> 这类 SRV 记录。如果 DNS 恰好也不给力,又是一次漫长的等待。显式关掉,等于提前拆掉一颗雷。

MaxStartups 10:30:60:防雪崩,不治单次延迟。
它的作用是限制未通过认证的连接能同时挂多少个。默认值是 10:30:100,含义是:当前有 10 个未认证连接时开始随机拒绝,概率 30%,到 100 个时全部拒绝。改成 10:30:60 只是把“全部拒绝”的阈值从 100 降到 60。对单次连接的速度没有任何影响。

LoginGraceTime 30:清理残留连接,同样不治单次延迟。
默认是 120 秒,如果用户 120 秒内没登录成功,服务端就断开这条连接以释放资源。改成 30 秒只是让超时连接更快被回收。同样不影响正常连接的握手速度。

不过话要说回来,后两项在这里并不是完全没有意义,因为这个故障本身会产生连锁反应:

如果每次登录都要卡 20 秒,而 LoginGraceTime 默认又是 120 秒,那么只要你连续开了几个终端窗口,或者在用自动化脚本批量连,未认证连接就会迅速堆积。等堆积超过 MaxStartups 的阈值,新连接开始被随机丢弃。表现出来就是“有时候能连上,有时候直接连不上,有时候卡到怀疑人生”。

这种“偶发性”和“随机失败”正是它容易被误判成网络故障的原因。把 20 秒的根因解决之后,这两个参数的价值才回到它们本来的位置:它们是容量和清理策略,不是延迟优化。


八、为什么反向解析偏偏要 20 秒

把整条链路完整串起来看:

  1. 服务端 resolv.conf 里配的是 114.114.114.114 和 222.5.5.5,都是公共 DNS。
  2. 客户端经过 NAT 之后,服务端看到的源地址是 192.168.1.23,一个 RFC1918 私网地址。
  3. 服务端拿这个私网地址去做 PTR 查询。公网 DNS 没有理由也不会有这个私网地址的记录,于是查询进入“发出请求 → 无应答 → 等待超时”的循环。
  4. glibc 解析器的默认参数是 timeout:5、attempts:2,也就是每台 DNS 等 5 秒、重试 2 轮;而 resolv.conf 里配了 2 台 DNS。5 秒 × 2 轮 × 2 台 = 20 秒。
  5. OpenSSH 默认 UseDNS yes,而且是同步阻塞地等这次解析结果,它不会“先跳过、后补上”。
  6. 于是每一次 SSH 登录,都在认证阶段白白付出 20 秒。

第 4 步这个算式值得单独强调一下,因为它解释了一个细节:实测耗时是 20.036 秒和 20.029 秒,精确得反常。如果只是“网络慢”或者“DNS 偶尔抽风”,耗时应该是浮动的。而这里每次都是 20 秒出头,说明它不是随机故障,而是一组固定的超时参数在叠加。看到这种稳定得可疑的数字,就该往“超时 × 重试次数”的方向想了。

这里还有个认知上的陷阱值得点出来:很多人以为 DNS 慢就等于 DNS 有问题。但在这个案例里,DNS 服务本身完全正常,解析公网域名只要 0.057 秒。问题出在“用错了 DNS”:拿一个只服务于公网解析的 DNS,去查询只有内网才能回答的私网地址。这不是 DNS 的故障,是配置和用途不匹配。

顺带说一句,这也是为什么“私网环境必须配内网 DNS”不只是个规范建议。少了内网 DNS,UseDNS yes 这种默认行为就成了纯粹的负担。


九、一个值得记住的坑:sshd 配置是“第一个值生效”

排查过程中还发现了一个静默失效的配置问题,它比延迟本身更值得警惕。

当时的 sshd_config 里,GSSAPIAuthentication 出现了两次:

78: # GSSAPI options
79:  GSSAPIAuthentication yes        ← 靠前
...
141: GSSAPIAuthentication no         ← 靠后,想关掉它

看起来“后面那行 no 会覆盖前面的 yes”,对吧?但 sshd 的规则恰好相反:

对于大多数配置项,sshd 只采用第一个出现的值,后面的重复项会被静默忽略。

也就是说,配置文件里真正生效的是第 79 行的 yes,第 141 行那句 no 写了等于没写,而且不会报任何警告。

这个规则可以用 sshd -T 直接验证,不用猜,也不用重启服务:

# 顶部写 yes,底部写 no
$ printf 'GSSAPIAuthentication yes\nGSSAPIAuthentication no\n' > /tmp/dup.conf
$ sshd -T -f /tmp/dup.conf | grep -i gssapiauth
gssapiauthentication yes          ← 第一个值生效

# 反过来,no 在前、yes 在后
$ printf 'UseDNS no\nUseDNS yes\n' > /tmp/dup2.conf
$ sshd -T -f /tmp/dup2.conf | grep -i usedns
usedns no                         ← 依然是第一个值生效

顺便把 OpenSSH 的内置默认值也一起测出来,免得再被“注释掉的默认值”误导:

$ sshd -T -f /dev/null | grep -iE 'usedns|gssapiauth|maxstartups|logingracetime'
logingracetime 120
gssapiauthentication no
usedns yes
maxstartups 10:30:100

这里有个极易踩的点:默认值是不会自己出现在配置文件里的。原始配置里 UseDNS 那行是注释状态(#UseDNS yes),不代表它被关掉了。恰恰相反,注释意味着“用默认值”,而默认值就是 yes。这正是这次故障的隐蔽之处。

所以以后改 sshd 配置,养成两个习惯:

  • 改完用 sshd -T 看“实际生效值”,而不是 cat 文件凭肉眼看。配置文件里写了什么不重要,sshd 认为它是什么才重要。
  • 配置文件里同一项只保留一处。重复项不会报错,只会静默地按第一个走,是个非常耗时的坑。

十、最终配置与验证结果

清理完重复项之后的最终配置:

UseDNS no
GSSAPIAuthentication no
MaxStartups 10:30:60
LoginGraceTime 30

重载配置:

sshd -t && systemctl reload sshd

确认实际生效值:

$ sshd -T | grep -iE 'usedns|gssapiauthentication|maxstartups|logingracetime'
logingracetime 30
gssapiauthentication no
passwordauthentication yes
usedns no
maxstartups 10:30:60

连接速度验证:

第几次修复前(UseDNS yes)修复后(UseDNS no)
120.874 s0.694 s
220.817 s0.804 s
320.939 s1.068 s

从 21 秒降到 0.8 秒左右,提升约 25 倍。卡顿彻底消失。


十一、几点提醒

改成 UseDNS no 之前,有两点副作用需要知道,它们都写在 OpenSSH 的手册里:

If this option is set to no then only addresses and not host names may be used in ~/.ssh/authorized_keys from and sshd_config Match Host directives.

具体是:

第一,authorized_keys 里的来源限制只能用 IP,不能用主机名。

# 关掉 UseDNS 之后,这种写法会失效:
from="trusted-host.example.com" ssh-rsa AAAA...

# 要改成 IP 或网段:
from="192.168.1.0/24" ssh-rsa AAAA...

第二,sshd_config 里的 Match Host 同理,只能用 IP 匹配。

如果你确实依赖按主机名做访问控制,那就不该关 UseDNS,而应该去修 DNS:给私网地址配上真正的内网 DNS 服务器。反过来说,如果 DNS 环境是健康的,UseDNS yes 带来的额外延迟通常只有几毫秒,是完全无感的,并没有必要为了“优化”而关掉它。这次之所以要关,是因为这台机器的 DNS 配置和它的使用场景根本不匹配。

第三,少了反向解析,日志里就只能看到 IP 地址了。

/var/log/secure 里会变成 Accepted publickey for root from 192.168.1.23 这种形式,不再有反解出来的主机名。对绝大多数场景来说这完全可以接受,但如果你的审计流程依赖主机名,需要提前评估。

另外,排查过程中还顺手排除了一个错误方向,值得记一笔。

一开始怀疑是虚拟机的熵池不足导致 sshd 在生成密钥材料时阻塞。这在 Linux 虚拟机上是真实存在的经典问题,网上也有大量“装个 haveged 就好了”的经验帖。我照着试了一下,结果 CentOS 7 的源里根本没有 haveged 这个包。于是换了个思路,直接去量熵池的实际水位:

$ cat /proc/sys/kernel/random/entropy_avail
3112

3112 位,这是一个非常健康的数值。熵池根本不是瓶颈。

这里有两层教训:

  • “熵不足”和“DNS 超时”的症状很像(都是登录时长时间阻塞),但验证方法完全不同:前者看 /proc/sys/kernel/random/entropy_avail 的实际水位,后者测 DNS 解析耗时。凭症状猜原因,很容易在白忙一场之后放弃。
  • CentOS 7 的系统里不存在 /proc/sys/kernel/random/entropy_bits 这个文件(Linux 上正确的文件名是 entropy_avail)。如果照着某篇帖子敲命令发现“文件不存在”,很可能不是你操作错了,而是那篇帖子本身就抄错了。这时候应该停下来核对,而不是继续跟着试。

十二、排查这类问题的通用顺序

把这套流程抽象一下,它是一个可以复用到其他“连接慢”场景的排查路径。

第一步:先量网络基线。 ping 看延迟和丢包,必要时加 mtr 看路径。这一步的目的是快速排除掉最大概率的假设,而不是急着上高级工具。零丢包、十几毫秒延迟,网络这条线就可以划掉了。

第二步:用 -vvv 找到“卡在哪一步”,而不是猜“为什么慢”。 这是整个排查中最关键的一步。SSH 的握手有清晰的阶段划分:TCP 连接、版本协商、密钥交换、认证、会话建立。不同阶段卡住,对应的原因完全不同:

卡住的位置大概率原因
TCP 连接失败网络不通、防火墙、端口未监听
密钥交换阶段算法不匹配、MTU 问题
认证阶段(本次案例)DNS 反解、PAM / NIS / LDAP 超时、GSSAPI
认证后无响应登录脚本(.bashrc、/etc/profile)过慢、motd 脚本卡住

把范围缩小到“认证阶段”之后,可选的怀疑对象就从几十个降到三四个了。

第三步:把“服务器侧的耗时”单独测出来。 在服务器上直接跑 time getent hosts <client-ip>,比在客户端反复连 SSH 高效得多,而且能立刻区分“是 DNS 慢”还是“是 SSH 慢”。

第四步:设计单变量对照实验。 这次用的“另起一个端口、另用一份配置文件的 sshd 实例”是个很好用的技巧,既能做干净的 A/B 对照,又完全不碰线上服务,风险为零。每次只改一个变量,重复测三次看稳定性。数据一旦呈现出稳定且量级悬殊的差异(20.9 秒 vs 0.8 秒),结论就无可辩驳了。

第五步:看服务端日志做交叉验证。 客户端 -vvv 给出“卡在哪一步”,服务端 LogLevel DEBUG3 给出“卡在哪个函数”。两边一对照,因果链就闭合了。这一步往往还能顺带挖出别的配置问题(比如本文那个“第一个值生效”的重复配置)。

第六步:改完配置,验证“实际生效值”,而不只是验证“文件内容”。 sshd -T 这类工具能把服务的真实解析结果打印出来,这是唯一可靠的方式。


小结

项目内容
根本原因UseDNS 默认为 yes,认证阶段对客户端私网 IP 做反向解析,而 resolv.conf 里只有公共 DNS,私网 PTR 查询无应答,阻塞约 20 秒
直接修复UseDNS no
修复效果单次连接从约 20.9 秒降到约 0.8 秒
未被验证的假设GSSAPI 不是本次元凶(实测无影响);熵池不足也已排除(entropy_avail = 3112,健康)
附带发现sshd_config 中重复配置项只取第一个值,后面的被静默忽略

真正值得记住的不是“加这四行配置”,而是这个思路:先量数据,再把问题定位到具体的协议阶段,然后用单变量对照实验把嫌疑一个个排除掉。 “SSH 慢”是个症状,不是原因。四行配置里可能只有一行有用,而那三行是不是有用、为什么有用,得靠数据说话。


本文所有测试数据均来自真实环境实测,测试用的临时 sshd 实例已在排查结束后全部清理。

发表评论