用 Python 探测 TCP 端口:区分连接拒绝、超时与系统错误
后台脚本连接本地服务失败,日志里只有“连不上”。这是服务没有监听、连接等待超时,还是其他系统错误?本文写一个 Python 小工具,输出 JSON 和明确的退出码,再用本地端口复现成功与失败。
面向初学者到中级读者,示例使用 Python 标准库,不需要 pip 包。工具每次只探测一个 IPv4 地址和 TCP 端口,不发送业务数据,也不做自动重试。
1. 方法:先定义检查范围
ok表示本次 TCP 连接建立成功。refused表示系统返回连接拒绝,常见原因是目标端口没有监听,也可能是中间设备主动拒绝。timeout表示连接操作超时,不能据此断言一定是防火墙。error保留其他系统错误,例如网络不可达或权限限制。
这里刻意使用数字 IPv4 地址,通过 IPv4Address校验。localhost、域名、IPv6 和带 http:// 的 URL 都会被拒绝,避免把 DNS 解析混进这次实验。地址应是实际目标地址;0.0.0.0 是服务端常用的绑定地址,不应当作远端服务地址。
TCP 成功后,还要检查应用协议。网站部署可接着使用 curl 状态码与页面内容检查,不能仅凭端口可连接就宣布发布成功。
2. 准备:下载脚本,启动本地演示服务
需要 Linux、Python 3.9 或更新版本,以及下载用的 curl。示例已在 Linux、Python 3.11.4 下测试。下面命令适用于 bash 或 zsh,不修改防火墙或调度器。
- 在终端 A 的新目录运行:
python3 --version
mkdir tcp-probe-demo
cd tcp-probe-demo
curl -fS https://www.bobobk.com/downloads/tcp-probe/check_tcp.py -o check_tcp.py
curl -fS https://www.bobobk.com/downloads/tcp-probe/test_tcp.py -o test_tcp.py
python3 check_tcp.py --help
mkdir served
python3 -m http.server 8765 --bind 127.0.0.1 --directory served
演示服务只监听回环地址,并只提供新建的空目录。Python 的 http.server --bind 和 --directory 文档说明了这两个参数;这里只把它用作本地测试服务。保持终端 A 运行,结束时按 Ctrl+C。
- 在终端 B 进入同一个
tcp-probe-demo目录。下载受限时,可将下面完整代码保存为check_tcp.py,并把第 5 节代码保存为test_tcp.py,跳过两条 curl 命令。
如果服务报 Address already in use,改用 8766,并把后续命令中的 8765 一起替换,不必终止占用端口的其他程序。
3. 完整实现:一次连接,一行 JSON
"""Probe one numeric IPv4 TCP endpoint. Python 3.9+, standard library."""
import argparse
import errno
import ipaddress
import json
import math
import socket
import time
def ipv4(value):
try:
return str(ipaddress.IPv4Address(value))
except ipaddress.AddressValueError as exc:
raise argparse.ArgumentTypeError("use a numeric IPv4 address") from exc
def probe(ip, port, timeout):
result = {"ip": ip, "port": port, "status": "ok", "errno": None,
"error": "", "timeout_seconds": timeout}
started = time.monotonic()
try:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as connection:
connection.settimeout(timeout)
connection.connect((ip, port))
except socket.timeout as exc:
result.update(status="timeout", errno=exc.errno, error=str(exc))
except OSError as exc:
status = "refused" if exc.errno == errno.ECONNREFUSED else "error"
result.update(status=status, errno=exc.errno, error=str(exc))
result["elapsed_ms"] = round((time.monotonic() - started) * 1000, 3)
return result
def main():
parser = argparse.ArgumentParser(description=__doc__, allow_abbrev=False)
parser.add_argument("ip", type=ipv4)
parser.add_argument("port", type=int)
parser.add_argument("--timeout", type=float, default=2.0,
help="connection timeout in seconds, 0 < value <= 60")
args = parser.parse_args()
if not 1 <= args.port <= 65535:
parser.error("port must be between 1 and 65535")
if not math.isfinite(args.timeout) or not 0 < args.timeout <= 60:
parser.error("--timeout must be finite and between 0 (exclusive) and 60")
report = probe(args.ip, args.port, args.timeout)
print(json.dumps(report, ensure_ascii=False))
return 0 if report["status"] == "ok" else 1
if __name__ == "__main__":
raise SystemExit(main())
socket.settimeout()设置连接操作的等待时间。参数限定为大于 0、最多 60 秒的有限数;这不是整个进程墙钟时间的硬性保证。socket 在上下文结束时关闭,探测不会留下长连接。
耗时用 time.monotonic()计算,避免系统时钟校正干扰持续时间。elapsed_ms 是观测值,保留三位小数;超时也可能由系统网络栈更早返回。
4. 运行:成功、拒绝与退出码
- 保持终端 A 的服务运行,在终端 B 执行:
python3 check_tcp.py 127.0.0.1 8765 --timeout 1
输出类似下面,实际耗时会变化:
{"ip": "127.0.0.1", "port": 8765, "status": "ok", "errno": null, "error": "", "timeout_seconds": 1.0, "elapsed_ms": 0.21}
- 在终端 A 按 Ctrl+C,停止自己启动的演示服务,然后回到终端 B:
if python3 check_tcp.py 127.0.0.1 8765 --timeout 1; then
printf 'TCP connected\n'
else
probe_exit=$?
printf 'Probe failed: exit=%s\n' "$probe_exit"
fi
没有其他程序接管端口时,应看到 status: "refused" 和 Probe failed: exit=1。errno 及错误文字由系统提供,不要在程序里硬编码某个平台的错误数字或英文字符串。
| 退出码 | 含义 |
|---|---|
| 0 | 本次 TCP 连接成功。 |
| 1 | 连接拒绝、超时或其他系统错误;标准输出仍有 JSON。 |
| 2 | 参数错误;说明写入标准错误,不输出成功 JSON。 |
refused 与 timeout 是不同观察结果。它们都不能完整证明远端程序的运行状态,下一节通过可控实验验证分类。
5. 验证:使用动态端口,注入超时分支
保存为当前目录的 test_tcp.py,下载版本与下方代码相同:
import errno
import json
import socket
import subprocess
import sys
from unittest.mock import patch
import check_tcp
def run(arguments):
return subprocess.run(
[sys.executable, "check_tcp.py", *arguments],
capture_output=True, text=True, timeout=5, check=False,
)
# Port 0 asks Linux for an available port; keep the socket open during each test.
for listening, expected in [(True, "ok"), (False, "refused")]:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as fixture:
fixture.bind(("127.0.0.1", 0))
if listening:
fixture.listen(1)
port = fixture.getsockname()[1]
completed = run(["127.0.0.1", str(port), "--timeout", "0.5"])
report = json.loads(completed.stdout)
assert report["status"] == expected, report
assert completed.returncode == (0 if listening else 1), completed
assert completed.stderr == "", completed
assert report["ip"] == "127.0.0.1" and report["port"] == port
assert report["elapsed_ms"] >= 0
if not listening:
assert report["errno"] == errno.ECONNREFUSED, report
for arguments in [
["localhost", "80"], ["::1", "80"], ["127.0.0.1", "0"],
["127.0.0.1", "65536"], ["127.0.0.1", "80", "--timeout", "0"],
["127.0.0.1", "80", "--timeout", "nan"],
["127.0.0.1", "80", "--timeout", "inf"],
["127.0.0.1", "80", "--timeout", "61"],
]:
completed = run(arguments)
assert completed.returncode == 2 and not completed.stdout, completed
assert completed.stderr, completed
# Inject failures: no firewall changes, external packets, or real timeout wait.
for failure, expected in [
(socket.timeout("test timeout"), "timeout"),
(OSError(errno.ENETUNREACH, "test unreachable"), "error"),
]:
with patch("check_tcp.socket.socket") as factory:
connection = factory.return_value.__enter__.return_value
connection.connect.side_effect = failure
report = check_tcp.probe("127.0.0.1", 12345, 0.5)
assert report["status"] == expected, report
connection.settimeout.assert_called_once_with(0.5)
connection.connect.assert_called_once_with(("127.0.0.1", 12345))
factory.return_value.__exit__.assert_called_once()
print("OK: listening, refused, invalid arguments, injected timeout and network error")
执行:
python3 test_tcp.py
预期输出:
OK: listening, refused, invalid arguments, injected timeout and network error
前两个测试确实建立本地 socket:一个开始监听,一个只绑定、不监听。端口由系统动态分配,并在测试期间一直保留,因此避免“先找空闲端口、再使用时已被抢占”的常见问题。监听测试没有处理任何 HTTP 请求,依然得到 ok,也说明 TCP 成功不等于应用正常。
超时和网络不可达使用 unittest.mock.patch注入异常,只验证脚本的分类和资源退出逻辑,不验证真实网络会在多久后超时。无需拿公网地址碰运气,也无需调整防火墙。
6. 接入自动化与 Linux 排错
- 重新启动第 2 节的演示服务,在终端 B 用
&&串联后续检查:
python3 check_tcp.py 127.0.0.1 8765 --timeout 1 \
&& printf 'Continue with an application-level check\n'
只有探测成功才执行后续命令,失败保留非零退出码。生产环境应把右侧替换为真实业务检查;需要请求并校验 JSON 时,可参考 Python HTTP JSON 请求与有限重试。一次成功只是当时的观察,后续连接仍可能失败,业务客户端还需要自己的错误处理。
- 在服务所在的 Linux 主机或容器里,使用已有的
ss查看监听状态:
ss -ltnp 'sport = :8765'
-ltnp 分别选择监听、TCP、数字地址和进程信息;权限不足时可能看不到其他用户的进程。若系统没有 ss,本教程的 Python 探测和自检仍可直接使用。不要为了通过探测而盲目开放端口或关闭已有服务。
| 现象 | 具体检查 |
|---|---|
refused |
查看目标地址和端口是否正确、服务是否监听,以及是否存在主动拒绝规则。 |
timeout |
对照服务端监听、客户端路由和网络访问规则排查;增加超时之前先确认目标。 |
error |
读取 JSON 中的 errno 与 error,按网络不可达、权限等具体原因处理。 |
| 本地成功,容器内失败 | 容器里的 127.0.0.1 指向容器自身;在实际调用方环境使用可达的目标地址。 |
| TCP 成功,HTTP 失败 | 继续核对 TLS、状态码与响应内容;端口连通检查不会读取这些信息。 |
| 交互运行成功,定时运行失败 | 使用解释器和脚本的绝对路径,并按定时任务路径与环境排查检查运行账号和工作目录。 |
7. 小结
把端口探测分成成功、拒绝、超时和其他错误,自动化脚本就能保留有用的故障信息,并让后续步骤按退出码决定是否继续。先用本地动态端口和注入异常验证工具,再在实际调用环境检查目标地址;TCP 连通后,继续验证应用响应和业务内容。
- 原文作者:春江暮客
- 原文链接:https://www.bobobk.com/python-tcp-port-check.html
- 版权声明:本作品采用 知识共享署名-非商业性使用-禁止演绎 4.0 国际许可协议 进行许可,非商业转载请注明出处(作者,原文链接),商业转载请联系作者获得授权。