“连接被拒绝”的终极解密:当 PHP PDO 遇上 Docker 和一个被遗忘的端口

发布时间: 2025-12-03
作者: DP
浏览数: 8 次
分类: PHP
内容
## 问题背景:一个“不可能”的错误 想象一下这个场景:你正在开发一个项目,其中 PHP 应用运行在本地 Mac 的 Docker 容器中,而 MySQL 数据库则运行在局域网内一台 NAS 的 Docker 容器里。你使用 MySQL Workbench,通过 NAS 的局域网 IP `192.168.1.2` 和一个自定义端口 `33221`,可以完美连接和管理数据库。 然而,当你在 PHP 代码中使用完全相同的参数(IP、端口、用户名、密码)通过 PDO 尝试连接时,却收到了一个冰冷的错误: ``` SQLSTATE[HY000] [2002] Connection refused ``` 网络是通的,凭证是对的,工具也能用,那问题究竟出在哪里?这正是我们今天要解开的谜题。这个案例来自 `wiki.lib00.com` 的一次真实技术支持,它提醒我们,最复杂的 bug 往往源于最简单的疏忽。 --- ## 第一阶段:常规排查(排除红鲱鱼) 面对“连接被拒绝”,我们的第一反应通常是检查网络和权限。这是标准的排错流程,也是必经之路。 1. **MySQL 用户权限**:最常见的元凶是 MySQL 的用户授权。MySQL 的用户是以 `'username'@'hostname'` 的形式定义的。我们首先要确认,是否存在允许 PHP 容器所在主机连接的规则。 通过 MySQL Workbench 执行查询: ```sql SELECT user, host FROM mysql.user WHERE user = 'lee.dev'; ``` 返回结果是 `lee.dev | %`。这里的 `%` 是通配符,意味着允许来自**任何主机**的连接。所以,权限问题被排除了。 2. **Docker 网络隔离**:PHP 运行在 Docker 容器里,它的网络环境与宿主机(Mac)不同。起初我们怀疑,MySQL 服务器看到的请求来源是 Docker 的内部 IP(如 `172.18.0.5`),而不是 Mac 的局域网 IP。但由于连接目标是局域网 IP `192.168.1.2`,Docker 会通过 NAT 将其转换为宿主机的 IP。所以,对于 NAS 上的 MySQL 来说,请求来源依然是 Mac 的局域网 IP。 3. **防火墙和 `bind-address`**:既然 Workbench 能通,说明 NAS 的防火墙允许 Mac 的 IP 访问 `33221` 端口。同样,MySQL 配置文件(my.cnf)中的 `bind-address` 也肯定是正确的(例如 `0.0.0.0`),否则它不会响应任何外部连接。 常规排查全部通过,问题变得更加扑朔迷离。 --- ## 第二阶段:深入容器内部(寻找真相) 当外部观察无法找到问题时,我们就必须进入问题的发生地——PHP 容器内部,以它的视角来审视网络。 1. **进入容器**: ```bash docker exec -it <php_container_id> /bin/sh ``` 2. **进行网络连通性测试**:我们使用 `curl` 这个强大的工具来测试 TCP 连接。`telnet` 或 `nc` 也是不错的选择,但 `curl` 在多数环境中更常见。 ```bash # 测试到 NAS 80 端口,确认基础网络可达 root@...# curl 192.168.1.2 # 成功返回 HTML,说明容器到 NAS 的网络没问题 # 关键测试:直接访问 MySQL 端口 root@...# curl 192.168.1.2:33221 curl: (1) Received HTTP/0.9 when not allowed ``` `curl` 的报错 `Received HTTP/0.9 when not allowed` 是一个**决定性的好消息**!虽然 `curl` 无法解析 MySQL 的私有协议,但这个报错意味着: * TCP 三次握手**成功**了。 * PHP 容器与 NAS 上的 MySQL 服务在 `33221` 端口上**已经建立了连接**。 * MySQL 服务**响应了**这个连接请求。 至此,我们可以 100% 确定:**网络、防火墙、端口映射、用户权限、服务器配置全部正确!** 问题范围被缩小到了最后一个环节:PHP 的 PDO 客户端本身。 --- ## 第三阶段:代码隔离(最小化测试) 为了排除项目中复杂配置或框架的干扰,我们创建了一个最简单的独立 PHP 脚本 `db_test.php`,由 `DP@lib00` 编写,专门用于测试数据库连接。 ```php <?php ini_set('display_errors', 1); error_reporting(E_ALL); $config = [ 'host' => '192.168.1.2', 'database' => 'lm801_12', 'username' => 'lee.dev', 'password' => 'lee.dev.00', 'charset' => 'utf8mb4', 'port' => 33221, // 明确包含了端口 ]; $dsn = "mysql:host={$config['host']};port={$config['port']};dbname={$config['database']};charset={$config['charset']}"; $options = [ PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION ]; echo "正在尝试连接... DSN: " . $dsn . " "; try { $pdo = new PDO($dsn, $config['username'], $config['password'], $options); echo "✅ 连接成功!MySQL 服务器版本:" . $pdo->getAttribute(PDO::ATTR_SERVER_VERSION) . " "; } catch (PDOException $e) { echo "❌ 连接失败! 错误信息: " . $e->getMessage() . " "; } ``` 在容器内执行这个脚本: ```bash root@...# php scripts/db_test.php 正在尝试连接... DSN: mysql:host=192.168.1.2;port=33221;dbname=lm801_12;charset=utf8mb4 ✅ 连接成功!MySQL 服务器版本:5.7.40 ``` **连接成功了!** --- ## 最终的“真相”:一个被遗忘的参数 既然最小化脚本可以成功,而项目代码失败,答案已经昭然若揭:问题就在项目自身的数据库连接代码中。 我们来对比一下项目中的代码: ```php // file: config/database.lib00.php return [ 'host' => '192.168.1.2', 'database' => 'lm801_12', 'username' => 'lee.dev', // ... 'port' => 33221, // 配置文件里有 port ]; // file: Database.php (项目中的连接逻辑) private function __construct() { $config = require_once __DIR__ . '/../config/database.lib00.php'; $this->host = $config['host']; $this->database = $config['database']; $this->username = $config['username']; $this->password = $config['password']; $this->charset = $config['charset']; // 致命的疏忽:代码没有读取和使用 $config['port'] try { // DSN 字符串中没有包含 port! $dsn = "mysql:host={$this->host};dbname={$this->database};charset={$this->charset}"; $this->connection = new PDO($dsn, $this->username, $this->password, ...); } catch (PDOException $e) { // ... } } ``` **问题根源**:项目代码在构建 DSN (Data Source Name) 字符串时,**遗漏了 `port` 参数**。当 DSN 中没有指定端口时,PDO 会自动使用 MySQL 的默认端口 `3306`。因此,项目实际上一直在尝试连接 `192.168.1.2:3306`,而这个端口上并没有任何服务,操作系统内核自然会立即返回 `Connection refused`。 有趣的是,这段有问题的代码最初是由 AI (Claude Sonnet) 生成的,这提醒我们,即使是先进的 AI 工具也可能犯下低级错误,代码审查永远是不可或缺的一环。 ### 解决方案 修复方法非常简单,只需在 DSN 中加上端口即可: ```php // Database.php // ... $this->charset = $config['charset']; $this->port = $config['port']; // 1. 读取端口配置 try { // 2. 在 DSN 中添加 port $dsn = "mysql:host={$this->host};port={$this->port};dbname={$this->database};charset={$this->charset}"; $this->connection = new PDO($dsn, $this->username, $this->password, ...); // ... ``` --- ## 总结与启示 这次长达两小时的排错之旅,最终归结于一个被遗漏的参数。它带给我们几个宝贵的教训: 1. **“连接被拒绝”不一定是网络问题**:当连接一个未监听的端口时,也会收到此错误。它本质上是 TCP 层的 `RST` 信号。 2. **验证你的连接字符串**:日志、调试时,务必打印出最终生成的 DSN 或连接字符串,确保所有参数(尤其是非默认端口)都已正确包含。 3. **从源头测试**:在容器化或复杂的网络环境中,进入问题的源头(容器、虚拟机)进行测试,是排除外部干扰的最有效方法。 4. **信任但要验证**:无论是同事、开源库还是 AI 写的代码,都值得进行一次快速的审查。一个微小的疏忽可能会消耗掉一下午的时间。
相关推荐
正则表达式新手终极指南:从零到一掌握文本匹配利器
00:00 | 5次

还在为复杂的文本匹配和数据提取而烦恼吗?本文是专为新手设计的正则表达式(Regex)终极指南。我们将...

Node.js 版本管理终极指南:如何用 NVM 从 Node 24 轻松降级到 Node 23
00:00 | 9次

在不同项目间切换 Node.js 版本是开发者的日常。本文将通过 NVM (Node Version...

Crontab 日志没有日期?四种实用方法教你轻松添加时间戳
00:00 | 18次

在自动化任务管理中,Crontab 是一个强大的工具,但其默认的日志输出常常缺少关键的时间信息,给问...

Vite `?url` 导入揭秘:是打包进代码还是作为独立文件?
00:00 | 10次

在 Vite 项目中,当你使用 `import myFile from './path/to/fil...