Bản vẽ thành phầnĐám mây tự vận hành
Một lần đăng nhập cho đám mây hai máy

Toàn bộ đám mây của tôi là hai chiếc máy tính. Một VPS nhỏ giữ cửa trước, và một workstation trong phòng làm việc chạy k3s lo phần việc thật sự. Bạn có thể ôm cả hệ thống xuống một tầng cầu thang chỉ trong một lượt. Vậy mà, vào một lúc nào đó năm ngoái, cái đế chế hai-máy này đã tích tụ năm hệ thống thông tin đăng nhập riêng biệt — và mỗi cái là một phiên bản khác nhau của câu "để rồi tôi sẽ làm cho tử tế sau."
Đó là phần đáng xấu hổ của self-hosting mà chẳng ai đưa vào ảnh chụp homelab của họ. Mấy cái dashboard Grafana thì đẹp lộng lẫy. Còn thư mục ~/.kube thì là một hiện trường án mạng.
Bản kiểm kê của sự xấu hổ
Trước khi sửa, tôi bắt mình viết ra "đăng nhập" thật sự có nghĩa là gì xuyên suốt cả stack. Danh sách còn tệ hơn tôi tưởng:
| Hệ thống | Thông tin đăng nhập | Vòng đời | Nơi nó nằm |
|---|---|---|---|
| Kubernetes | client cert bên trong một kubeconfig sao chép | ~vĩnh viễn | ba chiếc laptop, một trong số đó đã nghỉ hưu |
| MinIO (S3) | access key tĩnh + secret | vĩnh viễn | alias mc, hai file .env |
| Vault | một token từ vault login |
gia hạn thủ công | lịch sử shell, hai lần |
| Harbor | mật khẩu tài khoản robot | vĩnh viễn | ~/.docker/config.json, dạng plaintext |
| Postgres, linh tinh | mật khẩu | vĩnh viễn | file .env của mọi dự án |
Năm hệ thống, năm câu chuyện thu hồi, và — nói thật lòng — không có thực hành thu hồi nào cả. Nếu một chiếc laptop đi lạc, kế hoạch khôi phục thực tế của tôi là "xoay vòng lại tất cả và mất toi một cuối tuần." Với một nền tảng mà lý do tồn tại của nó là vì tôi kiểm soát được nó, thì đó không phải là kiểm soát. Đó chỉ là ở gần.
Vấn đề sâu xa không nằm ở bất kỳ một thông tin đăng nhập nào. Nó nằm ở chỗ mỗi secret tĩnh là một khoản thế chấp nhỏ: nó chẳng tốn gì vào cái ngày bạn sao chép nó, rồi sau đó tính lãi mãi mãi — mỗi cỗ máy nó chạm tới, mỗi bản backup nó lọt vào, mỗi năm nó lặng lẽ không hết hạn.
Học lấy ý tưởng, bỏ qua hóa đơn
Vấn đề là, bài toán này đã được giải rồi. Ai từng làm với AWS đều biết hình dáng của lời giải: bạn gõ aws sso login, một tab trình duyệt mở ra, bạn chứng minh mình là ai một lần, và rồi mọi thông tin đăng nhập dịch vụ bạn chạm tới suốt phần còn lại của ngày đều được suy ra từ phiên đó — ngắn hạn, giới hạn phạm vi, và có thể thu hồi tại nhà cung cấp danh tính. Không có secret dài hạn nào nằm trên đĩa của bạn đáng để đánh cắp.
AWS không phát minh ra phép màu. Họ ghép lại ba tiêu chuẩn mở nhàm chán: một nhà cung cấp danh tính, các token OIDC, và token exchange cho từng dịch vụ. Tất cả những thứ đó chạy vui vẻ trên một workstation ở Đắk Lắk.
Vậy nên mục tiêu thiết kế cô đọng lại thành một câu duy nhất:
Một lần đăng nhập của con người mỗi ngày; mọi thứ khác đều được suy ra, ngắn hạn, và thu hồi được ở một nơi.
Các mảnh ghép:
- Keycloak làm nhà cung cấp danh tính — nơi duy nhất mà một mật khẩu tồn tại.
- Một CLI duy nhất — tôi gọi nó là
anh, vừa là tên tôi vừa là "anh trai" trong tiếng Việt, tùy hôm đó thế nào — thực hiện việc đăng nhập và môi giới thông tin đăng nhập cho mọi thứ còn lại. - Quy tắc khiến nó hoạt động: cái CLI không bao giờ tạo ra thứ gì. Nó chỉ trao đổi. Mỗi hệ thống hạ nguồn vốn đã nói được OIDC hoặc có một endpoint token-exchange; công việc duy nhất của CLI là bước tới trước từng cái với một token danh tính mới tinh và hỏi một cách lịch sự.
Người môi giới
anh login chạy một luồng authorization-code chuẩn với PKCE: nó mở trình duyệt, Keycloak làm việc xác thực thật sự, và CLI bắt lấy cú redirect ở localhost. Thứ rơi xuống đĩa là một file phiên với một refresh token — 0600, ngắn hạn, và vô dụng với bất kỳ ai không thể đồng thời tiếp cận được nhà cung cấp danh tính của tôi với tư cách là tôi.
Từ đó, mỗi hệ thống con là một subcommand đổi token OIDC lấy bất cứ thứ gì mà hệ thống đó hiểu một cách bản địa.
Kubernetes, mà không sao chép kubeconfig
Cái nghi thức cũ — SSH vào server, sao chép /etc/rancher/k3s/k3s.yaml, sửa tay URL của server, dán cái blob sang một laptop khác — chính là cái anti-pattern mà EKS đã khai tử bằng DescribeCluster. Bạn không sao chép một kubeconfig EKS từ đâu cả; bạn hỏi API "mô tả cụm này đi," nó đưa cho bạn endpoint và CA, và kubeconfig của bạn được tạo ra.
Vậy nên tôi dựng phiên bản mười dòng của DescribeCluster: một endpoint tí hon vén ra đúng hai sự thật — URL của apiserver và CA của cụm. Cả hai đều không bí mật (CA nằm trong ConfigMap kube-root-ca.crt mà mọi pod vốn đã nhìn thấy). Cái còn thiếu là một nơi để hỏi.
$ anh kube write-config
✓ session valid (expires in 9h)
✓ cluster-info: apiserver + CA fetched
✓ wrote ~/.kube/config (context: anhhoang)
Cái kubeconfig được tạo ra không chứa thông tin đăng nhập nào cả — chỉ là một exec plugin:
users:
- name: anhhoang
user:
exec:
apiVersion: client.authentication.k8s.io/v1
command: anh
args: ["kube", "token"]
Mỗi lần gọi kubectl sẽ shell ra anh kube token, thứ trả về token OIDC hiện tại từ phiên đã cache. Apiserver k3s được khởi động với các cờ --oidc-*, nên nó xác thực token trực tiếp với Keycloak và ánh xạ các group sang RBAC. Việc đưa một máy mới vào không còn là một buổi chiều nữa mà thành: cài CLI, chạy hai lệnh, xong. Thu hồi một máy là một cú click trong Keycloak — cái exec plugin chỉ đơn giản bắt đầu thất bại.
Lưu trữ đối tượng, mà không có access key
MinIO đã lặng lẽ ship một trong những tính năng hữu ích nhất trong hạ tầng self-hosted: một endpoint STS. AssumeRoleWithWebIdentity nhận một token OIDC và trả về thông tin đăng nhập S3 tạm thời, với policy được chọn bởi một claim trong token:
$ anh mc login
✓ exchanged OIDC token via STS (AssumeRoleWithWebIdentity)
✓ temporary credentials valid 1h, policy: dev-readwrite
✓ mc alias 'anh' updated
Những access key tĩnh từng nằm trong các file .env đã biến mất. Cái còn lại thì hết hạn trong vòng một giờ, và cái policy — bucket nào, prefix nào — được quyết định bởi tư cách thành viên group trong Keycloak, chứ không phải bởi cái key nào tình cờ được dán vào đâu.
Vault và registry
Vẫn nước đi đó, thêm hai lần nữa. Phương thức JWT auth của Vault đăng nhập bằng token OIDC và trả về một token Vault mà TTL và các policy của nó đến từ các claim trong token — anh vault login bọc lấy nó. Harbor liên kết với Keycloak cho con người, và những tài khoản robot CI còn lại thì bị giới hạn trong một dự án duy nhất và được xoay vòng, bởi vì "CI có thể push một repo image" là một bán kính sát thương rất khác so với "mật khẩu này là root ở khắp mọi nơi."
Không có tích hợp nào trong số này dài hơn một màn hình code. Công việc chưa bao giờ là code. Công việc là quyết định rằng một hệ thống sở hữu danh tính, và rồi từ chối mọi lối tắt sẽ lặng lẽ tạo ra một cái thứ hai.
Một đám mây cá nhân chỉ giành được quyền ở lại nhỏ bé nếu việc đăng nhập vào nó còn nhỏ hơn nữa.
Những cạnh sắc
Hai vấn đề cắn tôi đủ đau để đáng viết ra.
Refresh đồng thời. Keycloak xoay vòng refresh token: dùng một cái, nhận một cái mới, cái cũ chết. Giờ hãy tưởng tượng kubectl bắn ra năm lần gọi exec-plugin song song trong khi mc thức dậy ở một terminal khác — mọi tiến trình đều thấy một access token đã hết hạn, mọi tiến trình đều đua nhau refresh, một cái thắng, và cái refresh token mới của kẻ thắng bị ghi đè bởi một kẻ thua đang ghi kết quả cũ của nó ngược lại vào file phiên. Xin chúc mừng: bạn vừa bị chính thành công của mình đăng xuất. Cách sửa thì cổ lỗ sĩ đến mức ngượng: một flock quanh file phiên, để đúng một tiến trình refresh và tất cả những cái còn lại đọc lấy kết quả. Sự khiêm tốn của hệ phân tán, giao tận nơi ở quy mô một chiếc laptop.
Đăng xuất phải thu hồi. Phiên bản đầu tiên của anh logout xóa file phiên rồi coi như xong. Đó không phải đăng xuất, đó là xả rác — cái refresh token vẫn còn sống nguyên phía server, chỉ là không còn trên đĩa của tôi nữa, điều này còn tệ hơn hẳn so với việc biết nó ở đâu. Giờ đăng xuất gọi endpoint thu hồi của Keycloak trước và xóa file sau. Nếu việc thu hồi thất bại, nó nói ra thật to thay vì giả vờ.
Cả hai con bug đều chia sẻ một bài học mà tôi cứ phải học đi học lại: trong các hệ thống danh tính, những đường đi không-hạnh-phúc chính là sản phẩm. Ai cũng dựng được cái luồng chạy ngon khi một tiến trình đăng nhập một lần. Hệ thống kiếm được miếng ăn của nó lúc 2 giờ sáng, khi ba cái terminal và một cron job đều bất đồng về việc token của ai tươi mới nhất.
Cảm giác bây giờ
Nghi thức buổi sáng gói gọn trong một dòng:
$ anh login
✓ authenticated as anh (session: 10h)

