# Đổi host và bốn hành vi nền tảng đang làm hộ mà bạn không sở hữu

Chuyển hecigo.com sang một host khác. Adapter mất mười lăm dòng. Bốn hành vi nền tảng vẫn làm hộ thì mỗi cái hỏng một kiểu, và không cái nào làm build đỏ.

Published: 2026-08-30 · Language: vi · Tags: Web Architecture, HTTP, Edge Computing, Testing, Migration · Canonical: https://hecigo.com/blog/doi-host-va-bon-hanh-vi-nen-tang-lam-ho-ma-ban-khong-so-huu/

---

Trước khi đổi gì, chúng tôi viết test cho những luật mà nền tảng đang thi hành hộ. Test đầu tiên chạy xong thì lộ một lỗi có sẵn từ trước:

```bash
curl -sI -H 'Accept: application/json' https://hecigo.com/docs
# HTTP/2 406
```

`/docs` đáng lẽ trả `301` về trang tài liệu. Với `*/*`, với `text/html`, với `text/markdown`, hay không gửi `Accept` thì nó trả `301` thật. Chỉ khi người gọi nói `application/json` hoặc `application/xml` thì nó thành `406 Not Acceptable`.

Mười sáu luật chuyển hướng, ba mươi hai đường dẫn, chết với đúng nhóm người gọi mà chúng được viết ra để phục vụ. Không ai biết, vì không có test nào hỏi câu đó.

Lỗi nằm ở lớp thương lượng nội dung: đường dẫn không có trong danh mục thì nó hỏi origin xem có tồn tại không, rồi đọc **mọi thứ không phải 404** là "tồn tại nhưng không có định dạng anh xin". Một cú `301` không phải là một định dạng.

Bài này ghi lại phần còn lại của việc đó: bốn hành vi mà nền tảng cũ đang làm hộ, mỗi cái hỏng một kiểu khi đổi sang nền tảng khác, và không cái nào làm build đỏ hay ghi một dòng log.

## Viết lưới trước khi tháo sàn

Site là static export thuần, cộng một lớp code ở edge làm thương lượng nội dung: cùng một URL trả HTML cho trình duyệt và Markdown cho agent, tuỳ header `Accept`.

Phần đó nằm trong repo, có test, và chuyển nền tảng không đụng tới. Phần không nằm trong repo mới là phần đáng lo:

- mười sáu luật chuyển hướng trong một file cấu hình của nền tảng
- trang 404 do nền tảng gắn vào phản hồi
- `content-type` và `cache-control` do nền tảng tự thêm
- luật header trong một file cấu hình khác

Bốn thứ đó chạy đúng suốt nhiều tháng và **không có dòng code nào của chúng tôi chịu trách nhiệm cho chúng**.

Cách làm: mô hình hoá từng luật thành code thuần, viết test cho mô hình, rồi viết một script hỏi nền tảng đang chạy đúng những câu đó. Test chứng minh mô hình tự nhất quán. Chỉ có script mới chứng minh nó khớp với thực tế.

Bước đó tìm ra cú `406` ở đầu bài, trước khi bất cứ thứ gì thay đổi.

## Bốn hành vi không ai viết ra

| Hành vi | Nền tảng cũ | Nền tảng mới |
| --- | --- | --- |
| File chuyển hướng | nền tảng thi hành | không áp cho request do code của bạn phục vụ |
| Trang 404 | trả kèm thân phản hồi | thân rỗng, tuỳ thiết lập |
| `content-type` của `.html` | `text/html; charset=UTF-8` | `text/html` trần |
| `cache-control` | `public, max-age=0, must-revalidate` | không có |

Đọc theo hàng thì mỗi cái nhỏ. Đọc theo cột phải thì đó là một site vẫn trả `200`, vẫn hiển thị đúng, và đã mất bốn thứ.

