Toàn bộ Kidslen — API Kotlin, dashboard cho phụ huynh, ops console, marketing site, PostgreSQL, Prometheus, Grafana, Alertmanager, và cả CI build tất cả những thứ đó — chạy trên một LXC container Proxmox đặt trong một căn phòng ở nhà tôi. HTTPS public, bốn hostname, pipeline push-là-deploy hoạt động thật, và không mở một cổng inbound nào trên router.
Nghe vậy nhiều người nghĩ ngay: rẻ nhưng mong manh. Rẻ thì đúng. Mong manh thì không, vì những chỗ mong manh đã được thiết kế biến mất chứ không phải được trả tiền để biến mất. Bài này là chương hạ tầng của series: chiếc máy đó làm gì, mẹo nào khiến deploy an toàn, và nếu tôi bớt cứng đầu thì cùng chừng đó thứ trên cloud sẽ tốn bao nhiêu.
Một container, không phải mười hai
Phản xạ thông thường với một stack cỡ này là chia nhỏ: mỗi service một VM, hoặc Kubernetes, hoặc database managed vì database thì đáng sợ. Tôi không làm cái nào cả.
Có đúng một LXC container unprivileged. Bên trong: các unit systemd cho API Spring Boot, nginx phục vụ ba bundle tĩnh (dashboard, ops console, marketing site), PostgreSQL, và bộ ba monitoring. Mọi thứ nói chuyện qua localhost. Không có service mesh, vì không có mesh nào cả — chỉ có một interface loopback.
Lý do không phải lười, mà là phép tính bề mặt lỗi. Mỗi bước nhảy qua mạng giữa hai thành phần của chính hệ thống mình là một bước có thể timeout, retry, half-open, hoặc hỏng theo kiểu mà tôi phải viết code để xử lý. Tài nguyên khan hiếm nhất của một người làm sản phẩm một mình là sự tập trung. Phân tán một hệ thống chưa cần phân tán tức là tiêu sự tập trung đó vào đường ống thay vì vào sản phẩm.
Proxmox cho tôi đúng hai thứ tôi thật sự cần từ ảo hoá: snapshot trước mỗi thay đổi rủi ro, và một biên container để một bản build chạy loạn không kéo cả host xuống. Backup là pg_dump cộng snapshot, đẩy ra ngoài máy, mỗi đêm.
Chiếc runner build chính cỗ máy nó đang sống trong đó
Đây là phần khiến người ta nhíu mày. CI của Kidslen chạy trên GitHub Actions, nhưng job Linux thực thi trên một runner riêng nằm ngay trong container đang chạy production.
Chuỗi sự kiện khi push lên main:
- GitHub nhận push và dispatch workflow.
- Runner agent trong LXC nhận job.
- Nó build backend Kotlin bằng Gradle, chạy 57 test backend, build ba frontend, chạy unit suite và các spec Playwright end-to-end.
- Nếu tất cả xanh, nó ghi artefact mới cạnh artefact đang chạy rồi restart các unit systemd.
Không artefact nào rời khỏi máy. Không registry, không bước deploy qua SSH, không secret nào trong GitHub có thể đẩy code vào máy tôi — GitHub hoàn toàn không với tới được runner; runner là bên chủ động gọi ra GitHub. Chiều đảo ngược đó chính là toàn bộ câu chuyện bảo mật ở đây. Hệ thống CI không giữ credential nào của hạ tầng tôi, bởi vì nó chưa bao giờ kết nối vào.
Phản biện hiển nhiên: build giành CPU với production là tự chuốc rắc rối. Đúng vậy, và tôi xử theo cách nhàm chán nhất: service runner được nice và giới hạn bộ nhớ bằng systemd slice, số worker của Gradle ghim thấp hơn hẳn số core, và API có health endpoint mà Alertmanager theo dõi trong lúc build. Mấy tháng qua, độ trễ duy nhất tôi đo được trong lúc build nằm ở vài query report nặng của ops console — mà ops console thì có đúng một người dùng.
Build iOS thì không thể chạy trong container Linux, nên có runner riêng thứ hai trên một chiếc Mac mini, gắn label macos, chỉ nhận job iOS của companion app. Máy riêng, label riêng, không chạy gì khác. Toolchain của Apple là phần duy nhất trong stack này tôi không container hoá được, và giả vờ ngược lại thì đã tốn của tôi một tuần.
Cổng chặn: smoke test đi hết luồng thật
Unit test xanh không có nghĩa sản phẩm chạy. Tôi học bài này với giá khá đắt, và đó là nội dung bài kế tiếp trong series. Hàng phòng thủ tôi dựng lên là một cổng deploy không kiểm tra code — nó kiểm tra hệ thống đang chạy.
Sau khi các unit restart và qua health check, workflow chạy một script thực hiện trọn vẹn hành trình lõi trên API thật, đúng như một client thật:
| Bước | Chứng minh điều gì |
|---|---|
| Đăng ký tài khoản phụ huynh | Auth, băm Argon2id, phát token |
| Tạo hồ sơ trẻ | Mô hình sở hữu, phân quyền deny-by-default |
| Sinh mã enrollment | Phát và lưu mã có hạn ngắn |
| Enroll một thiết bị mô phỏng bằng mã đó | Bắt tay ghép đôi, định danh thiết bị |
| Gửi heartbeat | Thiết bị còn sống, xoay vòng refresh token |
| Ingest một lô sự kiện hoạt động | Pipeline JSONB có hàng đợi, khử trùng lặp |
| Đọc lại activity feed | Processor, projection, xử lý ngày tháng |
| Lấy ops overview | Tổng hợp xuyên tenant, auth phía ops |
Bất kỳ bước nào hỏng, workflow fail ầm ĩ và tôi nhận alert. Script dùng một tenant test riêng mà job retention sẽ dọn, và nó chạy trên production — vì smoke test trên staging chỉ chứng minh được điều gì đó về staging.
Cái bảng trên cố tình trùng đúng với lời hứa lõi của sản phẩm. Phụ huynh cài companion app lên máy con, đứa trẻ thấy màn hình đồng thuận và banner thường trực, phụ huynh thấy hoạt động hiện lên. Chuỗi đó chạy thì Kidslen chạy. Chuỗi đó gãy thì mọi thứ khác vô nghĩa — nên cổng chặn đi đúng chuỗi đó, mỗi lần deploy, trước khi tôi được phép nói là đã ship.
Thêm cổng này là ngày làm hạ tầng đáng giá nhất của cả dự án. Nó đã bắt được một migration database hỏng, một CORS origin cấu hình sai, và một thay đổi serialisation biên dịch ngon lành nhưng trả về sai cấu trúc.
Cloudflare Tunnel: bốn hostname public, không cổng nào mở
Router nhà tôi không forward gì hết. Không 443, không 80, không SSH. Thay vào đó, daemon cloudflared trong container mở kết nối đi ra tới edge của Cloudflare và giữ chúng mở; Cloudflare kết thúc TLS ở edge rồi đẩy request ngược xuống theo chính các kết nối đó.
Bốn hostname trỏ tới bốn service nội bộ:
kidslen.app— marketing site bằng Astroapp.kidslen.app— dashboard cho phụ huynhapi.kidslen.app— API Spring Bootops.kidslen.app— ops console
Tính chất bảo mật của cách này tốt hơn bất cứ thứ gì tôi tự dựng tay. Không có cổng nào đang lắng nghe để ai đó quét. Địa chỉ IP nhà tôi không nằm trong DNS. Gia hạn chứng chỉ không còn là việc của tôi. Hấp thụ DDoS xảy ra ở một edge có năng lực lớn hơn đường truyền nhà tôi rất nhiều.
Ops console có thêm lớp thứ hai: Cloudflare Access đứng trước ops.kidslen.app và bắt đăng nhập qua nhà cung cấp danh tính trước khi request kịp được proxy vào container. Bản thân console cũng có xác thực riêng — Access không thay thế phân quyền, nó là bảo vệ đứng ở cửa toà nhà. Ai dò ra hostname sẽ gặp thử thách danh tính từ Cloudflare, chứ không phải một trang HTML để mò.
Một điểm cần nói thật: cách này biến Cloudflare thành phụ thuộc. Tunnel chết thì Kidslen không truy cập được, và tôi không thể SSH vào đâu đó để sửa. Tôi chấp nhận, vì phương án còn lại — tự làm edge, tự lo chứng chỉ, phơi IP nhà — là bề mặt rủi ro lớn hơn, vận hành bởi đúng một người vốn còn phải viết sản phẩm.
Chi phí thật, nói thẳng
Đây là hoá đơn thật, rồi mới tới phần so sánh mà ai cũng muốn nghe.
Hiện tại tôi trả gì: hai domain (kidslen.com và kidslen.app, mua hai năm), tiền điện cho một chiếc máy vốn đã chạy sẵn, và đường internet ở nhà mà đằng nào tôi cũng trả. Cloudflare Tunnel và Access ở quy mô này không tốn gì. Chiếc Mac mini vốn đã nằm trên bàn.
Chi phí tiền mặt tăng thêm hàng tháng để chạy hạ tầng Kidslen, nói cho tròn và thật, là hai cái domain chia đều — và hết.
Nếu chuyển lên cloud thì sao. Tôi sẽ không bịa ra con số, nên hãy nói chính xác về hình dạng của nó. Để tái tạo những gì container đang làm, tôi sẽ phải trả cho:
| Thành phần | Tương đương trên cloud |
|---|---|
| API + frontend | Một instance compute nhỏ chạy thường trực |
| PostgreSQL | Một database managed kèm backup |
| Prometheus / Grafana / Alertmanager | Gói observability managed, hoặc một instance thứ hai |
| Phút CI | Runner Linux của nhà cung cấp, cộng runner macOS cho iOS đắt gấp nhiều lần Linux |
| Egress | Tính theo lưu lượng, mà kết nối SSE thì sống rất lâu |
Từng dòng đều khiêm tốn, cộng lại thành một khoản thuê bao hàng tháng có thật — loại con số không đáng kể với một đội có vốn, và khó chịu ra trò với một sản phẩm chưa có doanh thu. Dòng runner macOS mới là dòng khiến người ta bất ngờ: phút macOS thuê ngoài là thứ đắt nhất trong CI mobile thông thường, và một chiếc Mac mini trên bàn xoá sạch dòng đó.
Tôi trả bằng gì thay cho tiền. Đây là phần các bài viết về hạ tầng hay bỏ qua. Tôi trả bằng:
- Sự chú ý vận hành. Postgres cần nâng cấp thì đó là buổi tối của tôi.
- Một điểm chết duy nhất. Một máy, một căn nhà, một đường internet. Snapshot và backup ngoài máy giới hạn thiệt hại; chúng không đưa nó về không.
- Một trần cứng. Kiến trúc này đúng cho một sản phẩm early access phục vụ số ít gia đình. Nó không đúng ở mười nghìn thiết bị, và tôi biết chỉ số nào sẽ báo: độ sâu hàng đợi ingest và số kết nối SSE. Khi hai con số đó dịch chuyển, kiến trúc dịch chuyển theo. Không chỗ nào trong thiết kế giả định container là vĩnh viễn: API phi trạng thái ngoài database, frontend là bundle tĩnh, còn tunnel thì không quan tâm phía sau nó là gì.
Chính điểm cuối mới làm toàn bộ chuyện này đứng vững được, chứ không chỉ là rẻ. Hạ tầng nhỏ có chủ đích, và di chuyển được theo thiết kế.
Vì sao điều này quan trọng với một sản phẩm dành cho trẻ em
Một sản phẩm giám sát trẻ vị thành niên là một sản phẩm giữ dữ liệu nhạy cảm của những người không thể tự đồng thuận theo nghĩa pháp lý. Mỗi nơi dữ liệu đó bị sao chép thêm — một cache CI thuê ngoài, một dịch vụ gom log bên thứ ba, một registry artefact — là thêm một chỗ có thể rò, và thêm một nhà cung cấp mà sự cố của họ trở thành sự cố của tôi.
Chạy stack trong một container tôi kiểm soát không phải là tối ưu chi phí khoác áo nguyên tắc. Nó thật sự làm giảm số hệ thống từng chạm vào dữ liệu hoạt động của một đứa trẻ — cùng lý do khiến sản phẩm thu tiêu đề và thời lượng chứ không phải ảnh màn hình. Bán kính thiệt hại nhỏ, ở mọi chỗ, một cách có chủ đích.
Artefact build không rời máy. Database không rời máy. Thứ duy nhất vượt biên là các kết nối tunnel đi ra và push notification, và cả hai đều mang theo lượng tối thiểu có thể.
Bài tiếp theo trong series: tuần mọi thứ đổ vỡ — một bug threading chỉ xuất hiện trên máy chậm, một bản build native không chịu biên dịch, và một định dạng ngày trả về HTTP 400 ngay trước ống kính.
Muốn xem hạ tầng này đang phục vụ cái gì, mời ghé kidslen.app.