はじめに
全てを脱ぎ捨てて丸腰でPHPと向き合うべく、シングルプロセスのビルドインサーバーを捨ててオレオレサーバーを作るという誰もが一度はやったことを改めてやる。
とはいっても、苦手なことを頑張るのはちょっと違うのでNginxをフロントに配置して負荷分散及びSSL対応のプロキシとして利用する。
Nginxの役割
- 複数のPHPプロセスに対するリクエストの分散
- SSL終端化
- クライアントからのリクエストをPHPにプロキシとして転送
PHPの役割
- HTTPリクエストの処理とレスポンス生成
- 静的ファイル・動的ルーティングの処理
- マルチプロセスで処理
- 強く生きる
構成について
マルチプロセスでPHPサーバーを作ったとしても子プロセスを多く用意するだけで終わりは近い。
そこで分散させることによってさらなるパワーを得る。
╭─── PHP Server 1
Nginx ───────────── ├─── PHP Server 2
(Reverse Proxy) ╰─── PHP Server 3
こうする。SessionのことはRedisで管理しとけ。(今はそれどころではない)
さっそく書いていく
server.php
<?php
$host = '0.0.0.0';
$port = $argv[1] ?? 8080;
$baseDir = __DIR__ . '/public';
$workerCount = 4;
$socket = stream_socket_server("tcp://{$host}:{$port}", $errNo, $errStr);
if (!$socket) {
die("Server startup failure: $errStr ($errNo)\n");
}
echo "Server Listening on http://{$host}:{$port}\n";
for ($i = 0; $i < $workerCount; $i++) {
$pid = pcntl_fork();
if ($pid === 0) {
workerProcess($socket, $baseDir);
exit;
}
}
for ($i = 0; $i < $workerCount; $i++) {
pcntl_wait($status);
}
function workerProcess($socket, $baseDir)
{
while (true) {
$client = @stream_socket_accept($socket);
if (!$client) {
continue;
}
$request = readRequest($client);
if (!$request) {
fclose($client);
continue;
}
$response = handleRequest($request, $baseDir);
fwrite($client, $response);
fclose($client);
}
}
function readRequest($client): string
{
$request = '';
while (($line = fgets($client)) !== false) {
$request .= $line;
if (trim($line) === '') {
break;
}
}
if (preg_match('/Content-Length: (\d+)/i', $request, $matches)) {
$contentLength = (int)$matches[1];
$body = fread($client, $contentLength);
$request .= $body;
}
return $request;
}
function handleRequest($request, $baseDir)
{
[$headers, $body] = parseRequest($request);
$uri = $headers['REQUEST_URI'] ?? '/';
$method = $headers['REQUEST_METHOD'] ?? 'GET';
$_GET = [];
$queryString = parse_url($uri, PHP_URL_QUERY);
if ($queryString) {
parse_str($queryString, $_GET);
}
$_POST = [];
if ($method === 'POST') {
parse_str($body, $_POST);
}
$_REQUEST = array_merge($_GET, $_POST);
$filePath = getFileFromUri($uri, $baseDir);
if ($filePath && is_readable($filePath)) {
if (is_dir($filePath)) {
$filePath = rtrim($filePath, '/') . '/index.php';
}
if (pathinfo($filePath, PATHINFO_EXTENSION) === 'php') {
return executePhpScript($filePath);
}
return sendStaticFile($filePath);
}
return sendResponse(404, "404 Not Found");
}
function getFileFromUri($uri, $baseDir)
{
$path = realpath($baseDir . parse_url($uri, PHP_URL_PATH));
return ($path && str_starts_with($path, realpath($baseDir))) ? $path : false;
}
function executePhpScript($filePath)
{
ob_start();
include $filePath;
$output = ob_get_clean();
return constructResponse(200, $output, "text/html; charset=UTF-8");
}
function sendStaticFile($filePath)
{
$mimeType = mime_content_type($filePath) ?: 'application/octet-stream';
$content = file_get_contents($filePath);
return constructResponse(200, $content, $mimeType);
}
function parseRequest($request): array
{
$lines = explode("\r\n", $request);
$headers = [];
$body = '';
$isBody = false;
foreach ($lines as $line) {
if ($isBody) {
$body .= $line;
} elseif (trim($line) === '') {
$isBody = true;
} elseif (preg_match('/^(GET|POST) (\S+) HTTP\/1\.\d/', $line, $matches)) {
$headers['REQUEST_METHOD'] = $matches[1];
$headers['REQUEST_URI'] = $matches[2];
} elseif (preg_match('/^(\S+): (.+)$/', $line, $matches)) {
$headers[$matches[1]] = $matches[2];
}
}
return [$headers, trim($body)];
}
function constructResponse($statusCode, $body, $contentType = 'text/plain')
{
$statusMessages = [200 => 'OK', 404 => 'Not Found'];
$statusMessage = $statusMessages[$statusCode] ?? 'Unknown';
$headers = "HTTP/1.1 {$statusCode} {$statusMessage}\r\n";
$headers .= "Content-Type: {$contentType}\r\n";
$headers .= "Content-Length: " . strlen($body) . "\r\n";
$headers .= "\r\n";
return $headers . $body;
}
function sendResponse($statusCode, $message)
{
return constructResponse($statusCode, $message);
}
ソケットサーバーの準備はstream_socket_serverを使用しTCPソケットを開く。
プロセスの分岐を行い$workerCountの数だけフォークして複数のプロセスがクライアントリクエストを処理する。
ワーカープロセスはクライアントの接続を待機しリクエストを受信して内容を見る。
リクエストに応じて適度にレスポンスを生成してクライアントに送信。
(ここでHTTPリクエストの種別を確認して処理を入れてなくてGET, POSTが捨てられてることに気が付かなかった...)
あとはレスポンスを生成してターンエンド。
やったこと
- HTTPサーバーを作った
- PCNTLを使用してマルチプロセス処理を実現した
- 静的ファイルの処理をNginxに任せずPHPスクリプトで対応した
- 手作り感満載のURIルーティング作った
不足を上げたら終りが見えないのでこのあたりで実際に動かす。
動かすための準備をする
まずはフロント側をどうしたいか考え、構成の通り3台のバックエンドにリクエストが分散されるようにする。
nginx.conf
http {
upstream php_backend {
server php1:8080;
server php2:8080;
server php3:8080;
keepalive 32;
}
server {
listen 80;
location / {
proxy_pass http://php_backend;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
}
あとは、そうなるようにDockerのyml捏ねていく
version: '3.9'
services:
nginx:
build:
context: ./nginx
dockerfile: ./Dockerfile
ports:
- "8080:80"
depends_on:
- php1
- php2
- php3
networks:
- app-network
php1:
build:
context: ./php
dockerfile: Dockerfile
ports:
- "8081:8081"
volumes:
-
networks:
- app-network
php2, 3同上(portsだけ変える)
networks:
app-network:
driver: bridge
Dockerfileはご自由に。phpはpcntlが必要。
実践
ちゃんと並列で動くかを確認する。
index.php
<?php
echo 'OK';
sleep(1);
同時接続数10で30秒間アクセスする。
1秒間スリープさせているので理論値上360リクエストが可能なはずだ...。
siege -c10 -t30S http://localhost:8080
Lifting the server siege...
Transactions: 126 hits
Availability: 100.00 %
Elapsed time: 30.24 secs
Data transferred: 0.00 MB
Response time: 2296.35 ms
Transaction rate: 4.17 trans/sec
Throughput: 0.00 MB/sec
Concurrency: 9.57
Successful transactions: 126
Failed transactions: 0
Longest transaction: 7030.00 ms
Shortest transaction: 1000.00 ms
パフォーマンス分析
- 理論最大値:360リクエスト(30秒 × 4プロセス × 3サーバー)
- 実際の処理数:126リクエスト(理論値の約35%)
- 実行効率の低下要因:
- 同一ホスト内のコンテナ間通信のオーバーヘッド
- Nginxのプロキシ処理
- プロセス切り替えのコスト
- ネットワークレイテンシ
並列接続数9.57は設定した同時接続数10に近い値となっており、
負荷分散自体は期待通りに機能していると考えられる。
課題は山積みだが裸一貫でPHPと向き合えたのではないだろうか。
最後に
こいつでちょっと複雑な処理をさせるとすぐに死ぬことは知っている。
ビルドインサーバーはとても良くできている。
くだらないことしてないでApache mod_php, Nginx php-fpm | unitを使おう!
本投稿の訂正について
以下の記事で反省しております。