Πριν κάνεις upgrade: έλεγξε τους proxies και τα meshes σου για το WebSockets transport του kubectl
// Το Kubernetes αποσύρει το SPDY streaming transport πίσω από τα kubectl exec, attach, cp και port-forward και περνάει σε WebSockets. Εδώ θα βρεις ένα audit που μπορείς να τρέξεις στα clusters σου αυτή την εβδομάδα, για να εντοπίσεις το hop που θα σπάσει πριν το εντοπίσει το upgrade για λογαριασμό σου.
Τα χειρότερα incidents σε upgrade είναι εκείνα όπου στην ουσία τίποτα δεν είναι κάτω. Ο API server είναι υγιής, όλα τα workloads τρέχουν, τα dashboards είναι πράσινα, κι όμως τρεις engineers κάθονται σε ένα call επειδή το kubectl exec σε ένα production pod κολλάει και το kubectl port-forward πεθαίνει μετά από ένα λεπτό. Κανείς δεν πείραξε το networking του cluster. Αυτό που άλλαξε είναι το HTTP handshake με το οποίο το kubectl ανοίγει ένα streaming connection, και κάτι στη διαδρομή ανάμεσα στο laptop και τον API server έχει άποψη επί του θέματος.
Αυτή είναι η φύση της μετάβασης από SPDY σε WebSockets (KEP-4006). Δεν είναι πρόβλημα των workloads, ούτε του API server. Είναι πρόβλημα των hops που δεν σου ανήκουν: ο εταιρικός egress proxy, ο reverse proxy μπροστά από το endpoint του API server, ο cloud load balancer, ο proxy του mesh. Σε αυτό το post θα βρεις το audit που θα έτρεχα εγώ πριν αγγίξω την έκδοση ενός cluster, μαζί με τις ρυθμίσεις ανά hop που μετράνε πραγματικά.
Ποιον αφορά αυτό το post;#
Το post απευθύνεται σε:
- SREs που έχουν την ευθύνη των Kubernetes cluster upgrades και των οποίων το
kubectltraffic περνάει από τουλάχιστον ένα hop που δεν ελέγχουν - Platform Engineers που τρέχουν mesh, ingress controller ή bastion proxy μπροστά από τα endpoints των clusters
- Οποιονδήποτε έχει incident runbooks που ξεκινάνε με «κάνε exec στο pod και δες»
Τι άλλαξε στην πράξη#
Τα kubectl exec, kubectl attach, kubectl cp (που πατάει πάνω στα primitives του exec) και kubectl port-forward χρειάζονται όλα ένα μακρόβιο, αμφίδρομο stream. Ιστορικά το έπαιρναν κάνοντας upgrade ένα HTTP/1.1 request σε SPDY. Σύμφωνα με το KEP-4006, το SPDY «has been deprecated since 2015, and by now many proxies, gateways, and load-balancers do not support SPDY». Ο αντικαταστάτης είναι το WebSockets, και φέρνει μαζί του δύο νέες εκδόσεις subprotocol:
| Ομάδα εντολών | Subprotocol | Έκδοση SPDY | Έκδοση WebSockets |
|---|---|---|---|
| exec, attach, cp | RemoteCommand | v4.channel.k8s.io | v5.channel.k8s.io |
| port-forward | PortForward | v1.portforward.k8s.io | v2.portforward.k8s.io |
Και τα δύο transports ξεκινάνε ως ένα απλό HTTP request και καταλήγουν σε 101 Switching Protocols. Εκεί τελειώνουν οι ομοιότητες, και η διαφορά τους είναι ακριβώς αυτό που βλέπει ένα ενδιάμεσο hop.
Πού βρισκόμαστε σήμερα#
Εδώ τα κάνουν λάθος τα περισσότερα άρθρα, οπότε διάβασε τις εκδόσεις προσεκτικά.
Τα metadata του ίδιου του KEP (kep.yaml) γράφουν stage: stable, latest-milestone: v1.38 και milestone.stable: v1.38, και το PR για την προαγωγή σε GA (kubernetes/enhancements#6364) έγινε merge στις 2026-09-21 με milestone το v1.38. Το feature gate reference όμως εξακολουθεί να δείχνει κάθε εμπλεκόμενο gate ως Beta. Άρα: το GA έχει στόχο το v1.38, δεν έχει βγει ακόμα.
Ακούγεται σαν να έχεις χρόνο. Δεν έχεις, και να γιατί:
| Gate ή μεταβλητή | Component | Default | Ενεργό by default από |
|---|---|---|---|
KUBECTL_REMOTE_COMMAND_WEBSOCKETS | kubectl | on | v1.30 |
KUBECTL_PORT_FORWARD_WEBSOCKETS | kubectl | on | v1.31 |
TranslateStreamCloseWebsocketRequests | kube-apiserver | true (Beta από το 1.30) | v1.30 |
PortForwardWebsockets | kube-apiserver | true (Beta από το 1.31) | v1.31 |
AuthorizePodWebsocketUpgradeCreatePermission | kube-apiserver | true (Beta από το 1.35) | v1.35 |
ExtendWebSocketsToKubelet | kube-apiserver, kubelet | true (Beta από το 1.36) | v1.36 |
Γιατί έχει σημασία: Με client και API server v1.31 ή νεότερα, το
kubectlδοκιμάζει ήδη πρώτα WebSockets και για τις τέσσερις εντολές (το v1.30 καλύπτει exec, attach και cp, ενώ το port-forward γύρισε και στις δύο πλευρές στο v1.31). Όπου ένα hop απορρίπτει το upgrade, τοkubectlκάνει αθόρυβα fallback σε SPDY και η εντολή συνεχίζει να δουλεύει, οπότε ένα χαλασμένο hop σήμερα σου είναι αόρατο. Αυτό που σου παίρνει το GA δεν είναι το παλιό default. Είναι το fallback και η έξοδος κινδύνου γύρω του. Σύμφωνα με το KEP, στο GA τοkubectl«no longer consults» τις δύο μεταβλητές περιβάλλοντος, τυπώνει deprecation warning αν κάποια είναι ορισμένη, και δοκιμάζει πάντα πρώτα WebSockets. Τα τέσσερα gates του API server «promoted to GA and locked to ON (LockToDefault: true)».
Ο πραγματικός κίνδυνος λοιπόν δεν είναι ότι το transport θα αλλάξει τη μέρα του upgrade. Είναι ότι το transport έχει ήδη αλλάξει, ότι ένα αθόρυβο SPDY fallback μπαλώνει εδώ και καιρό ένα χαλασμένο hop, και ότι το GA σου αφαιρεί τη δυνατότητα να επιβάλεις το παλιό μονοπάτι όσο το φτιάχνεις. Το αθόρυβο fallback είναι ο λόγος να κάνεις το audit σήμερα και όχι μετά το 1.38: σημαίνει ότι το hop που θα σου χαλάσει το upgrade βρίσκεται ήδη στη διαδρομή σου, αποτυγχάνει ήδη, και τίποτα στα dashboards σου δεν σου το λέει.
Γιατί νοιάζεται ένα ενδιάμεσο hop#
Τα δύο handshakes δείχνουν διαφορετικά πάνω στο καλώδιο. Αυτό που ακολουθεί είναι πραγματικό output από kubectl --v=8, όπως το κατέγραψε ένας χρήστης στο kubernetes/kubernetes#126134, όχι ανακατασκευή. Πρώτα SPDY, από client v1.29:
round_trippers.go:463] POST https://.../api/v1/namespaces/default/pods/alpine-.../exec?command=sh&container=alpine&stdin=true&stdout=true&tty=true
round_trippers.go:469] Request Headers:
round_trippers.go:473] User-Agent: kubectl/v1.29.2 (darwin/arm64) kubernetes/4b8e819
round_trippers.go:473] X-Stream-Protocol-Version: v5.channel.k8s.io
round_trippers.go:473] X-Stream-Protocol-Version: v4.channel.k8s.io
round_trippers.go:473] X-Stream-Protocol-Version: v3.channel.k8s.io
round_trippers.go:473] X-Stream-Protocol-Version: v2.channel.k8s.io
round_trippers.go:473] X-Stream-Protocol-Version: channel.k8s.io
round_trippers.go:574] Response Status: 101 Switching Protocols in 403 milliseconds
round_trippers.go:577] Response Headers:
round_trippers.go:580] Upgrade: SPDY/3.1
round_trippers.go:580] X-Stream-Protocol-Version: v4.channel.k8s.io
Και τώρα η ίδια εντολή με client v1.30, σε WebSockets, μέσα από τον ίδιο HTTPS proxy:
round_trippers.go:463] GET https://.../api/v1/namespaces/default/pods/alpine-.../exec?command=sh&container=alpine&stdin=true&stdout=true&tty=true
round_trippers.go:469] Request Headers:
round_trippers.go:473] Sec-Websocket-Protocol: v5.channel.k8s.io
round_trippers.go:473] User-Agent: kubectl/v1.30.2 (darwin/arm64) kubernetes/3968350
round_trippers.go:574] Response Status: in 0 milliseconds
round_trippers.go:577] Response Headers:
error: proxy: unknown scheme: https
Άλλαξαν τρία πράγματα, και το καθένα είναι ένα σημείο όπου ένα ενδιάμεσο hop μπορεί να πει όχι:
- Το HTTP method. Το
POSTέγινεGET. Το feature gate reference το λέει ξεκάθαρα: «while SPDY requests utilize HTTP POST (naturally aligning with thecreateRBAC permission), the WebSocket protocol requires an HTTP GET request for the handshake.». - Το header για το negotiation του subprotocol. Το
X-Stream-Protocol-Version(εφεύρεση του Kubernetes) έγινεSec-Websocket-Protocol(standard του RFC 6455). - Το upgrade token. Το
Upgrade: SPDY/3.1έγινε το standard WebSocket upgrade.
Το σημείο 1 είναι αυτό που ξεφεύγει από τον κόσμο. Κάθε policy που βασίζεται στο HTTP verb, ένα proxy ACL, ένας κανόνας στο WAF, ένα egress allow-list που επιτρέπει POST /exec αλλά όχι GET /exec, πλέον ταιριάζει με λάθος πράγμα.
Η μία ερώτηση που ορίζει όλο το audit: ποιος βλέπει plaintext HTTP;#
Πριν αρχίσεις τα grep στα configs των proxies, στένεψε το πεδίο. Το kubectl μιλάει TLS με τον API server. Ένα hop που δεν τερματίζει αυτό το TLS session δεν μπορεί να δει το header Upgrade, δεν μπορεί να εξετάσει το method και δεν μπορεί να απορρίψει το handshake. Απλώς μεταφέρει αδιαφανή bytes.
Το κριτήριο θέλει ακρίβεια, γιατί είναι «κάνει parse το HTTP» και όχι «τερματίζει το TLS». Ένα L4 hop που τερματίζει το TLS και μετά προωθεί τα αποκρυπτογραφημένα bytes χωρίς ποτέ να κάνει parse κάποιο request, με πιο προφανές παράδειγμα έναν TLS listener σε AWS NLB, εξακολουθεί να μην μπορεί να αγγίξει το header Upgrade. Πάει στον κουβά με τα αδιαφανή: μπορεί να σε πετάξει έξω με timeout, δεν μπορεί να σε απορρίψει.
flowchart TB
subgraph Client["Client side"]
K[kubectl]
end
subgraph Path["Hops you may not control"]
P["Corporate proxy<br/>CONNECT tunnel: L4, opaque<br/>TLS-intercepting: L7, sees the upgrade"]
M["Mesh sidecar / ztunnel<br/>L4 for client-originated TLS"]
L["Cloud LB<br/>NLB/L4: opaque, even with TLS termination<br/>ALB/L7: parses HTTP"]
I["Reverse proxy or ingress<br/>L7 only if it terminates TLS and parses HTTP"]
end
subgraph Cluster["Kubernetes"]
A[kube-apiserver]
N[kubelet]
C[container runtime]
end
K --> P --> M --> L --> I --> A
A --> N --> C
Το διάγραμμα δείχνει το υπερσύνολο, όχι μια συγκεκριμένη διαδρομή. Η δική σου έχει κάποιο υποσύνολο αυτών των hops, με κάποια σειρά: ένα kubectl που τρέχει μέσα σε pod του mesh συναντάει πρώτα το sidecar και δεν έχει καθόλου εταιρικό proxy, ενώ ένα laptop πάνω στο VPN μπορεί να έχει τον proxy και τίποτα άλλο.
Έτσι καταλήγεις σε δύο κουβάδες, που αποτυγχάνουν με εντελώς διαφορετικό τρόπο:
| Τύπος hop | Μπορεί να σπάσει το handshake; | Τι μπορεί παρ’ όλα αυτά να σπάσει |
|---|---|---|
| L4: TCP passthrough, CONNECT tunnel ή TLS termination χωρίς HTTP parsing | Όχι. Δεν κάνει ποτέ parse το HTTP request. | Idle timeouts στο TCP flow, και bugs του proxy στην πλευρά του client (δες παρακάτω) |
| L7: βλέπει plaintext HTTP | Ναι. Πρέπει να αφήνει να περάσουν τα Upgrade και Connection, να επιτρέπει GET στο subresource και να μην κάνει buffer το stream. | Idle timeouts, request buffering, HTTP/2 hops που πετάνε το upgrade |
Φτιάξε πρώτα αυτή την απογραφή. Στα περισσότερα clusters, τα hops που κάνουν parse HTTP στη διαδρομή του kubectl είναι μηδέν ή ένα, και το audit καταλήγει σε ένα μόνο config file.
Audit βήμα 1: ποιο transport κάνει πράγματι negotiate ο client σου;#
Σύμφωνα με το kubectl quick reference, το --v=7 σου δίνει «Display HTTP request headers» και το --v=8 σου δίνει «Display HTTP request contents». Το επίπεδο 7 αρκεί:
# Watch the upgrade on the /exec subresource. Non-interactive, so it exits on its own.
kubectl exec --v=7 deploy/my-app -- true 2>&1 | grep -E "exec\?|Switching Protocols|Sec-Websocket|X-Stream|Upgrade:"
Διάβασε τη γραμμή του request για το subresource /exec:
GET .../exec?...μεSec-Websocket-Protocol: v5.channel.k8s.ioσημαίνει WebSocketsPOST .../exec?...με headersX-Stream-Protocol-Version:σημαίνει SPDY
Το ίδιο ισχύει και για το port-forward, όπου ψάχνεις v2.portforward.k8s.io αντί για v1.portforward.k8s.io:
kubectl port-forward --v=7 deploy/my-app 8080:8080 2>&1 | grep -E "portforward\?|Switching Protocols|Sec-Websocket|X-Stream"
Το troubleshooting section του ίδιου του KEP-4006 χρησιμοποιεί το ίδιο κόλπο για το latency, σημειώνοντας ότι το kubectl exec -v=7 βγάζει γραμμές όπως Response Status: 101 Switching Protocols in 20 milliseconds. Αυτό το νούμερο είναι το handshake budget σου ανά hop, και αξίζει να το καταγράψεις πριν αλλάξεις οτιδήποτε.
Tip: Τρέξ’ το από κάθε σημείο απ’ όπου γίνεται exec σε pods, όχι μόνο από το laptop σου. CI runners, bastion hosts, το pod ενός operator, ο on-call engineer πάνω στο VPN. Ακολουθούν διαφορετικές διαδρομές και δεν θα συμφωνήσουν όλοι μεταξύ τους.
Η εικόνα σε όλο το fleet#
Το KEP-4006 καταχωρεί τέσσερα metrics που μπορείς να κάνεις scrape, αντί να ζητάς από κάθε engineer να τρέξει --v=7. Πριν βγάλεις συμπεράσματα από αυτά, έλεγξε ποια από τα τέσσερα υπάρχουν πράγματι στην έκδοσή σου:
| Metric | Component | Διαθέσιμο σε | Labels | Τι σου λέει |
|---|---|---|---|---|
apiserver_stream_translator_requests_total | kube-apiserver | Υπάρχει πριν το v1.36, η έκδοση εισαγωγής δεν τεκμηριώνεται από πηγή | code (το 101 είναι το επιτυχημένο upgrade) | τα WebSocket streams για exec και attach που ο API server μεταφράζει σε SPDY |
apiserver_stream_tunnel_requests_total | kube-apiserver | Υπάρχει πριν το v1.36, η έκδοση εισαγωγής δεν τεκμηριώνεται από πηγή | code (το 101 είναι το επιτυχημένο upgrade) | τα WebSocket streams για port-forward που ο API server κάνει tunnel |
apiserver_websocket_streaming_requests_total | kube-apiserver | μόνο v1.36 και νεότερα | subresource (exec, attach, portforward), proxy_type (proxied_to_kubelet, translated_at_apiserver) | τα WebSocket streaming requests που φτάνουν στον API server, χωρισμένα με βάση το πώς εξυπηρετήθηκαν |
kubelet_websocket_streaming_requests_total | kubelet | μόνο v1.36 και νεότερα | subresource | τα end-to-end WebSocket streams που φτάνουν απευθείας στο kubelet |
Κάτω από το v1.36 τα δύο πρώτα είναι το μοναδικό σου σήμα, και το KEP είναι σαφές για το πώς διαβάζονται: μη μηδενικό count με status code 101 σημαίνει ότι οι clients κάνουν streaming πάνω από WebSockets. Από το v1.36 ο API server μπορεί να παραδώσει ένα WebSocket stream κατευθείαν στο kubelet, χωρίς μετάφραση και χωρίς tunnel, και το help text του KEP λέει ότι αυτοί οι δύο counters μετράνε μόνο τα requests που χειρίστηκαν οι translator και tunnel proxies. Σε v1.36 ή νεότερο λοιπόν, πάρε ως βασικό σήμα το apiserver_websocket_streaming_requests_total αθροισμένο και στις δύο τιμές του proxy_type, και περίμενε τους δύο counters stream_* να πέφτουν όσο τα kubelets σου φτάνουν στο v1.36.
# Successful WebSocket upgrades, exec and attach.
sum(rate(apiserver_stream_translator_requests_total{code="101"}[5m]))
# Successful WebSocket upgrades, port-forward.
sum(rate(apiserver_stream_tunnel_requests_total{code="101"}[5m]))
# v1.36 and newer: WebSocket streams by how they were served.
sum(rate(apiserver_websocket_streaming_requests_total[5m])) by (proxy_type)
Σε API server παλαιότερο από v1.36, αν και τα δύο μένουν στο μηδέν ενώ οι engineers κάνουν exec όλη μέρα, οι clients σου είναι όντως ακόμα σε SPDY, και το upgrade στο GA θα είναι απότομο σκαλοπάτι και όχι no-op. Σε v1.36 ή νεότερο, το μηδέν σε αυτά τα δύο δεν αποδεικνύει τίποτα από μόνο του: δες το apiserver_websocket_streaming_requests_total πριν καταλήξεις σε αυτό το συμπέρασμα.
Σημείωση: Μην κάνεις αυτόν τον έλεγχο πάνω στο
apiserver_websocket_streaming_requests_totalαν ο API server σου δεν είναι v1.36 ή νεότερος. Οι δύο counters*_websocket_streaming_*βγήκαν στο v1.36, άρα σε οτιδήποτε παλαιότερο δεν υπάρχουν, και ένας counter που λείπει μοιάζει ακριβώς με έναν counter καρφωμένο στο μηδέν. Δεν υπάρχει πιο γρήγορος τρόπος να πείσεις τον εαυτό σου ότι δεν χρειάζεσαι ένα audit που χρειάζεσαι.
Audit βήμα 2: A/B και τα δύο transports στο ίδιο cluster#
Αυτό είναι το test που αποδεικνύει στην πράξη ότι ένα hop είναι ασφαλές, και υπάρχει μόνο όσο τα gates είναι ακόμα Beta. Τρέξ’ το τώρα.
Οι διακόπτες στην πλευρά του client είναι μεταβλητές περιβάλλοντος, όχι flags:
# Force the legacy SPDY path for exec, attach and cp.
KUBECTL_REMOTE_COMMAND_WEBSOCKETS=false kubectl exec --v=7 -it deploy/my-app -- sh
# Force the legacy SPDY path for port-forward.
KUBECTL_PORT_FORWARD_WEBSOCKETS=false kubectl port-forward --v=7 deploy/my-app 8080:8080
# Default behaviour on any client v1.30+ (exec) or v1.31+ (port-forward): WebSockets first.
kubectl exec --v=7 -it deploy/my-app -- sh
Ερμήνευσε το αποτέλεσμα με ειλικρίνεια:
| Με SPDY | Με WebSockets | Ετυμηγορία |
|---|---|---|
| δουλεύει | δουλεύει | Η διαδρομή είναι καθαρή. Πήγαινε παρακάτω. |
| δουλεύει | αποτυγχάνει | Βρήκες το χαλασμένο hop. Φτιάξ’ το πριν το GA σου πάρει την επιλογή του SPDY. |
| αποτυγχάνει | δουλεύει | Κάποιο ενδιάμεσο hop απορρίπτει ήδη το SPDY. Η μετάβαση σε βολεύει. |
| αποτυγχάνει | αποτυγχάνει | Το hop δεν υποστηρίζει κανένα από τα δύο. Σύμφωνα με το KEP, σε αυτή την περίπτωση «it will not be possible to run the kubectl streaming commands», και το KEP δεν αναφέρει κανένα mitigation. |
Αν χρειαστεί να γυρίσεις ολόκληρο το cluster πίσω σε SPDY όσο φτιάχνεις ένα hop, τα gates στην πλευρά του server το κάνουν για όλους τους clients μαζί:
# On kube-apiserver, while these gates are still Beta and unlocked.
--feature-gates=TranslateStreamCloseWebsocketRequests=false,PortForwardWebsockets=false
Με αυτά κλειστά, ο API server απορρίπτει το WebSocket upgrade, και τότε ο FallbackExecutor του kubectl ξαναδοκιμάζει πάνω από SPDY. Το KEP είναι σαφές ότι αυτό είναι προσωρινό: στο GA τα gates κλειδώνουν στο on, και αφαιρούνται από τη λίστα των gates «no earlier than v1.41».
Σημείωση: Αντιμετώπισε τις μεταβλητές περιβάλλοντος και τα server gates ως μοχλό για incidents με ημερομηνία λήξης, όχι ως επιλογή configuration. Οτιδήποτε στο automation σου ορίζει
KUBECTL_REMOTE_COMMAND_WEBSOCKETS=falseήKUBECTL_PORT_FORWARD_WEBSOCKETS=falseείναι ένα deprecation warning που μετράει αντίστροφα. Κάνε grep τώρα στα Dockerfiles, στα CI images και στα shell profiles σου και για τα δύο ονόματα.
Audit βήμα 3: η αλλαγή στο RBAC για την οποία δεν σε προειδοποίησε κανείς#
Το GET στη θέση του POST άνοιξε ένα πραγματικό κενό στο authorization, και το AuthorizePodWebsocketUpgradeCreatePermission (Beta, default true, από το v1.35) το κλείνει. Από το feature gate reference:
οι clients πρέπει να είναι εξουσιοδοτημένοι να κάνουν
createστα subresources του Pod, ακόμα κι όταν προκαλούν τη δημιουργία τους μέσω WebSocket. Το request για το connection upgrade γίνεται για καθένα από τα παρακάτω subresources:pods/exec,pods/attachκαιpods/portforward. […] εφαρμόζεται πλέον ένας συνθετικός έλεγχος RBAC, ώστε τα WebSocket upgrades να επιβάλλουν αυστηρά το permissioncreate, όπως ακριβώς το υπάρχον security model του SPDY.
Το ίδιο κείμενο ορίζει και το blast radius: «You may want to disable this feature gate if you have existing clients or custom tooling that rely on the previous behavior, specifically, if they connect via WebSockets but do not currently hold the create RBAC permission.».
Κάνε λοιπόν audit στα Roles σου. Οτιδήποτε έχει get αλλά όχι create στα subresources του exec δούλευε πάνω από WebSockets σε cluster πριν το 1.35 και τώρα σταματάει να δουλεύει:
# Find roles that grant the exec/attach/portforward subresources.
kubectl get clusterroles,roles -A -o json \
| jq -r '.items[] | select(any(.rules[]?.resources[]?; test("pods/(exec|attach|portforward)"))) | "\(.kind)/\(.metadata.namespace // "-")/\(.metadata.name)"'
Για κάθε αποτέλεσμα, επιβεβαίωσε ότι η λίστα verbs περιέχει create και όχι μόνο get. Οι συνήθεις ύποπτοι είναι χειροποίητα Roles για debugging tools, web terminals και CI service accounts.
# Correct. create is what the WebSocket upgrade is authorized against.
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: debug-exec
namespace: payments
rules:
- apiGroups: [""]
resources: ["pods"]
verbs: ["get", "list"]
- apiGroups: [""]
resources: ["pods/exec", "pods/portforward"]
verbs: ["create", "get"]
Audit βήμα 4: σημειώσεις ανά hop#
nginx (ingress controller ή σκέτος reverse proxy)#
Σημείωση: Το ingress-nginx αποσύρθηκε τον Μάρτιο του 2026 και το repo είναι archived: ούτε releases, ούτε bugfixes, ούτε security patches. Οι συμβουλές για τα timeouts παρακάτω ισχύουν ακόμα για τα clusters που το τρέχουν, αλλά δες τες ως αφορμή να σχεδιάσεις τη μετάβαση σε κάποια υλοποίηση του Gateway API, όχι ως μακροπρόθεσμη λύση.
Το ίδιο το handshake είναι το εύκολο κομμάτι. Τα ingress-nginx docs το λένε ωμά:
Η υποστήριξη για websockets παρέχεται από το NGINX out of the box. Δεν χρειάζεται κανένα ειδικό configuration.
Το πρόβλημα είναι τα timeouts. Η ίδια σελίδα συνεχίζει: «The only requirement to avoid the close of connections is the increase of the values of proxy-read-timeout and proxy-send-timeout. The default value of these settings is 60 seconds. A more adequate value to support websockets is a value higher than one hour (3600).»
Εξήντα δευτερόλεπτα είναι ακριβώς όσο χρειάζεται ένας engineer για να ανοίξει ένα shell, να διαβάσει ένα log, να σκεφτεί, και να φάει disconnect στη μέση της πρότασης.
# Per-Ingress override. Values are unitless seconds.
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: cluster-api
annotations:
nginx.ingress.kubernetes.io/proxy-read-timeout: "3600"
nginx.ingress.kubernetes.io/proxy-send-timeout: "3600"
spec:
ingressClassName: nginx
# ...
Αποτέλεσμα: τα μακρόβια exec και port-forward sessions αντέχουν μία ώρα αδράνειας αντί για ένα λεπτό.
Μία ακόμα γραμμή από εκείνη τη σελίδα μετράει, αν ο controller κάθεται μπροστά από οτιδήποτε ευαίσθητο στο TLS: «If the Ingress-Nginx Controller is exposed with a service type=LoadBalancer make sure the protocol between the loadbalancer and NGINX is TCP.»
Envoy, άρα και Istio#
Πρώτα τα καλά νέα. Σύμφωνα με το documentation του Envoy για τα upgrades, η υποστήριξη upgrade στον HTTP connection manager είναι ενεργή by default (ο πίνακάς του δείχνει T (Default) τόσο για το HCM όσο και για τις per-route ρυθμίσεις), και «Upgrades pass both the HTTP headers and the upgrade payload through an HTTP filter chain». Ένα Envoy hop με τα defaults δεν χρειάζεται να του πει κανείς τίποτα για WebSockets.
Οι παγίδες βρίσκονται στα μη default configurations:
| Συμπεριφορά του Envoy | Γιατί δαγκώνει ένα streaming session |
|---|---|
| Buffer filter στο default chain | Τα docs του Envoy: «Buffering is generally not compatible with upgrades», και προτείνουν να εξαιρεθεί μέσω upgrade filters. Ένα stream που γίνεται buffer είναι ένα kubectl exec που κρέμεται. |
| WebSockets πάνω από HTTP/2 ή HTTP/3 hop | «off by default». Χρειάζεται Extended CONNECT (RFC 8441) μέσω allow_connect στο Envoy του δεύτερου layer, ή allow_extended_connect για HTTP/3. |
| Η αυστηρότητα του HTTP/2 upgrade path | «The HTTP/2+ upgrade path has very strict HTTP/1.1 compliance, so will not proxy WebSocket upgrade requests or responses with bodies.» |
| Απενεργοποίηση του upgrade ανά route | Οποιοδήποτε route έχει ρητά απενεργοποιημένα τα upgrades υπερισχύει του HCM default. |
Το Envoy σου δίνει και τους counters για να το αποδείξεις: downstream_cx_upgrades_total και downstream_cx_upgrades_active. Παρακολούθησέ τους πάνω στο hop όσο τρέχεις το A/B από το βήμα 2. Αν το downstream_cx_upgrades_total δεν κουνιέται, το upgrade δεν έφτασε ποτέ στο filter chain.
Ειδικά για το Istio:
- Idle timeout. Το
ConnectionPoolSettings.HTTPSettings.idleTimeoutσε ένα DestinationRule είναι «the idle timeout for upstream connection pool connections. The idle timeout is defined as the period in which there are no active requests. If not set, the default is 1 hour.» Με μία ώρα επιβιώνεις, αλλά δεν είναι άπειρη, και ένα interactive shell είναι idle εξ ορισμού. - Sidecar mode. Το sidecar είναι Envoy, άρα ισχύουν τα defaults από πάνω. Το κρίσιμο: το
kubectlξεκινάει το δικό του TLS προς τον API server, οπότε ένα sidecar σε αυτή τη διαδρομή συνήθως βλέπει ένα αδιαφανές TCP stream, όχι ένα HTTP upgrade. Αυτό που σε φτάνει είναι τα timeouts, όχι το handshake. - Ambient mode. Σύμφωνα με τα docs του Istio για τα data plane modes, το ambient δρομολογεί όλο το traffic μέσα από «a Layer 4-only node proxy» (το ztunnel), και οι εφαρμογές «opt in to routing through an Envoy proxy to get Layer 7 features» (ένα waypoint). Ένα ztunnel hop που είναι μόνο L4 δεν μπορεί να σπάσει ένα WebSocket upgrade. Ένα waypoint είναι πλήρες Envoy και ανήκει στον κουβά με όσα κάνουν parse HTTP. Τον διαχωρισμό ztunnel και waypoint τον έχω αναλύσει περισσότερο στο post μου για το Istio Ambient Mesh.
Cloud load balancers#
Αν το endpoint του API server σου κάθεται πίσω από έναν cloud load balancer, το idle timeout είναι το σημείο όπου θα το νιώσεις, και η επιλογή ανάμεσα σε L4 και L7 κρίνει αν το handshake είναι καν ορατό.
| AWS load balancer | Layer | Idle timeout | Ρυθμίζεται; |
|---|---|---|---|
| Application Load Balancer | L7, τερματίζει το TLS | idle_timeout.timeout_seconds, default 60 seconds | Ναι |
| Network Load Balancer, TCP listener | L4, αδιαφανές | default 350 seconds για TCP flows | Ναι, από 60 έως 6000 seconds |
| Network Load Balancer, TLS listener | L4 με TLS termination | 350 seconds, σταθερό | Όχι. Σύμφωνα με τα AWS docs «can’t be modified» |
Δύο λεπτομέρειες από το documentation του AWS NLB που αξίζει να χωνέψεις. Πρώτον, όταν χτυπήσει το timeout δεν παίρνεις καθαρό κλείσιμο: «If a client or target sends data after the idle timeout period elapses, the client receives a TCP RST packet to indicate that the connection is no longer valid.» Ένα TCP RST στη μέση ενός exec session μοιάζει με χαλασμένο cluster, όχι με timeout ενός proxy. Δεύτερον, σε TLS listener ο load balancer παράγει keepalives κάθε 20 δευτερόλεπτα από τη στιγμή που θα δει ένα, αλλά «keepalive packets sent to maintain TLS connections can’t contain data or payload».
Αν στη διαδρομή υπάρχει ALB, αυτό είναι που θέλει εξονυχιστικό έλεγχο: τερματίζει το TLS, κάνει parse το HTTP, και το default idle timeout του είναι 60 δευτερόλεπτα.
Ο πίνακας καλύπτει μόνο το AWS. Κάθε managed load balancer έχει μια αντίστοιχη ρύθμιση με άλλο όνομα. Αν λοιπόν είσαι σε Google Cloud, Azure ή οπουδήποτε αλλού, ψάξε το default idle timeout του δικού σου provider και το αν το προϊόν κάνει parse HTTP, και βάλ’ το σε έναν από τους δύο κουβάδες παραπάνω πριν αρχίσεις να αλλάζεις configs.
HTTP proxies: proxy-url, HTTPS_PROXY, NO_PROXY#
Αυτό το hop έχει μια συγκεκριμένη παγίδα που εξαρτάται από την έκδοση, και είναι ο λόγος που υπάρχει το --v=8 output παραπάνω.
Το kubeconfig reference τεκμηριώνει τη συμπεριφορά του client: το proxy-url «is the URL to the proxy to be used for all requests made by this client. URLs with ‘http’, ‘https’, and ‘socks5’ schemes are supported. If this configuration is not provided or the empty string, the client attempts to construct a proxy configuration from http_proxy and https_proxy environment variables.»
Από εκεί βγαίνουν δύο ευρήματα:
1. Τα proxy URLs με https:// ήταν χαλασμένα για το exec. Το kubernetes/kubernetes#126134 έχει τίτλο «Kubectl Versions >= 1.30 Don’t Allow Exec When HTTPS Scheme in proxy-url». Το kubectl get δούλευε, το kubectl exec επέστρεφε error: proxy: unknown scheme: https. Το πρώτο fix, το PR #126231 («Falls back to SPDY for gorilla/websocket https proxy error», merged στο milestone του v1.31), δεν έκανε τα WebSockets να δουλεύουν μέσα από HTTPS proxy: έκανε το kubectl να πέφτει σε SPDY. Η πραγματική υποστήριξη ήρθε αργότερα. Το GA section του KEP-4006 αναφέρει ότι «WebSocket support for HTTPS proxies shipped in v1.33».
Γιατί έχει σημασία: Αν το kubeconfig σου χρησιμοποιεί proxy URL με
https://και οι clients σου είναι ανάμεσα σε v1.31 και v1.32, το exec δουλεύει σήμερα χάρη στο SPDY fallback. Το GA καταργεί την έξοδο κινδύνου των μεταβλητών περιβάλλοντος και κλειδώνει τα server gates στο on. Πήγαινε αυτούς τους clients σε v1.33 ή νεότερο πριν κάνεις οτιδήποτε άλλο από αυτό το audit.
2. Όσοι χρησιμοποιούν SOCKS5 ξεκινάνε από χειρότερη θέση. Το ίδιο kubeconfig reference σημειώνει: «socks5 proxying does not currently support spdy streaming endpoints (exec, attach, port forward).» Αν περνάς από SOCKS5 proxy, το exec δεν δούλεψε ποτέ πάνω από SPDY. Το WebSockets είναι ο δρόμος που θα μπορούσε να σε ξεμπλοκάρει, αλλά τα docs αποκλείουν μόνο το SPDY, δεν υπόσχονται ότι το νέο transport δουλεύει: απόδειξέ το με το A/B του βήματος 2 πριν στηρίξεις σχέδια πάνω του.
Τέλος, η βαρετή αλλά αποτελεσματική επιλογή: βγάλε τον proxy από τη διαδρομή. Αν το endpoint του API server είναι προσβάσιμο απευθείας, πρόσθεσέ το στο NO_PROXY και επιβεβαίωσε ότι το A/B test του βήματος 2 περνάει και στα δύο transports. Έτσι ένα hop που δεν ελέγχεις γίνεται κανένα hop.
Πώς μοιάζει στην πράξη ένα upgrade που απέτυχε#
Αυτό είναι το κομμάτι που σου τρώει τον περισσότερο χρόνο πάνω στο call, οπότε διάβασε προσεκτικά τι γράφει το KEP-4006 για αυτό το failure mode:
Failure Mode: Proxy ή Gateway που δεν υποστηρίζει SPDY ή WebSockets. […] Εντοπισμός: Η streaming εντολή του
kubectlθα επιστρέψει connection error. Το error που θα πάρει ο client θα προέρχεται από τον proxy ή το gateway, όχι από τον API Server (αφού η επικοινωνία δεν φτάνει ποτέ στον API Server). Mitigations: Κανένα.
Τρεις πρακτικές συνέπειες:
- Το κείμενο του error ανήκει στον proxy, όχι στο Kubernetes. Θα ψάχνεις το error string ενός αγνώστου. Το
error: proxy: unknown scheme: httpsδεν είναι μήνυμα του Kubernetes. - Τα logs του API server είναι άδεια. Το request δεν έφτασε ποτέ. Μην κάψεις μισή ώρα προσπαθώντας να συσχετίσεις audit logs του API server. Πήγαινε στα access logs του proxy, του ingress ή του sidecar και ψάξε το request προς το subresource
/execή/portforward, και το αν το response ήταν101ή κάτι άλλο. - «Mitigations: None.» Για ένα hop που δεν υποστηρίζει κανένα από τα δύο transports δεν υπάρχει flag να γυρίσεις. Γι’ αυτό ακριβώς το audit γίνεται πριν το upgrade.
Αν αντιστοιχίσεις τα συμπτώματα με τα hops, βγαίνει η παρακάτω εικόνα:
| Σύμπτωμα | Πιθανό hop | Τι να ελέγξεις |
|---|---|---|
Το kubectl get δουλεύει, το kubectl exec αποτυγχάνει ακαριαία | L7 hop που απορρίπτει το upgrade, ή το bug με το https:// proxy-url | kubectl exec --v=7: είδες ποτέ 101 Switching Protocols; |
| Το exec συνδέεται και μετά πεθαίνει ύστερα από περίπου 60 δευτερόλεπτα χωρίς πληκτρολόγηση | το proxy-read-timeout του nginx, ή το idle_timeout.timeout_seconds ενός ALB | Και τα δύο έχουν default 60 |
| Το exec πεθαίνει μετά από περίπου 350 δευτερόλεπτα | Το idle timeout του AWS NLB | 350 δευτερόλεπτα, σταθερό στους TLS listeners |
| Το port-forward δουλεύει, το exec όχι (ή το ανάποδο) | Ένα gate ή env var ορισμένο μόνο για το ένα subprotocol | Τα δύο έχουν ξεχωριστά gates: TranslateStreamCloseWebsocketRequests και PortForwardWebsockets |
| Το exec αποτυγχάνει μόνο για ένα service account ή ένα tool | AuthorizePodWebsocketUpgradeCreatePermission | Δίνει το Role create στο pods/exec, και όχι μόνο get; |
| Το exec κρεμάει χωρίς output και χωρίς error | Buffering σε L7 hop | Buffer filter του Envoy στο default chain, ή proxy buffering του nginx |
| Το exec αποτυγχάνει μόνο για pods σε συγκεκριμένα nodes | ExtendWebSocketsToKubelet μαζί με network policy | KEP: ένα policy που «blocks the WebSocket protocol’s port and upgrade headers» ανάμεσα σε API server και kubelet |
Τι κάνεις τώρα#
Με τη σειρά. Τίποτα από αυτά δεν θέλει maintenance window.
- Κάνε απογραφή των hops ανάμεσα σε κάθε σημείο απ’ όπου ξεκινάει
kubectl(laptops, CI, bastions, operator pods) και τον API server. Σημείωσε ποια βλέπουν plaintext HTTP. Μόνο αυτά μπορούν να σπάσουν το handshake, τα υπόλοιπα μπορούν μόνο να σε κόψουν με timeout. - Έλεγξε τις εκδόσεις των clients σου. Οτιδήποτε παλαιότερο από v1.33 πίσω από proxy URL με
https://είναι γνωστή νάρκη. Κάνε pin την έκδοση του client στα CI images, για να μην αλλάξει το transport κάτω από τα πόδια σου στη μέση του audit. - Τρέξε το A/B του βήματος 2 σε κάθε διαδρομή. Τα
KUBECTL_REMOTE_COMMAND_WEBSOCKETS=falseκαιKUBECTL_PORT_FORWARD_WEBSOCKETS=falseείναι το control group σου, και λήγουν στο GA. - Φτιάξε το hop, με αυτή τη σειρά πιθανότητας: πρώτα τα idle timeouts (τα 60 δευτερόλεπτα είναι το default σχεδόν παντού, και το default είναι λάθος για interactive shells), μετά το upgrade passthrough, μετά το buffering, μετά τα HTTP/2 hops χωρίς Extended CONNECT.
- Κάνε audit στο RBAC για
createσταpods/exec,pods/attachκαιpods/portforward. - Κάνε grep στα repos σου για
KUBECTL_REMOTE_COMMAND_WEBSOCKETSκαιKUBECTL_PORT_FORWARD_WEBSOCKETS. Κάθε εμφάνιση είναι δουλειά που χρωστάς στον εαυτό σου πριν κλειδώσουν τα gates. - Και μετά κάνε upgrade, παρακολουθώντας παράλληλα τα
apiserver_stream_translator_requests_total{code="101"}καιapiserver_stream_tunnel_requests_total{code="101"}. Σε v1.36 ή νεότερο, βασικό ζευγάρι γίνονται ταapiserver_websocket_streaming_requests_totalκαιkubelet_websocket_streaming_requests_total: οι δύο countersstream_*μετράνε μόνο το μερίδιο που εξακολουθεί να μεταφράζεται στον API server, και το μερίδιο αυτό μικραίνει όσο αναβαθμίζονται τα kubelets. Σε οτιδήποτε παλαιότερο, αυτά τα δύο δεν υπάρχουν.
Συμπέρασμα#
Ο αριθμός έκδοσης είναι το λιγότερο ενδιαφέρον κομμάτι αυτής της ιστορίας, και είναι αυτό που το πιθανότερο θα έχει μπαγιατέψει μέχρι να το διαβάσεις. Το GA έχει στόχο το v1.38 στα metadata του KEP, ενώ τα feature gates είναι ακόμα Beta στα docs. Αυτά τα δύο θα συγκλίνουν με το δικό τους χρονοδιάγραμμα.
Αυτό που δεν θα αλλάξει είναι η φύση του προβλήματος. Το Kubernetes αντικατέστησε ένα ιδιόρρυθμο, από καιρό deprecated upgrade handshake με ένα standard, και έτσι μετέφερε το failure domain από το «δουλεύει το cluster μου;» στο «χειρίζεται σωστά ένα HTTP upgrade κάθε ενδιάμεσο hop στη διαδρομή;». Η μετάβαση βγαίνει καθαρά κέρδος: το SPDY ήταν ένα πρωτόκολλο που το μιλούσε μόνο το Kubernetes, και η κοινή L7 υποδομή είχε κάθε λόγο να μην το υποστηρίζει, ενώ για το WebSockets δεν έχει κανέναν. Το κέρδος όμως πέφτει πάνω σε υποδομή που μάλλον δεν σου ανήκει, και το αυτόματο SPDY fallback σου έκρυβε τον λογαριασμό.
Κράτα δύο πράγματα. Πρώτον, με client και server v1.31 ή νεότερα δοκιμάζεις πρώτα WebSockets και πέφτεις αθόρυβα σε SPDY όπου ένα hop απορρίπτει το upgrade, άρα αυτό το audit ελέγχει τι συμβαίνει σήμερα, δεν είναι πρόβλεψη. Αν το kubeconfig σου χρησιμοποιεί proxy URL με https://, το κατώτατο όριο είναι το v1.33, γιατί από εκεί και πέρα το WebSockets δουλεύει μέσα από HTTPS proxy αντί να κάνει fallback. Δεύτερον, το πιο πολύτιμο πράγμα σε όλο αυτό το post δεν είναι κάποιο feature gate: είναι να μάθεις ποια hops στη διαδρομή σου βλέπουν plaintext HTTP, και να ανεβάσεις τα idle timeouts πάνω από τα 60 δευτερόλεπτα σε κάθε hop της διαδρομής, αδιαφανές ή όχι. Κάν’ το αυτή την εβδομάδα, και η μέρα του upgrade θα μείνει βαρετή.
Αν σου φάνηκε χρήσιμο, ίσως σε ενδιαφέρουν και τα σχετικά posts μου:
- Απλοποιώντας το Kubernetes Service Mesh - Μια βαθιά ματιά στο Istio Ambient Mesh
- Κατανοώντας το Kubernetes Gateway API
- Ephemeral Containers
