Skip to main content

2.9 — 7. Hosting và Cloud cơ bản

Summary

Mọi lựa chọn hosting đều nằm trên một trục duy nhất: đổi quyền kiểm soát lấy sự tiện lợi. Máy chủ vật lý cho bạn toàn quyền và toàn bộ trách nhiệm; serverless lo hộ gần hết nhưng bắt bạn viết theo cách của nó. Bài này đi qua năm nấc trên trục đó, vai trò của reverse proxy (kết thúc TLS, định tuyến, cân bằng tải — thứ mà gần như mọi hệ thống production đều có), và hai bài học rút từ sự cố thật: chọn base image sai làm ứng dụng chết lúc chạy chứ không phải lúc build, và gia hạn chứng chỉ tự động hỏng theo cách rất khó nhận ra.

Mục tiêu bài học​

Sau bài này bạn có thể:

  • Xếp năm mô hình hosting theo trục kiểm soát và tiện lợi, và chọn theo hoàn cảnh.
  • Nêu ba việc một reverse proxy làm và vì sao không nên để ứng dụng tự làm.
  • Giải thích image khác container ở đâu, và vì sao base image quyết định ứng dụng có chạy được không.
  • Nêu điều kiện để mở rộng ngang, và vì sao lưu file lên đĩa cục bộ phá vỡ điều kiện đó.
  • Cấu hình ứng dụng qua biến môi trường thay vì nhúng cứng.

Nội dung bài học​

2.9.1 — Năm nấc trên một trục​

Mô hìnhBạn loNhà cung cấp loHợp khi
Máy chủ vật lýTất cảĐiện, mạngYêu cầu đặc thù, dữ liệu nhạy cảm
VPSHệ điều hành trở lênPhần cứng, ảo hoáChi phí thấp, cần toàn quyền
ContainerỨng dụng và imageMáy chủ chạy containerChuẩn hoá môi trường, CI/CD
PaaSChỉ mã nguồnMọi thứ khácĐội nhỏ, muốn đi nhanh
ServerlessTừng hàmMọi thứ, kể cả co giãnTải rời rạc, xử lý theo sự kiện

Không có nấc nào "đúng" hơn nấc nào. Serverless rẻ khi tải rời rạc và đắt khi tải đều; VPS rẻ nhưng bạn phải tự vá lỗi bảo mật hệ điều hành lúc nửa đêm.

2.9.2 — Reverse proxy: thứ đứng trước ứng dụng​

Gần như mọi hệ thống production đều có một lớp đứng giữa Internet và ứng dụng: Nginx, Traefik, Caddy, hoặc một load balancer của nhà cung cấp cloud.

Ba việc nó làm:

  1. Kết thúc TLS. Chứng chỉ nằm ở proxy; ứng dụng chỉ nói HTTP trong mạng nội bộ. Nhờ vậy gia hạn chứng chỉ chỉ làm ở một chỗ.
  2. Định tuyến. api.company.com đi tới dịch vụ này, admin.company.com đi tới dịch vụ kia — trên cùng một địa chỉ IP.
  3. Cân bằng tải. Chia request cho nhiều instance, và ngừng gửi tới instance đã chết.

Hệ quả cần nhớ: vì proxy đứng giữa, ứng dụng không còn thấy IP thật của người dùng. Nó thấy IP của proxy. Thông tin gốc nằm trong các header X-Forwarded-For, X-Forwarded-Proto. Cấu hình ForwardedHeaders cho đúng là việc bắt buộc — và làm sai thì thành lỗ hổng, vì header có thể bị giả mạo. Bài Forward header trong ASP.NET Core phân tích vì sao "forward hết" là một lỗi kiến trúc.

Chứng chỉ tự động cũng là chỗ dễ hỏng âm thầm: bài Traefik + Cloudflare: vì sao cert hết hạn đồng loạt sau 60 ngày là một sự cố thật, nơi mọi thứ trông vẫn bình thường cho tới lúc không còn bình thường nữa.

