# 從 Nginx + PHP-FPM 轉換 Laravel Octane + RoadRunner
###### tags: `Laravel`,`PHP`,`RoadRunner`,`Octane`
Nginx + PHP-FPM 的組合從 2010 年後稱霸 PHP 應用程式伺服器,PHP-FPM 也是 PHP 官方標準,所以 Nginx + PHP-FPM 的組合基本上是目前 PHP 網站的主流。
然而 Laravel 搭配 Nginx + PHP-FPM 在高併發時仍會出現一些問題:
- Laravel:每個 HTTP request 都會都會重新載入 service provider、env 設定、解析路由及控制器等等初始化工作,降低了效能。
- PHP-FPM:
- 每個 worker process 只能處理一個 request,如果達到最大 process 數量(`pm.max_children`),就會剩下的 request 就會進入等待,可能會發生 timeout 的狀況。
- process 通常會設定 dynamic 模式,也就是根據 server 機器規格,設定最小數量(`pm.start_servers`),跟動態擴展的最大數量(`pm.max_children`),若在短時間大量請求時,會花費大量 CPU 去 fork 新的 process。另外 process 也會佔用記憶體資源,即時是閒置的狀態。
- 未支援長連結(ex: WebSocket)。
為了解決高併發時,啟動新 process 初始化問題,新的架構 Laravel Octane + RoadRunner 誕生了:
- [Laravel Octane](https://laravel.com/docs/master/octane):
Laravel 官方推出的高效能應用程式伺服器, Octane 讓 Laravel 應用保持在記憶體中,無需重新載入初始化設定,減少重新啟動的開銷,大幅提升請求處理速度。另外 Octane 也支援 FrankenPHP、Swoole、RoadRunner 這些高效能的 PHP app server。
Octane 在 Start 時就進行初始化 Laravel app 跟 kernel,之後的每個 request 使用記憶體儲存的 app 跟 kernel,而非每個 request 進行 app 跟 kernel 初始化,減少初始化時間。但是由於不會執行 kernel terminate 階段,對於有些 terminate 階段才會做執行的功能,可能要特別注意有無問題,[Mololog 的 Buffer Handler](https://hackmd.io/VLu8f2ywRbyLtpzejE-Igg) 就是一的例子。
- [RoadRunner](https://docs.roadrunner.dev/docs):高效能的 PHP app server,由 Golang 撰寫,負責負載均衡及進程管理,並把請求分配給 PHP Worker。其最大特點是把 PHP 應用程序保持在記憶體中,減少啟動成本。另外也支援長連結跟 gRPC。
## 轉換流程
轉換流程很簡單,就分成 RoadRunner 跟 Laravel Octane 兩部分。
### RoadRunner
本人原本專案 Dockerfile 會安裝 Nginx, Supervisor, php-fpm,並把 config 檔案 COPY 至 image 裡,如下:
```dockerfile
FROM alpine:3.20
COPY nginx.conf /etc/nginx/http.d/default.conf
COPY upstream.conf /etc/nginx/http.d/upstream.conf
COPY php.ini /etc/php82/conf.d/99_override.ini
COPY php-fpm.ini /etc/php82/php-fpm.d/www.conf
COPY supervisord-web.conf /etc/supervisor/conf.d/supervisord.conf
RUN adduser -u 1001 -D -S -G www-data www-data && \
apk add --no-cache --update \
curl-dev \
openssl-dev \
supervisor \
nginx \
php82 \
php82-fpm \
php82-pdo \
php82-pdo_mysql \
...(略)
WORKDIR /var/www
CMD ["/usr/bin/supervisord", "-c", "/etc/supervisor/conf.d/supervisord.conf"]
```
轉換成 RoadRunner 後,只保留 PHP 及套件,外加上 RoadRunner Server 和設定檔為 `.rr.yaml`。另外值得注意的是 RoadRunner 預設 `WORKDIR` 是 `/app`,跟 nginx 預設不同,當然可以從設定檔去做調整。
```dockerfile
FROM ghcr.io/roadrunner-server/roadrunner:2024 as roadrunner
FROM alpine:3.20
COPY .rr.yaml /etc/.rr.yaml
RUN apk add --no-cache --update \
curl-dev \
openssl-dev \
php82 \
php82-pdo \
php82-pdo_mysql \
...(略)
# Copy RoadRunner
COPY --from=roadrunner /usr/bin/rr /usr/bin/rr
EXPOSE 80/tcp
WORKDIR /app
```
### Laravel Octane
Laravel Octane 可透過 composer 跟 php artisan 指令安裝,Laravel Octane 會處理框架本身、PHP Workers 與 RoadRunner 彼此間的介接問題。安裝 Octane 下列步驟
- 安裝 Octane
```
composer require laravel/octane
php artisan octane:install
```
若執行 `php artisan octane:install` 無法正常安裝,可自行手動安裝 RoadRunner 套件:
```
composer require spiral/roadrunner
composer require spiral/roadrunner-http
composer require spiral/roadrunner-cli
```
- 增加環境變數 OCTANE_SERVER
`.env` 增加環境變數 `OCTANE_SERVER=roadrunner`
- Laravel Octance 支援[檔案異動監聽](https://laravel.com/docs/12.x/octane#watching-for-file-changes)
啟用檔案異動監聽才不需要在異動檔案時另外手動重啟 Octance Server,在開發環境是很實用的,若要啟用,在 Start Octane 時增加 `--watch` 標記即可,但環境必須安裝 node.js 和 [chokidar](https://github.com/paulmillr/chokidar) 套件(`yarn add chokidar --dev`)。
上述 Laravel Octane 相關的套件安裝完成後,可以開始處理 web 服務的 image 了,在 Build 線上環境的 image 時,通常會從基底 server image 開始,再把必要的 Laravel 資料夾/檔案複製打包成 web 服務專用的 image
```dockerfile
FROM <上一步驟 RoadRunner Serve Image 存放位置>
ARG APP_KEY
ARG AWS_ACCESS_KEY_ID
ARG AWS_SECRET_ACCESS_KEY
...(略)
ENV APP_KEY=$APP_KEY
ENV AWS_ACCESS_KEY_ID=$AWS_ACCESS_KEY_ID
ENV AWS_SECRET_ACCESS_KEY=$AWS_SECRET_ACCESS_KEY
...(略)
WORKDIR /app
COPY app ./app
COPY bootstrap ./bootstrap
COPY config ./config
COPY database ./database
COPY public ./public
COPY resources ./resources
COPY routes ./routes
COPY storage ./storage
COPY vendor ./vendor
COPY artisan ./artisan
COPY composer.json ./
COPY composer.lock ./
COPY server.php ./
COPY .rr.yaml ./
COPY entrypoint.sh ./
RUN chmod +x /app/entrypoint.sh
ENTRYPOINT ["/app/entrypoint.sh"]
```
原本是透過 Supervisor 啟用/監控 nginx 跟 php-fpm 運作,是否需要透過 Supervisor 啟用/監控 Laravel Octane 看系統需求,假設 Octane Server 掛了,Supervisor 會嘗試自動重啟,但 K8S 通常會設定 Liveness Probe,Octane Server 掛了,health api 沒有回應,K8S 也會自動重啟 pod,所以就看需求囉!
這邊例子沒有用 Supervisor,直接改成 Run container 時執行 `entrypoint.sh`,該 shell script 會透過 `php artisan octane:start` 指令啟用 Octane Server,以下範例 workers 數量設定為 2 倍的 CPU 核心數。
```shell
#!/bin/sh
CORES=$(nproc --all)
WORKERS=$((CORES * 2))
exec php artisan octane:start --server=roadrunner --host=0.0.0.0 --rpc-port=6001 --port=80 --workers=$WORKERS
```
#### 注意事項
Laravel Octane 提供 workers 數量設定(`--workers`)以及每個 worker 可以處理 request 最大上限(`--max-requests`),若未特別設定,Octane 預設**並非**使用 roadrunner `.rr.yaml` 設定檔的數值(`pool.num_workers` & `pool.max_jobs`),而是把 workers 數量設定為 CPU 核心數,request 最大上限設定為 500。
## 差異測試
以下使用一樣的 K8S 機器、一致 Laravel/PHP 版本和程式架構,針對單一 pod 做簡單的 health api 壓力測試
### Nginx + PHP FPM
- 每個 Nginx Worker 加上 PHP-FPM Worker 佔了大概三百多 mb 虛擬記憶體,這個 pod 有 8 組 workers。

- 當一秒發送 200 requests,99% 的 requests 300 ms 前就回應,不過 CPU 部分就很明顯往上衝了,如果觸發 HPA 設定的 CPU 使用率,可能就會進行 autoscaling。目前使用的系統架構,每秒超過 200 requests 就會觸發 autoscaling(80% CPU 使用率)。


### Laravel Octance + RoadRunner
- 一樣 8 個 RoadRunner workers,RoadRunner workers 佔用的虛擬記憶體比較小,大概只有一百多 mb。

- 當一秒發送 200 requests,response 回應速度稍微慢一些,不過 CPU 使用率只有微微上升。


- 當一秒發送 500 requests,response 回應速度明顯慢了許多,而 CPU 使用率往上衝到了 autoscaling 臨界點(80% CPU 使用率),不過整體而言 Laravel Octance + RoadRunner 的 CPU 使用表現上會比較好一些。

