“连接被拒绝”的终极解密:当 PHP PDO 遇上 Docker 和一个被遗忘的端口
内容
## 问题背景:一个“不可能”的错误
想象一下这个场景:你正在开发一个项目,其中 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 写的代码,都值得进行一次快速的审查。一个微小的疏忽可能会消耗掉一下午的时间。
关联内容
MySQL分区终极指南:从创建、自动化到避坑,一文搞定!
时长: 00:00 | DP | 2025-12-01 08:00:00MySQL索引顺序的艺术:从复合索引到查询优化器的深度解析
时长: 00:00 | DP | 2025-12-01 20:15:50MySQL中TIMESTAMP与DATETIME的终极对决:深入解析时区、UTC与存储奥秘
时长: 00:00 | DP | 2025-12-02 08:31:40群晖 NAS 部署 MySQL Docker 踩坑记:轻松搞定“Permission Denied”权限错误
时长: 00:00 | DP | 2025-12-03 21:19:10Docker 容器如何访问 Mac 主机?终极指南:轻松连接 Nginx 服务
时长: 00:00 | DP | 2025-12-08 23:57:30PHP 终极指南:如何正确处理并存储 Textarea 中的 Markdown 换行符
时长: 00:00 | DP | 2025-11-20 08:08:00相关推荐
正则表达式新手终极指南:从零到一掌握文本匹配利器
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...