**File chuyển hướng.** [Tài liệu Cloudflare nói thẳng](https://developers.cloudflare.com/workers/static-assets/redirects/) rằng luật trong `_redirects` không áp cho request do code Worker phục vụ. Worker thương lượng nội dung thì match mọi đường dẫn. Mười sáu luật im lặng cùng lúc.

**Trang 404.** Thiết lập `not_found_handling` có hai giá trị dùng được và cả hai đều sai nếu bạn muốn tự chọn định dạng lỗi. `"404-page"` để asset server trả trang HTML trước khi code của bạn kịp nói gì. `"none"` trả một cái 404 **rỗng**, nên nhánh HTML mất thân phản hồi trong khi nhánh JSON và Markdown vẫn chạy. Đó là loại hỏng bị phát hiện sau cùng: máy vẫn đọc được, chỉ có người là gặp trang trắng.

**Header của trang.** Thiếu `charset` thì trang tiếng Việt vẫn đọc được nhờ thẻ `<meta charset>` trong tài liệu, tức nó chỉ suy giảm chứ không gãy. Thiếu `cache-control` thì nặng hơn: trình duyệt rơi về heuristic caching, và trên một site URL cố định còn nội dung đổi mỗi lần deploy, khách có thể nhìn trang của hôm qua hàng giờ mà không có gì báo.

Cách xử cho cả bốn giống nhau: đưa vào code, có test, để nó trả lời giống nhau bất kể ai đang host.

```js
// Trước: để origin tự type. Đúng với host cũ, không đúng nói chung.
return { action: 'origin', headers: { vary: VARY } }

// Sau: nói ra thứ mình muốn.
return {
  action: 'origin',
  headers: {
    'content-type': 'text/html; charset=utf-8',
    'cache-control': 'public, max-age=0, must-revalidate',
    vary: VARY,
  },
}
```

> Related: [Kiểm tra độ thân thiện của website với AI Agent: Cơ chế hoạt động của Vercel Is Agentic](https://hecigo.com/blog/kiem-tra-do-than-thien-cua-website-voi-ai-agent-co-che-hoat-dong-cua-vercel-is-a/): Is Agentic chấm hecigo.com 73/100. Bốn vòng vá đưa nó lên 100. Lỗi đắt nhất là một phép kiểm mà chính bài này từng lấy làm ví dụ về việc làm đúng.

## Phép kiểm: cho origin giả cư xử như host không biết làm việc đó

Đây là phần đáng mang đi nhất, và nó không tốn gì.

Bộ test vốn có một origin giả, dựng theo cách host cũ phục vụ thư mục build. Mọi assertion trong đó vì thế là assertion về **host cũ** nhiều ngang với về code, và không có gì nói rõ cái nào là cái nào.

Cách tách: chạy **cùng một bộ request trên hai hình dạng origin** rồi assert kết quả giống hệt nhau.

```js
/** Host cũ: một cú trượt là trang 404, kèm status 404. */
const netlifyOrigin = (pathname) =>
  pathname === '/404.html' ? servePage() : new Response(NOT_FOUND_PAGE, { status: 404 })

/** Host mới với not_found_handling "none": một cú trượt là 404 trần. */
const workersOrigin = (pathname) =>
  pathname === '/404.html' ? servePage() : new Response(null, { status: 404 })
```

Chỗ nào hai bên trả khác nhau là chỗ code đang vay mượn nền tảng.

Điểm quan trọng về cách dùng: khi chưa sửa được, **vẫn viết test cho hiện trạng và nói thẳng trong đó**. Bản đầu của chúng tôi có một test tên là "trang 404 HTML đi mượn của origin, và một origin trả 404 trần sẽ mất nó", kèm dòng chú thích rằng khi nhánh HTML thôi mượn thân thì phải sửa chính test này. Nó xanh, nó mô tả đúng sự thật, và nó biến một rủi ro vô hình thành một dòng có tên trong bộ test.

Sau khi sửa, test đó đảo chiều thành "trang 404 HTML giống nhau trên cả hai origin". Chính assertion đó là bằng chứng bản vá đã đáp.

## Cắt bằng route, không phải bằng custom domain

Đây là thứ chỉ lộ ra lúc cắt thật.

Cloudflare có hai cách gắn code vào một tên miền, và [tài liệu best practices của họ](https://developers.cloudflare.com/workers/best-practices/workers-best-practices/) phân biệt rõ: **custom domain** nghĩa là Worker *là* origin, Cloudflare tự tạo bản ghi DNS và chứng chỉ; **route** nghĩa là Worker chạy *trước* một origin đang có, và cần sẵn một bản ghi DNS proxied.

Chúng tôi thử custom domain trước và bị từ chối:

```
Hostname 'example.com' already has externally managed DNS records
(A, CNAME, etc). Delete them first. [code: 100117]
```

Không hỏng gì, vì nó từ chối trước khi làm. Nhưng đi theo lời nó bảo, tức xoá bản ghi rồi deploy, là tự tạo một khoảng chết giữa hai thao tác **và** tự vứt mất đường lùi, vì sau khi xoá thì giá trị cũ không còn ở đâu để đọc lại.

Route thì ngược hẳn. Bản ghi DNS không đổi một chữ, vẫn trỏ host cũ, nhưng mọi request bị Worker chặn và phục vụ từ tài sản tĩnh đã tải lên, nên host cũ không bao giờ được gọi tới. Không có khoảng chết.

```jsonc
"routes": [
  { "pattern": "example.com/*",     "zone_name": "example.com" },
  { "pattern": "www.example.com/*", "zone_name": "example.com" }
]
```

Và quan trọng hơn: **lùi lại là bỏ hai dòng đó rồi deploy.** Request lập tức chảy về host cũ như chưa có gì xảy ra. Không phải khôi phục bản ghi nào, không phải nhớ giá trị cũ.

Đổi sang custom domain sau, khi đã yên tâm và sẵn sàng xoá bản ghi cũ. Lúc đó đo được: tên miền gốc trở lại sau **5 giây**, còn `www` mất **80 giây** vì phải chờ cấp chứng chỉ. Một cutover chỉ kiểm tên miền gốc sẽ tuyên bố thành công trong lúc `www` còn chết, mà `www` là thứ khách gõ tay.

## Ba chỗ tự tay làm hỏng

**Một: bản sửa tạo ra lỗi mới.** Để tiết kiệm số lần gọi Worker, chúng tôi thu hẹp phạm vi nó chạy xuống hai hình dạng URL. Lập luận đúng: site đặt `trailingSlash: true` nên mọi URL trang kết thúc bằng dấu gạch chéo, còn không asset nào như vậy.

Nhưng nguồn chuyển hướng thì **không** có dấu gạch chéo cuối, và file `_redirects` vẫn nằm trong thư mục build. Kết quả: `/docs` do asset server trả lời bằng file, `/docs/` do Worker trả lời. Cùng đích, nên mọi phép kiểm vẫn xanh.

Chuyện nặng hơn cái header thiếu: hai hệ thống cùng thi hành một bộ luật từ hai nguồn, và sửa file mà quên sinh lại danh mục là chúng bất đồng ngay. Cách xử là liệt kê thẳng cả mười sáu nguồn vào phạm vi Worker chạy trước, kèm test giữ danh sách đó khớp với file luật.

**Hai: đếm số chỗ lệch không chứng minh được gì.** Script so hai host báo 105 chỗ lệch `Vary` trên các file `.md`. Gọi lẻ từng đường dẫn thì bên cũ trả đủ. Truy ra là cache của host cũ giữ hai bản cho cùng một URL, một bản sinh từ lần revalidate trả `304` nên không mang `Vary` mới.

Cách kết luận không phải đếm mà là hỏi **chỗ lệch đó có nghĩa gì**. `Vary` chỉ quan trọng ở URL có nhiều hơn một biểu diễn; URL `.md` có đúng một. Kiểm riêng nhóm URL thương lượng thì chúng luôn đủ `Vary: Accept`.

Ba vòng so, 588 phép so mỗi vòng, từ 237 chỗ lệch xuống 124, và phần lớn số còn lại là thân HTML khác nhau vì khác máy dựng. Con số giảm dần nhìn rất thuyết phục và tự nó không nói lên điều gì.

**Ba: cổng kiểm báo động giả.** Chạy cả bộ trên production thì một đường dẫn trả `502` ở hai trong năm giá trị `Accept`. Gọi lẻ bằng `curl`: `301`. Gọi lẻ bằng `fetch`: `301`. Bắn 120 lần liên tiếp vào đúng đường dẫn đó: `301` cả 120 lần. Chạy lại cả bộ thì `502` rơi vào cùng đường dẫn nhưng ở `Accept` khác. Nó theo **vị trí trong loạt chạy**, không theo đường dẫn.

`5xx` không phải phát biểu về hợp đồng. Nó nói "lúc này tôi không trả lời được". Một cổng coi đó là vi phạm sẽ báo động giả, rồi người ta học cách phớt lờ nó, và lần nó kêu thật thì không ai nghe. Bản vá là thử lại hai lần có giãn cách, đếm số lần phải gọi lại và in ra ở dòng tổng kết.

## Con chim hoàng yến

Sau khi luật chuyển hướng vào code, script kiểm chuyển hướng **không còn phát hiện được Worker chết**. Worker ngừng chạy thì asset server vẫn thi hành `_redirects` từ file, mọi phép kiểm chuyển hướng vẫn xanh, trong khi thương lượng nội dung và thân lỗi JSON đã tắt thở.

Phép kiểm duy nhất lộ ra ngay là một dòng:

```bash
curl -sI -H 'Accept: text/markdown' https://hecigo.com/ | grep -i content-type
# content-type: text/markdown; charset=utf-8
```

Ra `text/markdown` là lớp thương lượng còn sống. Ra `text/html` là nó đã chết và mọi thứ khác đang nói dối, vì asset server tự phục vụ được hết.

Nó là phép kiểm đầu tiên trong script kiểm hợp đồng, chạy sau mỗi lần deploy trong CI, cùng 320 phép kiểm khác.

Cùng nhóm với nó là một phép kiểm khác, sinh ra từ một thứ chúng tôi phát hiện muộn: đếm số lần địa chỉ liên hệ xuất hiện trong HTML trên mạng, so với bản dựng. Trang chủ có 14 chỗ trong bản dựng, trên mạng còn 10 chỗ nguyên và 2 khối bị thay bằng một đoạn chỉ giải mã được bằng JavaScript. Thủ phạm là [Email Address Obfuscation](https://developers.cloudflare.com/waf/tools/scrape-shield/email-address-obfuscation/), và theo tài liệu Cloudflare thì nó **bật mặc định từ lúc đăng ký tài khoản**. Không ai chọn nó cả.

Bản Markdown không bị đụng vì không phải HTML. Nên thứ hành động được nhất của site nằm sau lớp JavaScript, trên một site có cả tầng hạ tầng để không cần JavaScript, và chỉ agent đọc HTML mới dính.

Đó cũng là một hành vi nền tảng làm hộ mà không ai viết ra. Chỉ khác là nó nằm ở dashboard chứ không ở file cấu hình, nên không có commit nào để đọc lại.

## Khi nào việc này là thừa

Nếu site của bạn không đổi host và sẽ không đổi, mượn hành vi của nền tảng là lựa chọn hợp lý. Nó ít code hơn, ít thứ phải nuôi hơn, và nền tảng làm những việc đó tốt. Bài này không lập luận rằng ai cũng nên viết lại chúng.

Cân nhắc thật nằm ở chỗ khác: **bạn có biết mình đang mượn những gì không?** Danh sách bốn dòng ở trên mất một buổi để dựng, và giá trị lớn nhất của nó không phải là bản vá mà là biết danh sách đó tồn tại.

Về chi phí, chúng tôi cũng không có con số chứng minh việc đổi host tiết kiệm được tiền cho một site cỡ này. Lý do đổi là chuyện khác: hạn mức của gói miễn phí bên cũ, khi vượt, sẽ tạm ngừng site tới hết tháng. Đó là rủi ro về hình ảnh, không phải bài toán tối ưu chi phí, và nói cho đúng thì phần lớn công sức trong bài này sinh ra từ việc đổi chứ không phải từ việc tiết kiệm.

Vài câu để tự đối chiếu với hệ thống của bạn:

- Có bao nhiêu luật đang nằm trong file cấu hình của nền tảng chứ không nằm trong code có test?
- Nếu lớp code ở edge của bạn ngừng chạy đêm nay, phép kiểm nào đỏ trong vòng năm phút?
- Trang lỗi của bạn do ai dựng, và nó còn nguyên nếu origin trả về thân rỗng không?
- HTML tới tay người đọc có đúng bằng HTML bạn dựng ra không, hay có tầng nào viết lại giữa đường?

Câu cuối là câu chúng tôi vẫn chưa trả lời trọn. Đếm một chuỗi trong HTML thì bắt được việc che địa chỉ email. Nó không bắt được một tầng chèn thêm script, đổi thứ tự thẻ, hay viết lại link theo cách vẫn hợp lệ. Một phép so toàn văn giữa bản dựng và bản trên mạng thì bắt được, nhưng nó cũng đỏ mỗi lần chunk hash đổi, tức đỏ mỗi lần deploy.

Chưa nghĩ ra cách phân biệt "nội dung đổi vì mình vừa build" với "nội dung đổi vì có ai đó viết lại" mà không phải dựng lại đúng bản build ấy để so. Nếu bạn đã giải bài đó rồi, chúng tôi muốn nghe.

> **Thấy hữu ích? Theo dõi hecigo trên [Zalo OA](https://zalo.me/3108963776852260798)** để nhận bài viết kỹ thuật mới sớm nhất, không spam, chỉ nội dung thực tế. Hoặc liên hệ trực tiếp nếu bạn cần hỗ trợ triển khai.

> Related: [Đọc tiếp: Middleware: phần việc n8n, OpenClaw và mọi nền tảng tự động hóa không làm hộ bạn](https://hecigo.com/blog/middleware-chia-khoa-mo-khoa-toan-bo-tiem-nang-cua-n8n-openclaw-va-moi-nen-tang-/): Nối được API là phần dễ. Phần khó lộ ra sau vài tuần chạy thật: sự kiện gửi lại hai lần, webhook rơi mất một giao dịch, hóa đơn bị hủy nhưng hệ...

## Nguồn tham khảo

- [Redirects](https://developers.cloudflare.com/workers/static-assets/redirects/) - Cloudflare Workers docs
- [Workers Best Practices](https://developers.cloudflare.com/workers/best-practices/workers-best-practices/) - Cloudflare Workers docs
- [Custom Domains](https://developers.cloudflare.com/workers/configuration/routing/custom-domains/) - Cloudflare Workers docs
- [Email Address Obfuscation](https://developers.cloudflare.com/waf/tools/scrape-shield/email-address-obfuscation/) - Cloudflare WAF docs
- [Cache multiple versions of a URL with Vary](https://developers.cloudflare.com/changelog/post/2026-07-02-vary-for-cache-rules/) - Cloudflare changelog
- [Ignore builds](https://docs.netlify.com/build/configure-builds/ignore-builds/) - Netlify docs

---

Published by hecigo, middleware & integration lab. https://hecigo.com · hi@hecigo.com
