容器全解:從Docker部署到依賴注入雙視角拆解)
寫這篇之前我想先問你一個問題當(dāng)你聽到“PHP服務(wù)容器”這六個字時腦子里蹦出來的是什么Docker里跑著php-fpm的鏡像還是Laravel文檔里那個能裝一堆服務(wù)的Application容器說實(shí)話我在社區(qū)里見過太多人把這兩件事攪在一起——有人興沖沖地寫了一堆Dockerfile結(jié)果連PHP-FPM為什么在容器里會多進(jìn)程都沒搞清也有人天天掛在嘴邊的“服務(wù)容器”其實(shí)是框架的依賴注入容器跟部署環(huán)境半毛錢關(guān)系都沒有。這篇就按“庖丁解?!钡穆纷影衙恳粚佣疾痖_給你看從PHP進(jìn)程的運(yùn)行原理到容器鏡像與編排的實(shí)操再到框架層面服務(wù)容器的解析機(jī)制最后把上線后最容易踩的坑一次性端出來。適合那些已經(jīng)把PHP項(xiàng)目跑起來、但一直覺得“容器”這個事兒隔著一層紗的開發(fā)者也適合Debug到深夜卻不知道從哪下手的部署新手。1. 先別急著寫Dockerfile兩類“服務(wù)容器”得先分開我第一次接觸“服務(wù)容器”這個詞是在Laravel的文檔里那時候它指的是那個可以綁定、解析類實(shí)例的對象容器。后來又聽人說“把PHP服務(wù)容器化”我才意識到這里說的其實(shí)是運(yùn)行環(huán)境容器。這兩個概念都叫容器但解決的問題完全不同如果一開始不把它們分開后面學(xué)什么都容易串味。1.1 環(huán)境容器解決“在我電腦上明明是好的”環(huán)境容器要解決的是環(huán)境漂移問題。你有沒有遇到過這種場景本地用的是PHP 8.2線上是7.4寫著寫著某個函數(shù)在本地好好的一上線就報(bào)錯或者本地裝了一堆擴(kuò)展線上機(jī)器光缺一個pdo_mysql就讓你排查半天。這種問題不是代碼邏輯問題是運(yùn)行環(huán)境不一致導(dǎo)致的。Docker這類環(huán)境容器的做法是把PHP解釋器、擴(kuò)展、配置文件、甚至操作系統(tǒng)的底層庫全部固化成一個鏡像。你在一臺新機(jī)器上把鏡像跑起來得到的PHP環(huán)境和本地完全一致。這就像把整個廚房包括鍋碗瓢盆調(diào)料灶臺全搬走而不是只帶著菜譜到處跑。實(shí)操層面最常見的是用php:8.3-fpm這類官方鏡像作為基礎(chǔ)鏡像。官方鏡像的好處是已經(jīng)幫你編譯好了PHP解釋器自帶docker-php-ext-install和docker-php-ext-enable這類工具裝擴(kuò)展時不需要你自己搞定編譯鏈。1.2 依賴注入容器解決“代碼里new出來的依賴太硬”框架里的服務(wù)容器則是另一套東西。它的核心價(jià)值是讓類的實(shí)例化過程變得可管理。打個比方你寫了一個ReportService它需要一個數(shù)據(jù)庫連接、一個緩存對象、還需要發(fā)郵件。不用容器的時候你得在ReportService內(nèi)部自己new PDO、new Redis、new Mailer所有依賴都是寫死的。一旦數(shù)據(jù)庫連接參數(shù)變了還得去改ReportService的代碼。更麻煩的是單元測試時想換一個假的數(shù)據(jù)庫連接根本換不進(jìn)去。依賴注入容器把這個過程反轉(zhuǎn)了ReportService只聲明“我需要一個數(shù)據(jù)庫連接”容器根據(jù)配置把合適的連接實(shí)例塞給它??蚣芾锿ǔ=锌刂品崔D(zhuǎn)IoC或依賴注入DI。這個容器就像一個后勤調(diào)度員誰需要什么就給誰送什么。所以在往下讀之前你得先確定自己說的“服務(wù)容器”是哪一個。這篇會把兩個都講清楚但重點(diǎn)是前者——因?yàn)榘裀HP跑進(jìn)容器是很多人從“寫著能跑”走向“部署靠譜”的必經(jīng)之路。2. 容器里那個PHP進(jìn)程是怎么活著的FastCGI與FPM調(diào)度拆解很多人以為PHP容器就是把PHP裝進(jìn)一個小盒子里其實(shí)沒那么簡單。你的PHP代碼并不是像Node.js那樣一個進(jìn)程常駐內(nèi)存而是由一個主進(jìn)程統(tǒng)一調(diào)度按需啟動工作進(jìn)程來處理請求。這個機(jī)制搞不明白你寫Dockerfile的時候就會到處是玄學(xué)。2.1 傳統(tǒng)落地方式的差異CGI、FastCGI與PHP-FPM早期PHP最常見的形式是Apache加載mod_phpApache本身就是Web服務(wù)器PHP作為它的一個模塊運(yùn)行。這種方式相對簡單但每個Apache進(jìn)程都會內(nèi)置一個PHP解釋器內(nèi)存占用高并發(fā)一大就吃力。后來主流架構(gòu)變成了Nginx配PHP-FPM。Nginx只負(fù)責(zé)處理靜態(tài)文件、作反向代理遇到.php請求時把請求通過FastCGI協(xié)議轉(zhuǎn)交給PHP-FPM進(jìn)程處理。PHP-FPM是一個獨(dú)立的進(jìn)程管理器它專門負(fù)責(zé)PHP代碼的解釋執(zhí)行。FastCGI協(xié)議本身可以看作“長駐進(jìn)程版的CGI”。老式的CGI每次請求都要重新啟動一個PHP進(jìn)程完成后立刻銷毀開銷非常大。FastCGI則是讓PHP進(jìn)程跑起來后一直待命Nginx把請求通過Unix套接字或TCP端口傳遞過去PHP處理完再返回結(jié)果。這樣省去了反復(fù)創(chuàng)建銷毀進(jìn)程的損耗。PHP-FPM在FastCGI之上做了進(jìn)程池管理這就是整個服務(wù)容器最核心的部分。2.2 PHP-FPM的master-worker模型PHP-FPM啟動后會有兩類進(jìn)程一個master主進(jìn)程和若干個worker工作進(jìn)程。master負(fù)責(zé)讀配置文件、監(jiān)聽端口、管理worker的生命周期worker才是真正執(zhí)行PHP代碼的進(jìn)程。worker進(jìn)程的數(shù)量不是越多越好它由配置文件里的pm.max_children控制。pm有三種模式static固定worker數(shù)、dynamic動態(tài)調(diào)整、ondemand按需啟動。生產(chǎn)環(huán)境比較常用的配置類似這樣[www] user www-data group www-data listen 127.0.0.1:9000 pm dynamic pm.max_children 20 pm.start_servers 5 pm.min_spare_servers 3 pm.max_spare_servers 10 pm.max_requests 500這里有個很關(guān)鍵的概念pm.max_requests。它表示一個worker處理完500個請求后就主動重啟自己。為什么要這么做因?yàn)樵俸玫腜HP代碼也可能有內(nèi)存泄漏或者某些擴(kuò)展會緩慢累積內(nèi)存。定期讓worker“自盡重生”能防止單個worker占用內(nèi)存持續(xù)膨脹。這是我在容器環(huán)境里特別建議保留的配置。2.3 PHP 8.3在容器里的升級細(xì)節(jié)如果你是從老版本升級到PHP 8.3容器里要注意的不只是PHP本身的變化。最典型的是擴(kuò)展需要重新編譯——官方基礎(chǔ)鏡像不會自動幫你保留舊擴(kuò)展。比如redis這類通過pecl安裝的擴(kuò)展升級基礎(chǔ)鏡像版本后必須重跑一次安裝命令pecl install redis docker-php-ext-enable redisPHP 8.3本身帶來了一些底層優(yōu)化比如更好的類型推斷、新的#[Override]特性、json_validate()函數(shù)等。但在容器部署層面這些東西并不會自動生效——你依然要手動把opcache開起來并把opcache.enable_cli按需配置好。很多人在容器里跑PHP明明用了8.3卻覺得性能沒什么變化檢查一下通常會發(fā)現(xiàn)opcache壓根沒啟用。3. 動手組裝一套生產(chǎn)可用的PHP服務(wù)容器鏡像、編排、健康檢查一次配齊概念講完了接下來是最值錢的部分怎么“庖丁解?!笔降匕岩粋€PHP服務(wù)容器真正組裝出來。我不會直接把一個現(xiàn)成的生產(chǎn)Dockerfile丟給你就完事而是把每一步背后的為什么說清楚這樣你遇到問題才能自己調(diào)整。3.1 為什么用官方php:8.3-fpm而不是自己裝很多新手喜歡寫一個裸的FROM ubuntu:22.04然后apt-get install php再手動編譯擴(kuò)展。我建議新手不要這么干除非你有非常特殊的需求比如需要某個特定補(bǔ)丁的PHP版本。官方PHP鏡像已經(jīng)解決了幾個麻煩一是PHP編譯參數(shù)經(jīng)過充分測試性能穩(wěn)妥二是apt源里已經(jīng)準(zhǔn)備好了編譯擴(kuò)展所需的依賴三是它有一個標(biāo)準(zhǔn)化的入口腳本docker-php-entrypoint會在容器啟動后正確處理FPM的信號轉(zhuǎn)發(fā)。自己從Ubuntu裝PHP最痛苦的是PHP版本會被apt倉庫鎖死想用8.3得折騰第三方源沒必要。用官方鏡像升級PHP版本時只要把FROM php:8.3-fpm改成php:8.4-fpm即可。3.2 Dockerfile的寫法與每一行的理由一個比較可靠的PHP服務(wù)容器Dockerfile可以這樣寫FROM php:8.3-fpm RUN apt-get update apt-get install -y \ libzip-dev \ libicu-dev \ libpq-dev \ unzip \ git \ curl \ docker-php-ext-install \ pdo_mysql \ mysqli \ zip \ bcmath \ opcache \ intl \ pecl install redis-6.0.2 \ docker-php-ext-enable redis \ docker-php-source delete COPY php.ini /usr/local/etc/php/conf.d/custom.ini COPY --fromcomposer:2 /usr/bin/composer /usr/bin/composer WORKDIR /var/www/html EXPOSE 9000 CMD [php-fpm]每行都有講究。docker-php-ext-install會自動編譯并啟用擴(kuò)展但前提是系統(tǒng)里已經(jīng)有對應(yīng)的開發(fā)庫所以libzip-dev、libicu-dev這些必須提前裝好。你可能注意到我沒有裝gd因?yàn)間d編譯需要額外處理jpeg、png、webp等庫我會在需要圖片處理的項(xiàng)目里單獨(dú)加RUN apt-get install -y libpng-dev libjpeg-dev libwebp-dev \ docker-php-ext-configure gd --with-jpeg --with-webp \ docker-php-ext-install gdCOPY --fromcomposer:2這行用的是多階段構(gòu)建從Composer官方鏡像里拷貝一個composer可執(zhí)行文件過來這樣鏡像里沒有多余的Composer源碼包干凈又能用。3.3 用docker-compose把它們串成一套服務(wù)單跑一個PHP-FPM容器是沒法提供服務(wù)器的至少還得配一個Nginx來接收HTTP請求。docker-compose.yml的典型結(jié)構(gòu)如下services: php: build: context: . dockerfile: Dockerfile container_name: app_php restart: unless-stopped working_dir: /var/www/html volumes: - ./src:/var/www/html networks: - app_net nginx: image: nginx:1.27-alpine container_name: app_nginx restart: unless-stopped ports: - 8080:80 volumes: - ./src:/var/www/html - ./nginx/conf.d:/etc/nginx/conf.d depends_on: - php networks: - app_net networks: app_net: driver: bridge注意./src:/var/www/html這個volume在php和nginx兩個容器里都掛載了。Nginx要找PHP文件PHP也要執(zhí)行這些文件所以兩邊都得能看到同一份代碼。開發(fā)階段直接掛載源碼是熱更新的利器改完代碼不用重新build鏡像生產(chǎn)環(huán)境則建議把代碼固化進(jìn)鏡像用新鏡像替換舊容器避免代碼和運(yùn)行環(huán)境版本不匹配。Nginx的配置里最關(guān)鍵的一行是這個location ~ \.php$ { fastcgi_pass php:9000; fastcgi_index index.php; fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; include fastcgi_params; }fastcgi_pass php:9000里的php不是IP地址而是docker-compose網(wǎng)絡(luò)里PHP容器的服務(wù)名。容器間的DNS自動解析機(jī)制會把這個名字解析成PHP容器的IP。很多新手在這一步卡住以為要寫成localhost:9000結(jié)果在Nginx容器里根本沒有PHP進(jìn)程監(jiān)聽。3.4 健康檢查與優(yōu)雅停機(jī)細(xì)節(jié)決定運(yùn)維體驗(yàn)容器運(yùn)行起來不等于萬事大吉。如果PHP容器掛了Nginx還會傻乎乎地往9000端口轉(zhuǎn)請求用戶看到的是一片502。這時優(yōu)雅的做法是配置healthcheckhealthcheck: test: [CMD, php, -r, exit(extension_loaded(pdo_mysql) ? 0 : 1);] interval: 30s timeout: 5s retries: 3這個檢查腳本簡單直接檢查關(guān)鍵擴(kuò)展是否還加載著。你也可以更嚴(yán)格一些比如寫一個健康檢查路由讓PHP請求一次數(shù)據(jù)庫返回200。另一個容易被忽略的點(diǎn)是容器停止行為。PHP-FPM默認(rèn)收到的是SIGTERM但官方鏡像的入口腳本做了信號轉(zhuǎn)發(fā)。如果你自己定制了啟動命令一定要保證能正確處理退出信號否則docker compose down會等待超時每次停服都要等十幾秒。4. 上線后最容易踩的四個坑OOM、數(shù)據(jù)卷、擴(kuò)展依賴與排查工具缺失把PHP服務(wù)容器跑起來只是開始。我踩過的這幾個坑隨便拎出來一個都足夠讓你熬夜。4.1 OOM內(nèi)存限制下FPM worker被批量收割在裸機(jī)時代你的PHP-FPM可以隨意吃掉幾個GB內(nèi)存因?yàn)闄C(jī)器內(nèi)存足夠大。但容器是有內(nèi)存限制的比如給PHP容器設(shè)了mem_limit: 2g而每個FPM worker吃50MB那么pm.max_children按經(jīng)驗(yàn)公式就是2000/50約等于40頂多再保守一點(diǎn)。如果不按這個邏輯設(shè)置典型的癥狀是訪問量稍微上來一點(diǎn)容器直接被OOM Killer殺掉然后restart: unless-stopped讓它重啟重啟后緩存全冷又被打爆。如此循環(huán)往復(fù)像極了“死循環(huán)”。解決思路有兩個層次。第一層合理設(shè)置pm.max_children。第二層為PHP容器開啟swap空間或者重新調(diào)整內(nèi)存限制。還有一個更細(xì)的排查方法在宿主機(jī)上用dmesg | grep -i oom看看是不是容器進(jìn)程被殺如果日志里出現(xiàn)Killed process基本就鎖定問題方向了。4.2 容器重啟后“上傳圖片/日志全沒了”這個問題幾乎人人都會遇到一次。你把代碼目錄掛載為數(shù)據(jù)卷了但用戶上傳的圖片、生成的日志、甚至Session文件都存在容器內(nèi)部的可寫層。容器一重建這些文件蕩然無存。正確做法是把這些需要持久化的目錄單獨(dú)用volume掛載出來volumes: - ./src:/var/www/html - upload_data:/var/www/html/storage/uploads - log_data:/var/www/html/storage/logs更進(jìn)一步我建議把storage目錄整個掛載出來因?yàn)榭蚣艿木彺嫖募?、視圖編譯文件也在里面。這樣即使容器重建也不會影響到用戶數(shù)據(jù)和已經(jīng)生成的緩存重啟后的冷啟動時間會短很多。4.3 擴(kuò)展裝不上多半是C庫依賴沒補(bǔ)齊我見過很多人在Dockerfile里費(fèi)勁巴拉地pecl install某個擴(kuò)展每次都報(bào)編譯錯誤。比如裝pcntl之前在官方基礎(chǔ)鏡像里其實(shí)已經(jīng)編譯好了執(zhí)行docker-php-ext-install pcntl就行但裝intl必須提前有l(wèi)ibicu-dev裝zip必須提前有l(wèi)ibzip-dev否則你會在編譯日志里看到一堆讓人頭皮發(fā)麻的#include報(bào)錯。經(jīng)驗(yàn)是每次安裝擴(kuò)展前先到Docker Hub的PHP鏡像文檔頁查一下該擴(kuò)展對應(yīng)需要的-dev包名然后一條apt-get install命令把依賴全裝齊。裝完擴(kuò)展后順手執(zhí)行docker-php-source delete刪掉源碼目錄能有效減小鏡像體積。4.4 鏡像里連排查工具都沒有的尷尬為了追求“精簡鏡像”我以前把Nginx容器壓縮到只有十幾MB結(jié)果線上出問題時進(jìn)容器連ps、curl、ping都沒有想看看PHP進(jìn)程還活著沒、接口有沒有通全都干不了?,F(xiàn)在我的建議是不必極端追求小鏡像。在生產(chǎn)鏡像里保留curl、procps、iproute2這類基礎(chǔ)排障工具多出來的體積不超過10MB卻能在關(guān)鍵時刻幫你省下半小時。排查問題要用的常見命令可以寫成一個debug服務(wù)單獨(dú)跑或者直接在compose里加一個toolbox容器共享同一個網(wǎng)絡(luò)平時不占用額外資源。5. 別忽略框架里的那口容器依賴注入服務(wù)容器的工作方式環(huán)境容器把PHP程序打包部署好之后代碼本身的架構(gòu)問題依然存在。這也是我前面說要拆成兩層來理解的原因。很多人在Laravel里天天寫app(SomeClass::class)對這個服務(wù)容器的底層邏輯卻很模糊。5.1 服務(wù)容器的注冊與解析bind、singleton與alias在Laravel里服務(wù)容器本身就是一個普通的類對象它內(nèi)部維護(hù)著一張“類的名稱到如何制造實(shí)例”的對應(yīng)表。你可以手動注冊app()-bind(report, function ($app) { return new ReportService($app-make(db), $app-make(cache)); }); app()-singleton(cache, function ($app) { return new RedisCache(); }); app()-alias(report, ReportService::class);bind表示每次解析時都會重新執(zhí)行工廠函數(shù)產(chǎn)生一個新實(shí)例singleton則保證整個請求生命周期內(nèi)只創(chuàng)建一次。這決定了你的對象是“每次都新鮮”還是“全局共享一份”。當(dāng)你寫app()-make(report)時容器會從綁定表里找到對應(yīng)的工廠回調(diào)遞歸地解析它所需要的依賴最后返回一個完整可用的對象。如果我們手動new ReportService(...)這些依賴管理邏輯就會散落在業(yè)務(wù)的各個角落項(xiàng)目變大后幾乎無法維護(hù)。5.2 為什么容器能自動解析大部分類反射機(jī)制你可能好奇過有些類我從來沒有手動bind過為什么直接app()-make(SomeService::class)也能拿到實(shí)例靠的是PHP的反射機(jī)制。容器在解析一個類時會先用反射讀取構(gòu)造函數(shù)參數(shù)的類型聲明然后逐個嘗試從綁定表里解析這些參數(shù)類型。如果都能解析出來就自動幫你在運(yùn)行時完成實(shí)例化。舉個例子你有這么個構(gòu)造器public function __construct(DatabaseManager $db, Mailer $mailer) {}容器會先去解析DatabaseManager和Mailer再把它們傳給ReportService的構(gòu)造函數(shù)。只有遇到無法自動解析的參數(shù)比如字符串、整型、枚舉常量你才需要借助bind手動告訴容器怎么制造。5.3 兩層“容器”怎么配合框架管代碼環(huán)境管進(jìn)程理解這兩層容器之后它們的配合方式就變得非常清晰環(huán)境容器負(fù)責(zé)的是“PHP-FPM跑起來、擴(kuò)展加載好、端口能連通”它保證你的代碼有一個穩(wěn)定一致的運(yùn)行環(huán)境框架的服務(wù)容器負(fù)責(zé)的是“業(yè)務(wù)的各個服務(wù)類怎么創(chuàng)建、怎么互相協(xié)作”它保證你的代碼沒有硬編碼依賴到處亂飛。我用了一套比較順手的模式環(huán)境層用php:8.3-fpm鏡像代碼放進(jìn)鏡像里并用compose編排Nginx、MySQL、Redis??蚣軐诱S梅?wù)容器綁定第三方服務(wù)比如把RedisCache綁定到CacheInterface上換驅(qū)動時只改一處綁定即可。調(diào)優(yōu)時先看環(huán)境層有沒有問題內(nèi)存、擴(kuò)展、日志再深入框架層排查依賴有沒有弄成重復(fù)創(chuàng)建的重對象。這兩層并不沖突反而是各管一段。把環(huán)境容器當(dāng)“房子”把框架容器當(dāng)“家具調(diào)度員”房子塌了家具再好也沒用家具亂堆房子再豪華也住得難受。只有兩層都理解了PHP服務(wù)這一整套才算真正被你拆開看明白了。