2.9.3 — Container: image và container khác nhau​

Image     = bản đóng gói bất biến (lớp hệ điều hành + runtime + ứng dụng)
Container = một tiến trình đang chạy từ image đó

Quan hệ giống như class và instance: một image sinh ra bao nhiêu container cũng được.

FROM mcr.microsoft.com/dotnet/aspnet:9.0      # ← dòng quan trọng nhất
WORKDIR /app
COPY --from=build /app/publish .
ENTRYPOINT ["dotnet", "MyApp.dll"]

Dòng FROM quyết định nhiều hơn vẻ ngoài của nó. Nó chọn thư viện C chuẩn mà mọi thư viện native bên dưới sẽ liên kết tới:

  • mcr.microsoft.com/dotnet/aspnet:9.0 — nền Debian, dùng glibc
  • ...:9.0-alpine — nền Alpine, dùng musl

Rất nhiều thư viện native chỉ phát hành bản dựng sẵn cho glibc. Chọn Alpine vì nó nhẹ, rồi ứng dụng build thành công, container khởi động thành công, và chỉ chết đúng lúc nạp thư viện đó. Bài ONNX Runtime chết trên Alpine là một ca như vậy, kèm lý do libc6-compat không cứu được.

Bài học chung: image nhỏ không phải mục tiêu. Image chạy được mới là mục tiêu.

2.9.4 — Cấu hình: biến môi trường, không nhúng cứng​

// SAI — chuỗi kết nối nằm trong mã nguồn, đi thẳng lên git
var connectionString = "Server=prod-db;Password=P@ssw0rd";

// ĐÚNG — đọc từ cấu hình, giá trị đến từ môi trường
var connectionString = builder.Configuration.GetConnectionString("Default");

ASP.NET Core đọc cấu hình theo thứ tự ưu tiên tăng dần: appsettings.json → appsettings.{Environment}.json → biến môi trường → tham số dòng lệnh. Nhờ vậy cùng một image chạy được ở mọi môi trường, chỉ khác biến truyền vào.

Ba quy tắc:

  • Bí mật không bao giờ nằm trong git. Dùng user-secrets khi phát triển, và một kho bí mật khi chạy thật.
  • Một image, nhiều môi trường. Nếu phải build lại image để deploy lên production thì bạn đang thử nghiệm một thứ và chạy một thứ khác.
  • Đổi cấu hình không cần build lại.

2.9.5 — Mở rộng: dọc và ngang​

Mở rộng dọcMở rộng ngang
Cách làmMáy mạnh hơnNhiều máy hơn
Giới hạnTrần phần cứngGần như không
Chi phíTăng phi tuyếnTăng tuyến tính
Điều kiệnKhông cóỨng dụng phải stateless

Dòng cuối là điều kiện then chốt. "Stateless" nghĩa là request đi vào instance nào cũng xử lý được như nhau. Ba thứ phá vỡ nó:

  1. Lưu file lên đĩa cục bộ. Người dùng tải ảnh lên instance 1, lần sau request vào instance 2 và ảnh không có ở đó. Dùng object storage.
  2. Session trong bộ nhớ. Đăng nhập ở instance 1, request sau vào instance 2 thành người lạ. Dùng Redis hoặc token.
  3. Cache trong bộ nhớ tiến trình. Mỗi instance một bản, và chúng lệch nhau. Dùng cache phân tán.

Đây cũng chính là lý do tính stateless của HTTP ở bài 2.4 lại quan trọng đến thế.

2.9.6 — Health check: để hệ thống tự biết mình hỏng​

builder.Services.AddHealthChecks()
.AddNpgSql(connectionString)
.AddRedis(redisConnection);

app.MapHealthChecks("/health/live"); // tiến trình còn sống không?
app.MapHealthChecks("/health/ready"); // đã sẵn sàng nhận request chưa?

Phân biệt hai loại là quan trọng:

  • Liveness — tiến trình còn sống không? Hỏng thì khởi động lại.
  • Readiness — đã kết nối được database, đã nạp xong cache chưa? Chưa sẵn sàng thì tạm ngừng gửi request tới, nhưng đừng khởi động lại.

