エラーの内容
Fatal error: Uncaught PDOException: SQLSTATE[HY000] [2002] Connection refused
Stack trace:
#0 /var/www/html/db.php(5): PDO->__construct('mysql:host=loca...', 'root', '***')
#1 {main}
thrown in /var/www/html/db.php on line 5
アプリが500エラーを返し続け、ログが大量に吐き出され、リクエストが積み上がっている状態です。このエラーは、PHPがMySQLへのTCP接続を一切開けない状態を意味します――認証に到達する前に弾かれています。以下の手順を順番に確認してください。
原因
SQLSTATE[HY000] [2002] はクライアント側のネットワークエラーです。MySQLクライアントが接続を試みたところ、拒否されました。実際の現場で起きる原因の95%は以下の5つに集約されます。
- MySQL/MariaDBサービスが停止またはクラッシュしている
- PDO DSN文字列のホストまたはポートが間違っている
localhostと127.0.0.1のUnixソケット問題- MySQLの
bind-addressがアプリのIPからの接続をブロックしている - ファイアウォールルールがポート3306のトラフィックを遮断している
手順1: MySQLは実際に動いているか?
深夜2時に最もよくある原因――サービスがクラッシュしていて誰も気づいていないケースです。まずここを確認してください。
sudo systemctl status mysql
# MariaDB
sudo systemctl status mariadb
failed または inactive と表示されている場合は起動してください。
sudo systemctl start mysql
sudo systemctl status mysql
起動に失敗する場合は、何もいじる前にログを確認してください。
sudo journalctl -u mysql -n 100 --no-pager
# または
sudo tail -100 /var/log/mysql/error.log
MySQLが起動時に落ちる原因は主に3つです。ディスク容量の不足(df -h で確認――ディスク100%が最多原因です)、InnoDBファイルの破損、または別プロセスがすでにポート3306を使用している(sudo ss -tlnp | grep 3306)。根本原因を修正してから次へ進んでください。
手順2: PDO DSNを確認する
ホストのタイポ、ポートの誤り、環境変数が空のいずれの場合も [2002] が発生します。DSNが正しいと決めつける前に、実際の実行時の値を出力して確認してください。
<?php
// アプリが実際に使っている接続パラメータを出力する
$host = $_ENV['DB_HOST'] ?? getenv('DB_HOST') ?: '(not set)';
$port = $_ENV['DB_PORT'] ?? getenv('DB_PORT') ?: '3306';
$dbname = $_ENV['DB_NAME'] ?? getenv('DB_NAME') ?: '(not set)';
$user = $_ENV['DB_USER'] ?? getenv('DB_USER') ?: '(not set)';
echo "DSN will be: mysql:host={$host};port={$port};dbname={$dbname}\n";
echo "User: {$user}\n";
よくある3つのミス: DB_HOST が誤ったサーバーに解決されている(または解決されない)。ポートが間違っている――MySQLのデフォルトは3306ですが、Docker環境では3307や33060にリマップされていることがよくあります。または .env ファイルが読み込まれておらず、すべての変数が空になってDSNが mysql:host=;port=3306;dbname= になっている。
手順3: localhost と 127.0.0.1 のソケット問題
PHP開発者がよくはまる落とし穴です。PDO DSNで host=localhost を指定すると、MySQLクライアントライブラリはTCPを無視して、代わりにUnixソケットファイル経由で接続しようとします。PHPとMySQLでソケットのパスが一致しない場合、MySQLが正常に動いていても [2002] が発生します。
// Unixソケットを使用――ソケットパスが一致しないと無言で失敗する
$dsn = 'mysql:host=localhost;dbname=myapp';
// TCPを強制使用――ほぼすべての環境で確実に動作する
$dsn = 'mysql:host=127.0.0.1;dbname=myapp';
PHPとMySQLのソケットパスが一致しているか確認してください。
# PHPのpdo_mysqlがソケットを探す場所
php -r "echo ini_get('pdo_mysql.default_socket') . PHP_EOL;"
# MySQLが実際にソケットを作成する場所
grep -E "^socket" /etc/mysql/mysql.conf.d/mysqld.cnf
# または
mysqladmin -u root -p variables 2>/dev/null | grep socket
パスが一致しない場合は、DSNを 127.0.0.1 に変更するのが最も手早い解決策です。あるいは、php.ini の pdo_mysql.default_socket をMySQLの実際のソケットパスに合わせて設定し、PHP-FPMをリロードしてください(sudo systemctl reload php8.2-fpm)。
手順4: MySQLのbind-addressを確認する
アプリサーバーとDBサーバーが別々になっている場合や、各コンテナが独自のネットワーク名前空間を持つDocker環境の場合は、bind-address が問題になることがあります。
# MySQLがどのインターフェースでリッスンしているか確認
grep bind-address /etc/mysql/mysql.conf.d/mysqld.cnf /etc/mysql/my.cnf 2>/dev/null
# ssコマンドで確認
sudo ss -tlnp | grep 3306
bind-address = 127.0.0.1 の場合、MySQLはローカル以外のすべての接続を拒否します。リモートアクセスを許可するよう変更してください。
# /etc/mysql/mysql.conf.d/mysqld.cnf
[mysqld]
bind-address = 0.0.0.0
sudo systemctl restart mysql
次に、アプリサーバーのIPからの接続を許可するMySQLユーザー権限を設定します。構文はバージョンによって異なります。
-- MySQL 5.7以前
GRANT ALL PRIVILEGES ON myapp.* TO 'myuser'@'APP_SERVER_IP' IDENTIFIED BY 'password';
FLUSH PRIVILEGES;
-- MySQL 8.0以降: 先にユーザーを作成してからGRANT
CREATE USER IF NOT EXISTS 'myuser'@'APP_SERVER_IP' IDENTIFIED BY 'password';
GRANT ALL PRIVILEGES ON myapp.* TO 'myuser'@'APP_SERVER_IP';
FLUSH PRIVILEGES;
手順5: ファイアウォールを確認する
# UFW
sudo ufw status verbose
# iptables
sudo iptables -L -n | grep 3306
# アプリサーバーから(DBサーバーからではなく)到達可能かテスト
nc -zv DB_SERVER_IP 3306
# または
telnet DB_SERVER_IP 3306
接続が拒否されるかタイムアウトする場合は、インターネット全体に開放せず、アプリサーバーのIPに限定したファイアウォールルールを追加してください。
sudo ufw allow from APP_SERVER_IP to any port 3306
sudo ufw reload
手順6: Docker / コンテナ環境
アプリとMySQLを別々のコンテナで動かしている場合、localhost と 127.0.0.1 はどちらもコンテナ自身を指します――隣のMySQLコンテナではありません。docker-compose.yml で定義したサービス名を使用してください。
<?php
// 'mysql' はdocker-compose.ymlのサービス名
$dsn = 'mysql:host=mysql;port=3306;dbname=myapp';
# コンテナ同士が実際に通信できるか確認
docker exec app-container nc -zv mysql 3306
# MySQLコンテナが正常かどうか確認
docker ps | grep mysql
docker logs mysql-container-name --tail 50
修正の確認
変更後は、このスクリプトをサーバー上で直接実行してください。ブラウザの出力は信用しないでください――キャッシュされていたり、キューに溜まっていたり、別のインスタンスにルーティングされている可能性があります。
<?php
$dsn = 'mysql:host=127.0.0.1;port=3306;dbname=myapp';
$user = 'myuser';
$pass = 'mypassword';
try {
$pdo = new PDO($dsn, $user, $pass, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_TIMEOUT => 5,
]);
echo "Connected OK\n";
echo "Query OK: " . $pdo->query('SELECT 1')->fetchColumn() . "\n";
} catch (PDOException $e) {
echo "FAILED: " . $e->getMessage() . "\n";
}
php /tmp/test_db.php
期待する結果: Connected OK に続いて Query OK: 1 が表示されることです。まだ [2002] が出る場合は、エラーメッセージをよく読んでください――PHPが接続しようとした正確なホストとポートが含まれているはずです。それがDSNの誤りを特定する手がかりになります。
再発防止のポイント
- 必ず
PDO::ATTR_TIMEOUTを設定する: 設定しないと、MySQLサーバーが応答しない場合にPHPワーカーが無期限にブロックされ、アプリ全体が落ちます。5秒が無難なデフォルト値です――フリーズするより速く失敗させる設計にしましょう。 - DSNでは
localhostの代わりに127.0.0.1を使う。Unixソケットを使う特別な理由がない限り、これを推奨します。 /healthzエンドポイントを追加する。SELECT 1を実行してHTTP 200または503を返すエンドポイントを設置しておくと、ユーザーにエラーが見える前にロードバランサーがそのインスタンスをプールから外せます。- systemdでMySQLの自動再起動を有効にする: MySQLサービスユニットに
Restart=on-failureとRestartSec=5を追加してください。一時的なクラッシュから誰も起こさずに復旧できます。 - 稼働監視ツールでポート3306を監視する――怒ったユーザーからの報告で気づくより、1分以内のアラートの方がはるかにましです。

