Kỹ thuật WireGuard JIT
(fly.io)- Để giảm gánh nặng trạng thái trên các gateway WireGuard mà vẫn duy trì giao tiếp trực tiếp giữa
flyctlvà Fly Machines, Fly.io đã chuyển sang cách không cài sẵn peer từ trước mà chỉ thêm vào kernel tại thời điểm kết nối - Luồng cũ có cấu trúc GraphQL API truyền cấu hình peer qua NATS RPC,
wggwdđăng ký vào SQLite và WireGuard trong kernel Linux, rồiflyctlkết nối - Khi mất thông điệp NATS kết hợp với việc các job CI tạo peer dùng một lần, hàng trăm nghìn peer không bao giờ được tái sử dụng đã tích tụ trên gateway, làm chậm thao tác trong kernel và thời gian nạp khi khởi động lại
- Cách mới chặn gói
handshake initiationtrên bộ lọc BPF hoặc đường nhận WebSockets, giải mã một phần bắt tay Noise để nhận diện khóa công khai, rồi lấy đúng peer cần thiết qua HTTP API nội bộ - Sau vài tuần triển khai, số lượng peer cũ gần như biến mất, và gateway có thể xử lý việc thiết lập peer cũng như khởi động lại nhanh hơn với ít trạng thái hơn
Cách Fly.io sử dụng WireGuard
- Fly.io chạy container trên VM dựa trên Firecracker và dùng WireGuard ở nhiều nơi như một phần của API khách hàng
- Khi chạy,
flyctltạo một ngăn xếp TCP/IP với địa chỉ IPv6 riêng và giao tiếp trực tiếp với Fly Machines trong mạng Fly.io - Cách tiếp cận này giúp dễ biểu diễn các tính năng như remote Docker builder như thể chúng nằm cùng một LAN, nhưng khó vận hành ổn định trong thời gian dài hơn
- Cuối cùng Fly.io đã chuyển đường mặc định sang WireGuard-over-WebSockets
Luồng cấp phát gateway cũ
- Fly.io nối các kết nối WireGuard đi vào qua nhiều máy chủ gateway trên toàn thế giới tới đúng mạng riêng phù hợp
- Khi
flyctlcần giao tiếp với Fly Machine để build container, mở SSH console, sao chép file hoặc proxy dịch vụ, nó sẽ khởi chạy hoặc kết nối tới một tiến trình agent chạy nền - Khi chạy lần đầu, agent tạo một cấu hình peer WireGuard mới từ GraphQL API
- Cấu hình peer gồm khóa công khai và địa chỉ để kết nối
- API chuyển cấu hình đó tới gateway phù hợp qua RPC của hệ thống nhắn tin NATS
wggwdtrên gateway nhận cấu hình, lưu vào SQLite, thêm vào kernel bằng thư viện WireGuard Go, rồi phản hồi lại API rằng việc cài đặt đã hoàn tất- Khi API trả lại cấu hình cho yêu cầu GraphQL,
flyctlsẽ kết nối bằng peer WireGuard đã được cài sẵn trên gateway
Vì sao kiến trúc cũ trở nên chậm
- NATS rất nhanh nhưng không đảm bảo phân phối, nên khó dùng như một nền tảng API đáng tin cậy
- Fly.io đã giảm dần việc dùng NATS nội bộ, ví dụ API
flydnội bộ đã chuyển từ nền tảng NATS sang HTTP - Dù đã giảm dùng NATS, gateway WireGuard vẫn được cải thiện nhưng chưa đủ
- Fly.io đã giảm dần việc dùng NATS nội bộ, ví dụ API
- Các peer WireGuard được tạo ra sau khi
flyctlthoát vẫn ở lại trên gateway, và không có quy trình dọn dẹp peer cũ- Có lý do để không xóa peer vì có thể hôm sau lại deploy tiếp hoặc debug bằng
fly ssh console - Nhưng phần lớn peer lại được tạo trong các job CI không có lưu trữ bền vững, nên ở lần chạy sau không thể kết nối lại bằng cùng peer và mỗi lần đều tạo peer mới
- Có lý do để không xóa peer vì có thể hôm sau lại deploy tiếp hoặc debug bằng
- Kết quả là gateway giữ hàng trăm nghìn peer có thể sẽ không bao giờ được tái sử dụng
- Khi số peer cũ tăng cao, các thao tác WireGuard trong kernel trở nên rất chậm
- Đặc biệt chậm là quá trình nạp lại toàn bộ peer vào kernel sau khi máy chủ gateway khởi động lại
- Thậm chí còn phát sinh một số kernel panic
Thiết kế chỉ cài peer vào kernel khi cần
- Việc lưu toàn bộ lịch sử peer WireGuard trong một SQLite không khó, nhưng giữ tất cả peer trong kernel Linux mới là nút thắt cổ chai
- Thay vì đẩy cấu hình tới gateway, Fly.io chọn cách để gateway lấy peer theo nhu cầu từ API
- Nếu chỉ thêm peer vào kernel khi client thực sự cố kết nối, thì peer cũ có thể bị xóa khỏi kernel bất kỳ lúc nào
- Ngay cả peer đã bị xóa vẫn có thể được lấy lại và cài lại ở lần kết nối sau, nên gateway không còn phải giữ quá nhiều trạng thái dài hạn
- Tuy vậy, WireGuard trong kernel Linux không có API để đăng ký sự kiện “incoming connection attempt”
Cách triển khai peer WireGuard JIT
- Giao diện cấu hình WireGuard trong kernel Linux là Netlink, và thư viện điều khiển WireGuard Go dùng wgctrl-go
- Fly.io tận dụng việc yêu cầu kết nối WireGuard là các gói có thể nhận diện được để tự tạo sự kiện bằng bộ lọc BPF và packet socket
- Trên đường WireGuard qua WebSockets, việc lấy gói WireGuard thô còn dễ hơn
- Đường này trao đổi các gói UDP được đóng khung qua kết nối WebSockets không xác thực với giao diện gateway
- Vì Fly.io sở hữu mã daemon đó, họ có thể gắn hook vào hàm nhận gói
- WireGuard không có khái niệm “client” và “server”; đây là giao thức point-to-point để các peer kết nối với nhau khi gửi lưu lượng
- Bên bắt đầu trước là initiator, bên còn lại là responder
- Trong Fly.io, thông thường
flyctllà initiator còn gateway là responder
- Gói UDP đầu tiên theo bài báo WireGuard là
handshake initiation, và loại gói được ghi trong 1 byte bản rõ- Fly.io dùng bộ lọc BPF
udp and dst port 51820 and udp[8] = 1để bắt các kết nối đi vào
- Fly.io dùng bộ lọc BPF
Nhận diện peer trong bắt tay Noise
- WireGuard dựa trên Noise Protocol Framework, và Noise che giấu định danh trong lúc bắt tay để ẩn danh tính
- Vì vậy không thể đơn giản đọc một giá trị như tên người dùng từ gói rồi tra thẳng cấu hình
- Để nhận diện yêu cầu đi vào, Fly.io chạy một phần mã hóa của Noise để giải mã danh tính
- Phần mã này khá khó nhưng chỉ khoảng 200 dòng
- Giao diện Netlink của kernel có thể cung cấp khóa riêng của giao diện cho tiến trình đặc quyền, nên có thể lấy các bí mật cần thiết
- Mã liên quan được công khai tại gist
- Qua quy trình này, họ thu được một luồng sự kiện khóa công khai của người dùng đang cố tạo kết nối WireGuard tới gateway
Tối ưu cài đặt, cache và thử lại
- Gateway giữ một cache giới hạn tốc độ trong SQLite, và khi phát hiện peer mới, nó gửi yêu cầu tới HTTP API nội bộ để lấy thông tin peer tương ứng rồi cài đặt
- Logic này rất phù hợp để đưa vào daemon nhỏ vốn đã quản lý WireGuard trên gateway
- Các peer cũ giờ có thể bị xóa chủ động bằng tác vụ
cron - Việc tra API cho peer mới có thể không đủ nhanh để phản hồi ngay thông điệp
handshake initiationđầu tiên- WireGuard sẽ thử lại rất nhanh nên bản thân hoạt động không gặp vấn đề
- Họ còn dùng một tính năng WireGuard Netlink trên Linux do Jason Donenfeld chỉ ra để thiết lập kết nối nhanh hơn
- Từ thông điệp initiation đi vào, họ lấy được địa chỉ 4-tuple gồm cả source port tạm thời của
flyctl - Gateway cài peer như thể chính nó là initiator còn
flyctllà responder - Kernel Linux sẽ chủ động bắt đầu kết nối WireGuard về phía
flyctl, và giao thức này không phụ thuộc nhiều vào vai trò server hay client - Kết nối mới được thiết lập gần như nhanh bằng tốc độ cài đặt có thể đạt được
- Từ thông điệp initiation đi vào, họ lấy được địa chỉ 4-tuple gồm cả source port tạm thời của
Kết quả sau khi áp dụng vào vận hành
- Cách làm này đã chạy trong production suốt vài tuần
- Số peer WireGuard cũ trên mỗi gateway, trước đây từ hàng nghìn đến hàng trăm nghìn, đã giảm gần về 0
- Lượng trạng thái mà gateway phải giữ đã giảm
- Thiết lập peer nhanh hơn
- Khi khởi động lại, không còn cần nạp lại nhiều peer không sử dụng vào kernel như trước
1 bình luận
Các ý kiến trên Hacker News
Tôi không hiểu lắm ý nói rằng WireGuard trong kernel Linux không có khả năng cài đặt peer khi cần. Có vẻ vẫn có thể thêm peer trong lúc chạy: https://serverfault.com/questions/1101002/wireguard-client-a...
Nếu tôi hiểu đúng thì bước đó đã là quá muộn, và có vẻ họ muốn xác thực trước khi thêm peer để tránh để lại các mục cũ trên interface
Vì vậy cấu trúc có vẻ là đặt một bộ lọc eBPF phía trước interface, tự thử kết nối xem đối phương có được phê duyệt dựa trên định tuyến bằng khóa mật mã hay không, nếu qua thì thêm peer vào interface rồi xóa sau khi timeout
Tất cả peer có thể nằm trong một nơi như SQLite, rồi khi client thử kết nối thì cài đặt khi cần
Từ góc nhìn nhà cung cấp VPN, API hiện tại hơi thô. Thực tế cũng có điểm là tại một thời điểm chỉ một phần peer đang được dùng, nhưng khi số peer tăng từ hàng trăm nghìn lên hàng chục triệu, việc lưu tất cả trong một instance kernel trở nên bất khả thi
Nếu phải cài sẵn peer thì cuối cùng sẽ bị ràng buộc vào một máy chủ cụ thể
Như bài viết nói, ngay cả hiện nay cũng có thể tạo thứ gần giống interface cần thiết bằng packet capture đơn giản, và nhờ Jason đã thiết kế API tốt nên có thể đảo chiều khởi tạo giữa server và client rất dễ dàng. Dù kernel đã bỏ thông điệp khởi tạo đầu tiên, người dùng vẫn cảm thấy như kết nối diễn ra mượt mà
Jann Horn còn đi xa hơn một bước, nói rằng có thể lưu gói khởi tạo đã capture rồi sau khi cài peer thì bơm lại vào kernel, và đó cũng là một ý tưởng khá hay
Tôi không nghĩ bài này đến mức thay đổi cuộc đời, mà giống vài mẹo gọn gàng mà mọi người nên biết hơn
Bước tiếp theo là dựa trên đó để tạo floating peers, tách peer hoàn toàn khỏi khu vực địa lý. Khi đó người dùng không cần quan tâm peer được cấu hình ở region nào, và điều này có vẻ có lợi ích sản phẩm thực tế chứ không chỉ là trò vui cho dân mê kỹ thuật
Cụm JIT WireGuard nghe hơi lạ. Suy nghĩ đầu tiên của tôi là “tại sao? Nút thắt hiệu năng là mã hóa, còn JIT theo từng client thì đâu giúp được gì ở đó”
Nếu là tôi, có lẽ tôi sẽ chuyển thẳng sang user space. Dùng thứ như tokio-uring hoặc glommio để vắt hiệu năng là được
Nếu cứ cố đẩy trong kernel, họ sẽ liên tục đụng trần vì Linux không được tạo ra để xử lý hàng triệu tunnel đang hoạt động. Chỉ riêng hàng triệu kết nối TCP trong một kernel đôi khi cũng đã rắc rối
Mỗi giới hạn lại cần một hack, và mỗi hack lại sinh ra các thiết lập hệ thống phải áp dụng và quản lý. Toolchain provisioning máy chủ vật lý Linux tụt xa so với các công cụ phát triển ứng dụng/dịch vụ và quản lý cấu hình
Hay là tôi ngốc nên đã hiểu sai điều gì đó?
Nếu muốn tạo peer WireGuard user-space trong ứng dụng Go, bạn có thể xem dự án thử nghiệm gần đây này: https://github.com/dpeckett/noisysockets
Nó dựa trên công trình tuyệt vời của wireguard-go, nhưng cố gắng đơn giản hơn để dùng như thư viện và mang phong cách Go hơn
Dùng cái này để tạo service mesh có vẻ thú vị. Hỗ trợ nhiều ngôn ngữ sẽ khó, nhưng có lẽ cũng có thể triển khai socket API
Tuy nhiên tôi chưa thấy tăng tốc phần cứng cho mã hóa WireGuard, nên về hiệu năng có thể khó cạnh tranh với mTLS
Nhân tiện, hiện tôi đang tìm việc freelance, nên nếu cần freelancer Golang trong lĩnh vực mạng tốc độ cao và bảo mật, có thể liên hệ
Nó giống Magic Wormhole cho tunnel tùy ý, và tôi hy vọng cũng có thể cải thiện đáng kể vấn đề truyền file bị sụp từ 20–30 MB/s trên các mạng dài, băng thông cao
0 - https://github.com/slackhq/nebula
Nhìn chung tôi đồng ý rằng với thông điệp điểm-điểm đơn lẻ, request HTTP trực tiếp có thể đáng tin cậy hơn đi qua message queue, nhưng tôi hơi ngạc nhiên khi NATS làm mất nhiều message đến vậy và gây ảnh hưởng lớn tới dịch vụ
Nếu message bị mất, chẳng phải NATS sẽ gửi lại cho đến khi thành công sao? Tôi tò mò không biết có ai hiểu vì sao họ lại gặp độ bất ổn đủ để cảm nhận được như vậy không
Kiến trúc của NATS trực quan và hấp dẫn, nên tôi tò mò nó lệch ở đâu. JetStream có nhiều tham số có thể điều chỉnh
Ví dụ có thể cấu hình memory stream với cửa sổ phát hiện trùng lặp dựa trên thời gian, chế độ push/pull, thiết lập chính sách gửi lại và xác nhận, v.v.
Tuy nhiên nó có thể không hợp với kết nối một lần cho một message đơn lẻ. Dù thế nào, nếu có chi tiết cụ thể hơn thì sẽ rất hữu ích
Nhưng rốt cuộc chúng tôi không cần nó. Lớp message không làm tăng khả năng biểu đạt, mà chỉ khiến việc test và monitoring khó hơn
Phần “thiết lập peer như thể chúng ta là initiator và đặt flyctl làm responder. Nhân Linux khởi động lại kết nối WireGuard về phía flyctl” thực chất có phải là thêm độ trễ nửa vòng khứ hồi vào handshake không?
Ví dụ, tôi tò mò liệu luồng có giống như: 1) flyctl gửi Initiation, 2) peer được thêm qua netlink và gửi Initiation mới, 3) flyctl gửi Response hay không
Tức là không có bước 3 hoặc không cần chờ, và nếu chặn việc khởi tạo mới ở bước 2 thì chắc chắn sẽ không như vậy
1.a) Bob không bắt máy, nhưng thêm số trên caller ID vào danh bạ
Tôi không hiểu câu “mỗi lần chạy flyctl, CLI đồ sộ đáng yêu của chúng ta tạo ra một TCP/IP stack từ hư không, có địa chỉ IPv6 riêng, và giao tiếp trực tiếp với các Fly Machines đang chạy trong mạng của chúng ta” nghĩa là gì
Lý do họ diễn đạt là “tạo TCP/IP stack từ hư không” là vì thông thường hệ điều hành cung cấp TCP/IP stack như một phần của kernel
Trong wireguard-go, TCP/IP stack chạy ở user space, nên có thể tạo bên trong một tiến trình user space thông thường như giao diện dòng lệnh flyctl
Với những người đã làm việc với hệ thống từ lâu thì điều này trông khá như ma thuật. Một TCP/IP stack user space chạy trong tiến trình mà dùng được trong thực tế là thứ tương đối mới và lạ
Tôi tò mò điều gì ngăn việc bơm lại gói handshake đầu tiên vào network stack. Như vậy có vẻ sẽ không bị mất gói
Ngoài ra, tôi cũng tò mò mục đích của việc kiểm tra
udp[8] = 1trong bộ lọc eBPF là gìNhư bình luận bên cạnh đã nói, bộ lọc BPF chỉ bắt các gói khởi tạo, và đó là hành vi mong muốn. Nó là phiên bản WireGuard của việc sniff SYN để quan sát lúc bắt đầu kết nối TCP
udp[8] = 1chỉ lọc các gói handshake. Nếu không có nó thì các gói dữ liệu cũng sẽ bị gửi tới daemon user spaceTôi không chắc có thể phát lại handshake đầu tiên hay không, nhưng vì WireGuard bỏ qua các client không biết nên có thể là được
Thật thú vị khi mặc định họ tunnel WireGuard qua WebSocket. Không tốt cho hiệu năng, nhưng có vẻ ổn cho các tác vụ kiểu DevOps mà flyctl dùng
Tôi cũng từng tò mò về điểm này khi nghĩ về tương lai của QUIC/HTTP3. Khả năng các nhà vận hành mạng chặn hẳn cổng UDP 443 thay vì xử lý đúng cách cũng không phải bằng 0
flyctlcũng có tùy chọn cấu hìnhNếu UDP không chạy thì nó hoàn toàn không chạy và cũng khó debug, nên mặc định được đặt về phía mà chúng tôi biết chắc sẽ hoạt động
Tôi hơi cay vì đã thua trong cuộc tranh luận về việc nên chọn mặc định nào
Startup của tôi đã dùng Fly gần 1 năm. Chức năng cốt lõi biến code thành code đã deploy trong chưa tới 1 phút thực sự rất đẹp
Việc bật và tắt node mới để backfill cũng chỉ mất vài giây
Nhưng bản thân công ty thì tôi thấy hơi non nớt. Có lần API server không thể truy cập trên Fly trong 48 giờ, và tôi không chắc đó là lỗi cấu hình của mình hay lại là một sự cố “im lặng” khác
Họ có sản phẩm “db” nhưng lại kiểu “không phải managed Postgres”, và ở đó cũng liên tục xảy ra gián đoạn
Tôi thấy lạ khi họ thêm Postgres làm danh từ cấp cao nhất trong CLI, nhưng lại giới hạn phạm vi tính năng hỗ trợ
Việc truy cập API dịch vụ cốt lõi cũng thường xuyên bị sập, khiến chúng tôi phải chờ mới có thể sửa và deploy dịch vụ mới
Tôi nhớ trải nghiệm deploy đó, nhưng nói thật hiện giờ tôi hài lòng với Cloud Run của GCP hơn. Ít “bất ngờ” hơn nhiều và tài liệu cũng hoàn thiện hơn nhiều
Tôi thấy lạ là các nhà cung cấp VPS hầu như không hỗ trợ giảm độ trễ dịch vụ backend cho người dùng. Không nơi nào hỗ trợ Anycast, và lựa chọn GeoDNS cũng rất ít
Tuy nhiên GeoDNS lại thêm độ phức tạp riêng
Tôi ước chi phí truyền dữ liệu của Fly.io rẻ hơn. Hiện tại, trong một dịch vụ giống ngrok mà tôi đang làm, tôi phải tự tái triển khai một cách vụng về khá nhiều tính năng của Fly.io
[0]: https://lastlogin.io
[1]: Phần code riêng cho Fly cần thiết để chạy LastLogin theo kiểu phân tán toàn cầu chỉ có chừng này: https://github.com/lastlogin-io/obligator/blob/37f75cc861f1b...
Khi hoạt động tốt thì nó thực sự rất mượt, nhưng tần suất như vậy là chưa đủ
Nhân dịp này tôi muốn giới thiệu Netmaker[0]
Tôi không liên quan gì, chỉ là người đang dùng khá hài lòng vì cần truy cập AWS VPC riêng tư trên nhiều tài khoản. Hy vọng nó được áp dụng rộng rãi hơn
[0] https://www.netmaker.io/
Ở công ty trước, tôi từng dùng Ansible để thiết lập và quản lý wg trên vài máy Windows và Linux; cũng ổn, nhưng về cuối thì hơi lộn xộn
Trang web quá mơ hồ
Phần “một gateway có hàng trăm nghìn peer, trong đó có những peer sẽ không bao giờ được dùng lại” chính xác là điều tôi đã nghĩ đến khi đọc các đoạn đầu
Ý tưởng “không có lệnh gọi API nào để subscribe sự kiện thử kết nối đến. Không sao. Ta tự tạo sự kiện. Yêu cầu kết nối WireGuard là packet và có thể nhận diện dễ dàng, nên có thể bắt hiệu quả bằng bộ lọc BPF và packet socket” cũng hay
Khi nhận được thông điệp khởi tạo gửi đến, họ nói sẽ có địa chỉ 4-tuple của kết nối mong muốn, bao gồm cả cổng nguồn tạm thời mà flyctl dùng, rồi cài đặt peer như thể chúng ta là initiator còn flyctl là responder; tôi tò mò liệu cách này có hoạt động cả khi ở sau NAT không
{wggwd.fly.io, 12345, clientIP, 23456}Dù là gói UDP “initiator” mới, hay phản hồi cho thông điệp khởi tạo đi ra, đối với UDP NAT trên đường truyền thì trông chính xác như nhau
Vì cơ sở phán đoán chỉ là 4-tuple, và 4-tuple đó giống nhau