Sau đó, kubectl, mc, vault, và docker push cứ thế chạy — mỗi cái lặng lẽ đổi phiên lấy thông tin đăng nhập ngắn hạn của riêng nó, không cái nào giữ thứ gì đáng đánh cắp. Bản kiểm kê của sự xấu hổ giờ chỉ còn một dòng, và cái dòng đó hết hạn trước bữa tối.
Có phải là over-engineering cho hai cỗ máy không? Tôi đã nghĩ về điều đó rất nhiều, và tôi ngã về phía không — vì một lý do chẳng liên quan gì đến bảo mật. Ma sát quyết định những gì bạn xây. Khi việc chạm vào cụm đòi hỏi khảo cổ học trên những cái kubeconfig cũ, tôi gộp các ý tưởng lại thành lô và đánh mất phần lớn chúng trong hàng đợi. Giờ khoảng cách giữa "không biết liệu—" và một cái pod đang chạy là một lần đăng nhập vốn đã được làm xong lúc ăn sáng. Nền tảng trở nên nhỏ hơn vào cái ngày cửa trước của nó nhỏ đi.
Điều đó, và một lý do lặng lẽ hơn: đây là hạ tầng của tôi. Không ai kiểm toán nó, không ai bắt tôi xoay vòng thứ gì, không ai bao giờ biết về cái mật khẩu robot dạng plaintext. Xây nó cho đúng bất chấp điều đó — lúc 5 giờ sáng, cho một khán giả đúng một người — đó mới là toàn bộ kỷ luật. Bạn không vươn lên tới thế bảo mật của những tham vọng của mình; bạn rơi xuống tới cái chuẩn của những giá trị mặc định của mình.
Vậy nên tôi đã sửa những giá trị mặc định.
Bình luận
00Để lại đôi dòng
Đang tải bình luận…