春江暮客

春江暮客的个人学习分享网站

用 Python 探测 TCP 端口:区分连接拒绝、超时与系统错误

2026-10-18 技术
用 Python 探测 TCP 端口:区分连接拒绝、超时与系统错误

后台脚本连接本地服务失败,日志里只有“连不上”。这是服务没有监听、连接等待超时,还是其他系统错误?本文写一个 Python 小工具,输出 JSON 和明确的退出码,再用本地端口复现成功与失败。

面向初学者到中级读者,示例使用 Python 标准库,不需要 pip 包。工具每次只探测一个 IPv4 地址和 TCP 端口,不发送业务数据,也不做自动重试。

1. 方法:先定义检查范围

  1. ok 表示本次 TCP 连接建立成功。
  2. refused 表示系统返回连接拒绝,常见原因是目标端口没有监听,也可能是中间设备主动拒绝。
  3. timeout 表示连接操作超时,不能据此断言一定是防火墙。
  4. 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,不修改防火墙或调度器。

  1. 在终端 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。

  1. 在终端 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. 运行:成功、拒绝与退出码

  1. 保持终端 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}
  1. 在终端 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 排错

  1. 重新启动第 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 请求与有限重试。一次成功只是当时的观察,后续连接仍可能失败,业务客户端还需要自己的错误处理。

  1. 在服务所在的 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 连通后,继续验证应用响应和业务内容。

标签

1024 12306 ablang adsense agents.md ai ai-agent ai-agents ai-seo algorithm amp antibodies apparmor argparse automation backup bash batch-processing binarycif bioinformatics biopython blockchain boltz bootstrapping boxes bubblewrap c-index cca cdn chatgpt checkpoint cli cloudflare codex cofoldarena copy cpu监控 cron csv cuda curl data-leakage data-processing data-quality data-validation datascience datavisualization datetime debugging deployment desktop-app devtools disk-space disown docker dovecot download electron encoding esm esm2 esm3 esmc esmfold2 faceswap fasta fastmcp faulthandler ffmpeg file-descriptors file-io flashppi flask folium frontend game generator git github-actions google google-research grep gzip harness hls html http hugo indexnow javascript jev json jsonl just k-means kaggle langfuse leecode linux list litellm llm llms.txt logging logs lollipop lsof m3u8 machine-learning macos manacher matplotlib mcp mirror mmcif model-evaluation monitoring mp3 mp4 mpnn multiomics mutation mysql nanobert nanobodies networkx nginx normalize numpy ollama omegatherm pandas password pdb pep-723 phaser pillow pip postfix preprocessing print protein-alignment protein-design protein-embeddings protein-interactions protein-language-models protein-stability protein-structure proxy pydantic pyecharts pyqt python python3 r raincloud reproducibility requests reservoir-sampling rfoptimization rg ripgrep rosettafold3 roundcube rrsi rsi rsync s-tui sampling scale scikit-learn scrapy screen seaborn security selenium seo sequence-identity sha256 signals sklearn socket solana somaticsignatures spl sqlite ssh standardize static-site subprocess symlink sysadmin system-one tcp tensorflow tkinter toml tron tronpy troubleshooting turtle typesafe-ai ubuntu usdt uv vhhbert vite webp wordcloud wordpress workflow yaml zipfile zoneinfo 后台 寓言 概率 经济 贸易 迅雷解析 钱包

友情链接

其它