Tích hợp CDN để serve file tĩnh cho app Laravel + Livewire
Câu hỏi nghe đơn giản: "cắm CDN cho app Laravel thì làm sao?". Trả lời kiểu
thuộc lòng là "set ASSET_URL trong .env". Trả lời đó
đúng một nửa và hỏng nửa còn lại, vì trong một app Livewire có tới
bốn nhóm file tĩnh, mỗi nhóm sinh URL theo một cơ chế khác nhau — và
ASSET_URL chỉ điều khiển được hai nhóm.
Bài này đọc code thật của project laravel-livewire
(Laravel 12, Livewire 4.3, Vite, nginx blue-green) và chạy thật
trong container clicon-dev-app-1 để lấy output, không đoán.
Từ khoá dùng suốt bài
- CDN (mạng phân phối nội dung) — một tầng máy chủ cache đặt gần người dùng về mặt địa lý. Mỗi máy gọi là một edge hoặc POP (point of presence, điểm hiện diện).
- origin (máy gốc) — server thật của bạn. Khi edge không có file, nó đi hỏi origin, gọi là cache miss; có sẵn thì là cache hit.
- pull CDN — CDN tự lấy file từ origin khi cần. push CDN — bạn chủ động đẩy file lên trước (thường lên S3/R2).
- content hash (băm theo nội dung) — Vite gắn chuỗi hash vào tên file, ví dụ
app-CIomGrQN.js. Nội dung đổi thì tên đổi.- cache-busting (ép tải lại bản mới) — đổi URL để trình duyệt không dùng bản cũ trong cache.
- immutable — giá trị trong header
Cache-Controlnói với trình duyệt "URL này không bao giờ đổi nội dung, đừng hỏi lại".- purge (xoá cache ở CDN) — bảo mọi edge quên file đang giữ.
- CORS (chia sẻ tài nguyên khác nguồn) — luật của trình duyệt: trang ở domain A muốn đọc file ở domain B thì B phải cho phép bằng header.
1. Nó là gì / chạy thế nào
1.1. Bốn nhóm file tĩnh, bốn cơ chế sinh URL
Đây là chỗ hầu hết người ta làm sai. Cắm CDN xong, vẫn còn hai nhóm chạy thẳng vào origin mà không ai để ý.
| Nhóm file | Ai sinh URL | Đi CDN theo ASSET_URL? |
|---|---|---|
Vite build: /build/assets/app-*.js, *.css |
@vite → Vite::asset() → asset() |
Có |
File nằm sẵn trong public/: ảnh, favicon, svg |
asset('images/...') |
Có |
File user upload: storage/app/public |
Storage::url() → disks.public.url |
Không — key riêng trong config/filesystems.php |
livewire.js, module URL, update endpoint |
Livewire FrontendAssets |
Không — phải bật riêng livewire.asset_url |
Trong project này, chỗ dùng thật rất ít — đúng như một app còn gọn:
resources/views/errors/404.blade.php:34 src="{{ asset('images/404-error-illustration.svg') }}"
resources/views/components/layouts/app.blade.php:72 @vite(['resources/css/app.css', 'resources/js/app.js'])
1.2. ASSET_URL chạy ở hai pha, không phải một
Đây là điểm nhiều người hiểu sai nhất. ASSET_URL có tác dụng ở
hai thời điểm khác nhau: lúc build (Node/Vite đọc) và lúc chạy
(PHP đọc). Thiếu một trong hai là hỏng một nửa.
flowchart TB
subgraph BUILD["PHA BUILD — npm run build, chạy lúc build image"]
E1["ASSET_URL trong .env"] --> V["laravel-vite-plugin
base = ASSET_URL + '/build/'"]
V --> R["Rollup ghi URL tuyệt đối
vào BÊN TRONG file build"]
R --> R1["url(...) của font, ảnh trong CSS"]
R --> R2["dynamic import() các chunk JS"]
end
subgraph RUNTIME["PHA RUNTIME — mỗi request, PHP render Blade"]
E2["config('app.asset_url')"] --> U["UrlGenerator::assetRoot"]
U --> A1["asset() — thẻ img, link"]
U --> A2["Vite::asset() — thẻ script, link của @vite"]
end
BUILD -.-> RUNTIME
Bằng chứng trong code, không phải trí nhớ:
| Nơi | Dòng code | Ý nghĩa |
|---|---|---|
node_modules/laravel-vite-plugin/dist/index.js:41 |
const assetUrl = env.ASSET_URL ?? "" |
Vite đọc biến lúc build |
...index.js:44 |
base: ... resolveBase(pluginConfig, assetUrl) |
Chốt tiền tố cho mọi URL sinh trong file build |
vendor/laravel/framework/config/app.php:62 |
'asset_url' => env('ASSET_URL') |
Key này ở config mặc định của framework |
.../Bootstrap/LoadConfiguration.php:88-93 |
diffKeys($files) rồi $repository->set(...) |
Laravel 11+ merge config mặc định vào, nên vẫn có app.asset_url dù config/app.php của project không khai báo |
.../Routing/RoutingServiceProvider.php:66 |
truyền config['app.asset_url'] vào UrlGenerator |
Gốc URL của asset() |
.../Foundation/Vite.php:918 |
($this->assetPathResolver ?? asset(...))($path, $secure) |
@vite đi qua đúng asset() đó |
Chạy thật trong container (dùng URL::useAssetOrigin() để giả lập
ASSET_URL, vì config đang bị cache — xem Case 2 ở mục 6):
docker exec clicon-dev-app-1 php artisan tinker --execute="
URL::useAssetOrigin('https://cdn.example.com');
echo asset('images/404-error-illustration.svg').PHP_EOL;
echo Vite::asset('resources/js/app.js').PHP_EOL;
"
https://cdn.example.com/images/404-error-illustration.svg
https://cdn.example.com/build/assets/app-CIomGrQN.js
Nhớ cái này: Laravel chỉ đổi gốc URL, nó không copy file nào lên CDN cả. Việc đưa file lên là chuyện của CDN (tự đi lấy — pull) hoặc của CI (đẩy lên — push). Rất nhiều người set
ASSET_URLxong ngồi chờ file tự bay lên.
1.3. Livewire nằm ngoài ASSET_URL
Output thật của FrontendAssets::js([]) khi asset origin đã trỏ CDN:
<script src="http://109.123.236.64:8070/livewire-9945d67f/livewire.js?id=657d57c2"
data-csrf=""
data-module-url="http://109.123.236.64:8070/livewire-9945d67f"
data-update-uri="http://109.123.236.64:8070/livewire-9945d67f/update"></script>
Ba điều phải nắm:
livewire.jskhông phải file trên disk mà nginx serve. Nó là một route PHP:FrontendAssets::returnJavaScriptAsFile()đọc file trongvendor/livewire/livewire/dist/rồiUtils::pretendResponseIsFile(). Nghĩa là mỗi lần tải file 238 KB này, một worker php-fpm bị chiếm.- Prefix
/livewire-9945d67flà hash củaAPP_KEY— Livewire 4 mới thêm (EndpointResolver::prefix():substr(hash('sha256', config('app.key').'livewire-endpoint'), 0, 8)). ĐổiAPP_KEYlà đổi path. Rule CDN/WAF viết cứng/livewire/*sẽ trượt hết. data-update-urilà POST endpoint, luôn phải ở origin. File JS có thể ở CDN, còn request thì không bao giờ.
flowchart TB
S["FrontendAssets::js() — chọn src cho thẻ script"] --> C1{"config('livewire.asset_url')
có set không?"}
C1 -- "có" --> CDN["dùng URL đó
(vd https://cdn.example.com/vendor/livewire/livewire.min.js)"]
C1 -- "không" --> C2{"public/vendor/livewire/manifest.json
đã publish chưa?"}
C2 -- "rồi" --> PUB["url('vendor/livewire/livewire.min.js')
nginx serve từ disk"]
C2 -- "chưa" --> ROUTE["route PHP /livewire-{hash}/livewire.js
chiếm 1 worker php-fpm mỗi lần miss"]
CDN --> Q["gắn thêm ?id={hash từ dist/manifest.json}"]
PUB --> Q
ROUTE --> Q
Q --> OUT["data-update-uri VẪN là origin — không đổi"]
Cách đưa livewire.js lên CDN — hai bước, thiếu bước nào cũng hỏng:
php artisan livewire:publish --assets # copy dist/ -> public/vendor/livewire/
// config/livewire.php
'asset_url' => env('LIVEWIRE_ASSET_URL'),
// LIVEWIRE_ASSET_URL=https://cdn.example.com/vendor/livewire/livewire.min.js
Verify thật, sau khi set livewire.asset_url:
<script src="https://cdn.example.com/vendor/livewire/livewire.min.js?id=657d57c2"
data-update-uri="http://109.123.236.64:8070/livewire-9945d67f/update">
Đúng như mong muốn: file JS lấy từ CDN, AJAX vẫn về origin.
Chuỗi ?id=657d57c2 lấy từ dist/manifest.json, đó là
cache-busting khi nâng version Livewire.
1.4. Đường đi đầy đủ của một request
sequenceDiagram
autonumber
participant B as Trình duyệt
participant D as CDN edge (POP gần user)
participant N as nginx origin
participant P as php-fpm (blue hoặc green)
B->>N: GET / (HTML)
N->>P: fastcgi_pass
P-->>B: HTML, trong đó src trỏ https://cdn.example.com/build/...
Note over B,N: HTML KHÔNG BAO GIỜ được cache ở CDN
B->>D: GET /build/assets/app-CIomGrQN.js
alt cache hit
D-->>B: trả luôn từ edge, khoảng 10-30ms
else cache miss
D->>N: GET cùng đường dẫn, kèm Host của app
N-->>D: file từ /srv/assets/public
Cache-Control: public, max-age=31536000, immutable
D-->>B: trả file và giữ lại cho lần sau
end
B->>N: POST /livewire-9945d67f/update
Note over B,N: request tương tác đi thẳng origin,
CDN không cache được (POST, có state, có CSRF)
N->>P: fastcgi_pass
P-->>B: JSON snapshot mới
Repo này đã sẵn sàng cho CDN ở phía origin.
docker/production/nginx/app-common.confserve/build/từ volume/srv/assets/public(giữpublic/của mọi build đã chạy, phục vụ blue-green) kèmCache-Control: public, max-age=31536000, immutable. Đó chính là bản hợp đồng mà CDN cần — không phải sửa gì thêm để bật pull CDN.
2. Lợi ích
Không lợi ích nào là vô điều kiện. Cột bên phải mới là phần interviewer muốn nghe.
| Được gì | Chỉ thật khi |
|---|---|
| Giảm latency — file phục vụ từ POP gần user | User phân tán địa lý. User cùng thành phố với server thì gần như không đổi gì. |
| Giảm tải origin — nginx/php-fpm không phải phục vụ vài chục request tĩnh mỗi lượt xem trang | Trang nhiều asset, traffic cao. Trang nội bộ 50 user thì thừa. |
| Băng thông rẻ hơn — giá egress của CDN thường thấp hơn của VPS/cloud | Lượng byte đủ lớn để bù phí cố định hằng tháng. |
| Chống burst — traffic viral đập vào edge, không vào origin | Đúng lúc cần nhất. Đây là lợi ích lớn nhất nhưng khó thấy lúc bình thường. |
| Có sẵn TLS, HTTP/3, nén Brotli, resize ảnh tại edge | Tuỳ nhà cung cấp, không phải CDN nào cũng có. |
3. Tác hại / hạn chế
- Thêm một tầng cache nữa phải nghĩ tới. Bug có thêm chỗ trốn:
browser cache → CDN edge → nginx → app. Debug lúc 3h sáng phải biết đọc
Age,X-Cache,CF-Cache-Status. - Sinh ra cross-origin. Font
.woff2và mọi file mà JSfetch()đều cầnAccess-Control-Allow-Origin. Thiếu thì font không load, mà không có lỗi rõ ràng — chỉ thấy chữ nhảy sang font dự phòng. - Không cứu được TTFB. HTML vẫn do PHP render ở origin. CDN chỉ giúp asset. Với Livewire, mỗi tương tác là một POST về origin — CDN không đụng tới.
- Purge không tức thời. Purge toàn cầu thường 5-30 giây, có nơi lâu hơn. Đang rollback gấp mà ngồi chờ purge là hỏng.
- Chi phí ẩn: tính tiền theo số request (không chỉ băng thông), phí purge, và phí egress origin → CDN khi cache miss hàng loạt ngay sau deploy.
- Đôi khi chậm hơn. Với HTTP/2, nhiều asset nhỏ trên cùng
một origin dùng chung một connection. Tách sang domain thứ hai là phải trả thêm
DNS + TCP + TLS handshake.
preconnectgiảm bớt chứ không xoá.
4. Trade-off
4.1. Pull CDN (origin là app) vs Push lên S3/R2
| Pull — CDN đi lấy từ nginx origin | Push — CI đẩy lên bucket, CDN trỏ bucket | |
|---|---|---|
| Việc phải thêm khi deploy | Không có | Thêm bước upload trong CI |
| Rollback | Asset build cũ vẫn nằm ở origin (volume giữ mọi build) → ổn | Phải giữ file cũ trên bucket — tuyệt đối đừng sync --delete |
| Khi origin chết | Edge còn cache thì vẫn sống, miss thì 502 | Asset sống độc lập với origin |
| Hợp với | Setup hiện tại của repo | Serverless, nhiều region, muốn tách hẳn asset khỏi app |
Chọn gì cho repo này: Pull. Volume /srv/assets/public
đã giữ public/ của mọi build cho blue-green, tức là điều kiện khó nhất
(giữ asset của build cũ) đã có sẵn. Thêm bước push chỉ tạo thêm chỗ hỏng. Chuyển sang
Push khi muốn asset sống được cả lúc origin down.
4.2. Cùng domain vs subdomain riêng
Cùng domain (app.com/build/* qua proxy) | Subdomain riêng (cdn.app.com) | |
|---|---|---|
| CORS | Không cần | Phải cấu hình cho font, và cả CSP |
| Handshake | Dùng chung connection sẵn có | Thêm DNS + TCP + TLS cho host mới |
| Cookie | Cookie session bị gửi kèm mọi request asset — tốn byte, và edge phải cẩn thận không cache theo cookie | Cookie-less, sạch |
| Chọn khi | Cookie nhỏ, ít asset | Cookie lớn (>2 KB) hoặc >50 asset mỗi trang |
4.3. Có nên đưa livewire.js lên CDN không?
File livewire.min.js nặng 238 KB và đang do php-fpm phục
vụ. Ba lựa chọn:
| Cách | Được | Mất |
|---|---|---|
| Để mặc định (route PHP) | Không phải làm gì, không bao giờ lệch version | Mỗi lần tải chiếm một worker php-fpm |
livewire:publish --assets, để nginx serve |
Hết chiếm worker, vẫn cùng origin nên không CORS | Phải publish lại mỗi lần nâng version Livewire |
Publish + livewire.asset_url trỏ CDN |
Nhẹ origin nhất | Thêm rủi ro lệch version qua tầng cache CDN |
Cách lười nhất mà vẫn tốt: chọn dòng giữa. Publish ra
public/vendor/livewire/ rồi để nginx serve. Giải quyết đúng cái đau thật
(chiếm worker php-fpm) mà không rước thêm tầng cache. Chỉ lên CDN khi user thật sự
phân tán toàn cầu.
5. Khó khăn khi vận hành thực tế
immutablean toàn chỉ vì tên file có content hash. Nếu lỡ để một file không hash (ví dụpublic/js/legacy.js) dính ruleimmutablemột năm thì coi như không sửa được nữa, trừ khi đổi tên file. Rule cache phải bám đúng/build/— config của repo đang làm đúng.- HTML tuyệt đối không được cache ở CDN. HTML chứa CSRF token và
data-csrf. Cache HTML dùng chung → user A nhận token của user B → Livewire trả 419 Page Expired hàng loạt. Đây là lỗi số 1 khi ai đó bật "cache everything" trên Cloudflare. - Cần theo dõi: tỉ lệ cache hit (mục tiêu >95% cho
/build/), lỗi 4xx/5xx tại edge tách riêng khỏi origin, và số byte đi từ origin sau mỗi deploy — tăng vọt rồi hạ là bình thường, tăng vọt và ở lì là dấu hiệu cache key sai. - Chi phí con người: thêm một dashboard, thêm credential purge trong CI, thêm một chỗ nữa để cấu hình lệch giữa staging và production.
- Bộ lệnh chẩn đoán lúc 3h sáng:
# header CDN đang trả
curl -sI https://cdn.example.com/build/assets/app-CIomGrQN.js \
| grep -iE 'cache-control|age|x-cache|cf-cache|access-control'
# header origin đang trả — lệch nhau nghĩa là CDN đang override
curl -sI https://app.example.com/build/assets/app-CIomGrQN.js | grep -i cache-control
# file build có mang tiền tố CDN không (kiểm tra pha build)
grep -o 'https://cdn[^"]*' public/build/assets/*.css | head
6. Case thực tế và cách xử lý
Case 1 — Rollback blue → green: "Failed to fetch dynamically imported module"
Triệu chứng: sau khi rollback, một số user click vào trang thì thấy trang trắng, console báo lỗi 404 khi tải chunk.
Nguyên nhân: trình duyệt (hoặc CDN) còn giữ HTML/entry JS của build mới, nhưng chunk lazy-load của build mới đã biến mất khỏi origin.
sequenceDiagram autonumber participant B as Trình duyệt participant D as CDN participant N as origin sau rollback B->>B: còn giữ HTML của build v2 trong cache B->>D: GET /build/assets/cart-9xY.js (chunk của v2) D->>N: cache miss N-->>D: 404 — origin đã quay về v1 D-->>B: 404 B->>B: Failed to fetch dynamically imported module → trang trắng
Xử lý từng bước:
- Kiểm tra chunk còn ở origin không:
curl -I https://app.example.com/build/assets/cart-9xY.js - Nếu 404 → đừng purge CDN. Purge sẽ xoá luôn bản đang chạy tốt
và tạo cache-miss storm. Sửa ở origin: giữ
public/build/của N build gần nhất trong volume chung. Repo đã đi đúng hướng (volume/srv/assets/publicgiữ "public/ của mọi build đã chạy") — chỉ cần chắc rằng job dọn dẹp giữ tối thiểu 2-3 build. - Lá chắn phía client cho phần còn sót lại — bắt lỗi và reload đúng một lần:
window.addEventListener('vite:preloadError', (e) => { e.preventDefault(); window.location.reload(); }); - Phòng dài hạn: HTML không cache ở CDN, đặt
Cache-Control: no-storehoặcmax-age=0, must-revalidate.
Case 2 — Đổi ASSET_URL xong mà không thấy gì đổi (đã verify)
Chạy thật trong container:
docker exec -e ASSET_URL=https://cdn.example.com clicon-dev-app-1 \
php artisan tinker --execute="
echo 'env='.env('ASSET_URL').PHP_EOL;
echo 'cfg='.config('app.asset_url').PHP_EOL;
echo 'cached='.(file_exists(base_path('bootstrap/cache/config.php'))?'yes':'no').PHP_EOL;
"
env=https://cdn.example.com
cfg=
cached=yes
env() có giá trị nhưng config('app.asset_url')
rỗng, vì bootstrap/cache/config.php đang tồn tại —
config:cache đã đóng băng giá trị từ lúc build image.
Xử lý:
- Không sửa
.envtrên container đang chạy rồi mong nó ăn. Phảiconfig:clear && config:cachetrong image mới. - Quan trọng hơn:
ASSET_URLphải có mặt lúcnpm run build, vìbasecủa Vite chốt tại pha build. Đặt biến ở stage build của Dockerfile, không phải chỉ ở runtime env. - Kiểm tra nhanh xem build có ăn biến không:
grep -o 'https://cdn[^"]*' public/build/assets/*.css | head— rỗng nghĩa là build chưa thấy biến.
Case 3 — Font 404 im lặng, chữ nhảy font sau khi bật CDN subdomain
Nguyên nhân: @font-face trong CSS trỏ
https://cdn.example.com/...woff2, trang thì ở
app.example.com → cross-origin, thiếu
Access-Control-Allow-Origin. Trình duyệt bỏ font, im lặng dùng font
dự phòng.
Xử lý — thêm vào location /build/ trong
docker/production/nginx/app-common.conf:
location /build/ {
add_header Cache-Control "public, max-age=31536000, immutable";
add_header Access-Control-Allow-Origin "*";
add_header Vary "Origin";
access_log off;
try_files $uri =404;
}
Bẫy nginx:
add_headertrong một block sẽ xoá sạch mọiadd_headerkế thừa từ block cha. Phải liệt kê đủ trong cùng một block. Sau khi sửa, nhớ purge riêng đường dẫn font vì header cũ (thiếu CORS) đã bị CDN cache lại rồi.
Case 4 — Livewire chết trên production sau khi nâng version
Nguyên nhân: chạy composer update livewire/livewire
nhưng quên livewire:publish --assets. CDN vẫn serve
livewire.min.js bản cũ, còn PHP phía server đã là bản mới → payload
protocol lệch nhau, component không update được. Livewire có tự in cảnh báo
(usePublishedAssetsIfAvailable() so sánh hai manifest rồi
console.warn) nhưng chẳng ai nhìn console lúc đang cháy.
Xử lý: đưa vào CI ngay sau composer install:
php artisan livewire:publish --assets --force
Và để ?id= làm việc của nó — Livewire đã tự gắn hash
lấy từ dist/manifest.json. Điều kiện là CDN phải cache theo full URL
kèm query string; một vài CDN mặc định strip query, phải kiểm tra.
Case 5 — Ảnh user upload không đi CDN
Nguyên nhân: asset() đổi gốc, nhưng
Storage::url() thì không. Trong repo,
config/filesystems.php gắn cứng vào APP_URL:
'public' => [
'driver' => 'local',
'root' => storage_path('app/public'),
'url' => rtrim(env('APP_URL', 'http://localhost'), '/').'/storage',
...
],
Xử lý: sửa key url của disk (hoặc chuyển disk sang
s3 và set AWS_URL trỏ CDN).
Đừng đặt
immutablecho ảnh upload. URL ảnh upload không có content hash, user thay ảnh thì URL giữ nguyên. Dùngmax-age=86400rồi purge khi có thay đổi, hoặc thêm?v={updated_at}vào URL để tự cache-bust.
7. Câu hỏi phụ interviewer hay hỏi tiếp
"CDN có làm Livewire nhanh hơn không?"
Rất ít. Mỗi wire:click là một POST tới
/livewire-{hash}/update ở origin — CDN không cache được (POST, có state,
có CSRF). CDN chỉ giúp lần tải trang đầu. Muốn nhanh phần tương tác thì phải tối ưu
origin: số worker PHP-FPM, query, và dùng wire:model.blur /
.live.debounce để giảm số round-trip.
"Vì sao dám để immutable một năm?"
Vì tên file mang content hash do Vite sinh. Nội dung đổi → tên đổi → URL đổi.
Một URL không bao giờ mang nghĩa khác. Với file không hash thì
immutable là cái bẫy tự đặt cho mình.
"Deploy xong purge cache thế nào cho đúng?"
Không purge gì cả cho /build/. Content hash đã làm cache-busting rồi;
purge chỉ tốn tiền và tạo cache-miss storm dội về origin. Chỉ purge khi sửa file
không có hash: favicon, robots.txt, ảnh upload.
"Tách asset sang subdomain có luôn nhanh hơn không?"
Không. Domain mới tốn thêm DNS + TCP + TLS. Với HTTP/2 dùng chung một connection, gộp
chung domain thường nhanh hơn cho site nhỏ. Tách khi cookie lớn, hoặc khi muốn asset
sống độc lập với origin.
"CDN của bạn có cache HTML không?"
Không. HTML mang CSRF token và nội dung riêng theo user. Cache HTML dùng chung sẽ gây
419 Page Expired hàng loạt và làm rò session giữa các user.
"Đổi APP_KEY thì sao?"
Livewire 4 sinh prefix endpoint từ APP_KEY
(/livewire-9945d67f). Đổi key là đổi path. Mọi rule CDN/WAF/firewall viết
cứng /livewire/* sẽ hết tác dụng — dùng pattern /livewire-*/,
hoặc lấy path động từ data-update-uri.
"Làm sao biết CDN đang hoạt động?"
So header giữa edge và origin. Ở edge phải thấy Age tăng dần và
X-Cache: HIT (hoặc CF-Cache-Status: HIT). Nếu
Age: 0 mãi thì mọi request đang xuống origin — thường do header
Set-Cookie hoặc Vary quá rộng làm CDN từ chối cache.
Chưa verify được: phần hành vi của CDN thật (tỉ lệ hit, độ trễ purge, việc CDN override header) — môi trường ở đây không có CDN để chạy thử. Mọi thứ thuộc Laravel / Livewire / nginx phía origin trong bài đều đã chạy thật trong container
clicon-dev-app-1và output được dán nguyên văn.