Nhầm hai cái này gây ra vòng lặp khởi động lại liên tục: database chậm một chút, liveness fail, container bị giết, khởi động lại, lại chậm.

2.9.7 — Rà lại hệ thống của bạn​

Danh sách rà soát hosting

  • •Không có bí mật nào nằm trong mã nguồn hay trong file đã commit.
  • •Cùng một image chạy được ở mọi môi trường, chỉ khác biến truyền vào.
  • •Ứng dụng không ghi file lên đĩa cục bộ để dùng lại về sau.
  • •Session và cache nằm ở nơi dùng chung, không nằm trong bộ nhớ tiến trình.
  • •Đã cấu hình ForwardedHeaders và giới hạn proxy tin cậy.
  • •Có endpoint health check tách riêng liveness và readiness.
  • •Base image đã được xác nhận chạy được với mọi thư viện native mà dự án dùng.
  • •Chứng chỉ TLS gia hạn tự động, và có cảnh báo khi sắp hết hạn.

Bài tập áp dụng​

Bài 1 — Phá vỡ tính phi trạng thái​

Chạy hai thể hiện ứng dụng sau một reverse proxy. Thêm một endpoint ghi file vào /tmp và một endpoint đọc lại. Gọi luân phiên vài lần và quan sát lỗi. Sau đó chuyển sang lưu vào nơi dùng chung.

Tiêu chí hoàn thành: bạn giải thích được vì sao lỗi xuất hiện ngẫu nhiên chứ không phải lần nào cũng lỗi.

Gợi ý và lời giải — Bài 1

Gợi ý. Mỗi thể hiện có hệ thống file riêng. Reverse proxy phân phối request theo vòng, nên request ghi và request đọc có thể rơi vào hai máy khác nhau.

Lời giải — dựng bằng Docker Compose:

services:
api1:
image: crm-api
api2:
image: crm-api
nginx:
image: nginx
ports: ["8080:80"]
volumes: ["./nginx.conf:/etc/nginx/nginx.conf:ro"]
app.MapPost("/save", async (HttpContext ctx) =>
{
await File.WriteAllTextAsync("/tmp/note.txt", "xin chào");
return Results.Ok(Environment.MachineName);
});

app.MapGet("/read", (HttpContext ctx) =>
File.Exists("/tmp/note.txt")
? Results.Ok(new { machine = Environment.MachineName, content = File.ReadAllText("/tmp/note.txt") })
: Results.NotFound(new { machine = Environment.MachineName }));

Gọi:

curl -X POST localhost:8080/save    # -> api1
for i in $(seq 1 6); do curl -s localhost:8080/read; echo; done

Kết quả xen kẽ:

{"machine":"api2"}                                 <- 404
{"machine":"api1","content":"xin chào"} <- 200
{"machine":"api2"} <- 404
{"machine":"api1","content":"xin chào"} <- 200

Vì sao lỗi ngẫu nhiên. Reverse proxy phân phối luân phiên, nên khoảng một nửa số request rơi vào máy không có file. Đây là dấu hiệu đặc trưng của trạng thái cục bộ trong hệ thống nhiều thể hiện: lỗi xuất hiện với tỉ lệ xấp xỉ (n−1)/n với n là số thể hiện, và không bao giờ tái hiện được khi chạy một thể hiện trên máy phát triển.

Bốn loại trạng thái cục bộ hay gặp:

Trạng tháiTriệu chứngNơi nên chuyển sang
File tải lên lưu trên đĩaTải lên xong không xem lại đượcKho đối tượng như S3, Azure Blob
Bộ nhớ đệm trong tiến trìnhDữ liệu cũ mới lẫn lộn tuỳ máyRedis
Phiên đăng nhập trong bộ nhớĐang dùng thì bị đăng xuấtToken tự chứa, hoặc kho phiên chung
Bộ đếm hoặc hạn mức trong biến tĩnhHạn mức bị nhân lên theo số máyRedis với phép tăng nguyên tử

Cách sửa cho bài này:

// Lưu vào volume dùng chung, hoặc tốt hơn là kho đối tượng
app.MapPost("/save", async (IBlobStorage blob, CancellationToken ct) =>
{
await blob.SaveAsync("note.txt", "xin chào", ct);
return Results.Ok();
});

Vì sao "dính phiên" không phải giải pháp tốt. Nhiều người sửa bằng cách cấu hình proxy luôn gửi cùng một người dùng về cùng một máy. Cách này che triệu chứng nhưng giữ nguyên bệnh: máy đó khởi động lại là mất dữ liệu, tải phân bố không đều, và không thể thu nhỏ số máy khi lượng truy cập giảm.

Quy tắc. Một ứng dụng sẵn sàng mở rộng ngang phải thoả điều kiện: tắt bất kỳ thể hiện nào bất kỳ lúc nào cũng không mất dữ liệu gì. Đây là nguyên tắc số sáu trong Twelve-Factor App, và Module 15 xây dựng toàn bộ phần triển khai dựa trên nó.

Bài 2 — So sánh hai base image​

Build cùng một ứng dụng với aspnet:9.0 và aspnet:9.0-alpine. So sánh kích thước image, thời gian khởi động, và xác nhận thư viện native vẫn chạy trên cả hai.

Tiêu chí hoàn thành: bạn nêu được một trường hợp cụ thể mà Alpine không phải lựa chọn đúng.

Gợi ý và lời giải — Bài 2

Gợi ý. Alpine dùng thư viện C tên là musl thay vì glibc như các bản Linux phổ biến. Hầu hết code .NET thuần không quan tâm, nhưng thư viện nào gọi xuống mã máy native thì có thể quan tâm rất nhiều.

Lời giải — Dockerfile nhiều tầng:

FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build
WORKDIR /src
COPY . .
RUN dotnet publish -c Release -o /app

# Đổi dòng dưới giữa hai biến thể để so sánh
FROM mcr.microsoft.com/dotnet/aspnet:9.0
# FROM mcr.microsoft.com/dotnet/aspnet:9.0-alpine
WORKDIR /app
COPY --from=build /app .
ENTRYPOINT ["dotnet", "Crm.Api.dll"]
docker build -t crm:debian . && docker images crm:debian --format "{{.Size}}"
docker build -t crm:alpine . && docker images crm:alpine --format "{{.Size}}"

Kết quả điển hình:

Biến thểKích thước imageGhi chú
aspnet:9.0 (Debian)~220 MBMặc định, tương thích rộng nhất
aspnet:9.0-alpine~110 MBNhỏ hơn khoảng một nửa
aspnet:9.0-noble-chiseled~120 MBBỏ shell và trình quản lý gói

Vì sao kích thước image quan trọng, và không quan trọng như người ta tưởng. Nó rút ngắn thời gian kéo image khi triển khai và khi tự động mở rộng, đồng thời giảm bề mặt tấn công vì có ít gói hệ thống hơn. Nhưng nó không làm ứng dụng chạy nhanh hơn: sau khi khởi động, thời gian xử lý mỗi request gần như y hệt.

Khi nào Alpine không phải lựa chọn đúng — đây là phần chính của bài:

  1. Thư viện native biên dịch cho glibc. Một số gói xử lý ảnh, mã hoá, hoặc trình điều khiển database bản cũ chỉ có bản dựng cho glibc. Trên Alpine chúng ném DllNotFoundException — và thường chỉ ném lúc chạy tới đoạn code đó, chứ không phải lúc khởi động.
  2. Quốc tế hoá. Alpine mặc định không cài dữ liệu ICU, nên so sánh chuỗi, sắp xếp theo ngôn ngữ và định dạng ngày giờ theo vùng đều sai lệch. Phải cài thêm gói icu-libs, và lúc đó phần dung lượng tiết kiệm được co lại đáng kể. Với ứng dụng xử lý tiếng Việt, đây là điểm phải kiểm tra kỹ.
  3. Cần công cụ chẩn đoán. Alpine tối giản nên thiếu nhiều tiện ích quen thuộc khi cần vào container xem xét sự cố.

Kiểm chứng phần quốc tế hoá:

var vi = new CultureInfo("vi-VN");
Console.WriteLine(new DateTime(2026, 9, 24).ToString("D", vi));
Console.WriteLine(string.Compare("Đà Nẵng", "Hà Nội", vi, CompareOptions.None));
Console.WriteLine(12345.678m.ToString("C", vi));

Chạy đoạn này trên cả hai image. Nếu Alpine cho kết quả khác, ICU đang thiếu.

Khuyến nghị thực dụng. Mặc định dùng bản Debian. Chỉ chuyển sang Alpine khi kích thước image thật sự là vấn đề đo được — ví dụ khi tự động mở rộng thường xuyên và thời gian kéo image chiếm phần đáng kể — và sau khi đã kiểm tra đủ ba điểm trên. Nếu mục tiêu chính là giảm bề mặt tấn công thì bản chiseled thường là lựa chọn tốt hơn Alpine, vì nó vẫn dựa trên glibc.

Bài 3 — Tách liveness và readiness​

Cài health check cho database. Tắt database và quan sát: endpoint nào chuyển sang không khoẻ, và hệ thống điều phối phản ứng ra sao với mỗi loại.

Tiêu chí hoàn thành: bạn nêu được hậu quả cụ thể nếu gộp hai loại làm một.

Gợi ý và lời giải — Bài 3

Gợi ý. Hai loại kiểm tra trả lời hai câu hỏi khác nhau, và hệ thống điều phối phản ứng khác nhau với mỗi câu:

  • Tiến trình này còn sống không? → nếu không thì khởi động lại.
  • Tiến trình này sẵn sàng nhận request chưa? → nếu chưa thì ngừng gửi request tới, nhưng vẫn để nó chạy.

Lời giải.

builder.Services.AddHealthChecks()
.AddCheck("self", () => HealthCheckResult.Healthy(), tags: ["live"])
.AddNpgSql(connectionString, name: "postgres", tags: ["ready"])
.AddRedis(redisConnection, name: "redis", tags: ["ready"]);

app.MapHealthChecks("/health/live", new HealthCheckOptions
{
Predicate = c => c.Tags.Contains("live")
});

app.MapHealthChecks("/health/ready", new HealthCheckOptions
{
Predicate = c => c.Tags.Contains("ready")
});

Tắt database rồi gọi hai endpoint:

docker stop postgres
curl -s -o /dev/null -w "live : %{http_code}\n" localhost:5000/health/live # 200
curl -s -o /dev/null -w "ready: %{http_code}\n" localhost:5000/health/ready # 503

live vẫn 200 vì tiến trình hoàn toàn khoẻ mạnh — nó chỉ không có database để làm việc. ready trả 503 nên bộ cân bằng tải ngừng gửi request tới.

Hậu quả nếu gộp hai loại làm một. Đây là phần quan trọng nhất. Giả sử chỉ có một endpoint kiểm tra cả database và dùng nó cho cả hai mục đích. Database gặp sự cố tạm thời 30 giây:

1. Mọi thể hiện báo không khoẻ
2. Hệ thống điều phối hiểu là tiến trình hỏng -> khởi động lại TOÀN BỘ
3. Khởi động lại đồng loạt tạo ra một đợt kết nối mới đổ vào database
4. Database vốn đang yếu nay bị quá tải thật
5. Kiểm tra lại thất bại -> khởi động lại lần nữa -> vòng lặp

Một sự cố 30 giây biến thành gián đoạn kéo dài, và nguyên nhân là chính cơ chế lẽ ra để bảo vệ. Hiện tượng này gọi là crash loop, và nó xảy ra đủ thường xuyên để trở thành một lỗi cấu hình kinh điển.

Ba nguyên tắc khi viết health check:

  1. Liveness phải rất đơn giản. Chỉ trả lời tiến trình có phản hồi được không. Không kiểm tra phụ thuộc bên ngoài, vì phụ thuộc hỏng không phải lý do để khởi động lại.
  2. Readiness kiểm tra phụ thuộc bắt buộc. Nhưng chỉ những thứ mà thiếu nó thì ứng dụng không làm được gì. Một dịch vụ gửi email hỏng không nên khiến toàn bộ API ngừng nhận request.
  3. Đặt thời gian chờ ngắn. Health check gọi database mà không đặt thời gian chờ sẽ tự nó bị treo, và hệ thống điều phối hiểu nhầm là tiến trình chết.

Loại thứ ba. Kubernetes còn có startup probe, dành cho ứng dụng khởi động chậm. Nó hoãn liveness cho tới khi ứng dụng khởi động xong, tránh việc bị giết ngay trong lúc còn đang nạp. Module 15 trình bày cấu hình đầy đủ cả ba loại.

Tự kiểm tra​

Frequently asked questions

Reverse proxy làm những việc gì?

Ba việc chính: kết thúc TLS để chứng chỉ chỉ nằm ở một chỗ và ứng dụng chỉ nói HTTP nội bộ; định tuyến nhiều tên miền về nhiều dịch vụ trên cùng một IP; và cân bằng tải giữa các instance, đồng thời ngừng gửi request tới instance đã chết.

Vì sao ứng dụng phía sau proxy không thấy IP thật của người dùng?

Vì kết nối tới ứng dụng là do proxy mở, nên ứng dụng thấy IP của proxy. IP gốc nằm trong header X-Forwarded-For. Phải cấu hình ForwardedHeaders để ASP.NET Core đọc header đó, nhưng chỉ tin những proxy đã khai báo — vì header này có thể bị giả mạo nếu tin tất cả.

Image và container khác nhau thế nào?

Image là bản đóng gói bất biến gồm lớp hệ điều hành, runtime và ứng dụng. Container là một tiến trình đang chạy từ image đó. Quan hệ giống class và instance: một image sinh ra bao nhiêu container cũng được.

Vì sao chọn base image lại quan trọng đến thế?

Vì nó quyết định thư viện C chuẩn mà mọi thư viện native sẽ liên kết tới. Bản Debian dùng glibc, bản Alpine dùng musl. Rất nhiều thư viện native chỉ phát hành bản dựng sẵn cho glibc, nên chọn Alpine thì build vẫn thành công, container vẫn khởi động, và chỉ chết đúng lúc nạp thư viện đó lúc chạy.

Điều kiện để mở rộng ngang là gì?

Ứng dụng phải stateless, nghĩa là request đi vào instance nào cũng xử lý được như nhau. Ba thứ phá vỡ điều đó: ghi file lên đĩa cục bộ, giữ session trong bộ nhớ, và cache trong bộ nhớ tiến trình. Cách xử lý lần lượt là object storage, Redis hoặc token, và cache phân tán.

Liveness và readiness khác nhau ra sao?

Liveness hỏi tiến trình còn sống không; hỏng thì khởi động lại. Readiness hỏi đã sẵn sàng nhận request chưa, ví dụ đã kết nối được database; chưa sẵn sàng thì tạm ngừng gửi request tới nhưng không khởi động lại. Nhầm hai cái này gây vòng lặp khởi động lại liên tục khi database chỉ chậm một chút.

Kết luận​

Ba điều đáng nhớ nhất:

  1. Mọi lựa chọn hosting là một cuộc đổi chác. Không có nấc nào đúng tuyệt đối, chỉ có nấc phù hợp với đội và tải của bạn.
  2. Image nhỏ không phải mục tiêu. Base image sai thì build thành công rồi chết lúc chạy.
  3. Stateless là điều kiện, không phải lời khuyên. Ghi một file lên đĩa cục bộ là đủ để chặn việc mở rộng ngang.

Tham khảo​

Điều hướng​

Bài liên quan​