Πριν κάνεις upgrade: έλεγξε τους proxies και τα meshes σου για το WebSockets transport του kubectl
~/posts/auditing-proxies-and-meshes-for-the-kubectl-websockets-transport.el.md23 min · 4875 words

Πριν κάνεις upgrade: έλεγξε τους proxies και τα meshes σου για το WebSockets transport του kubectl

// Το Kubernetes αποσύρει το SPDY streaming transport πίσω από τα kubectl exec, attach, cp και port-forward και περνάει σε WebSockets. Εδώ θα βρεις ένα audit που μπορείς να τρέξεις στα clusters σου αυτή την εβδομάδα, για να εντοπίσεις το hop που θα σπάσει πριν το εντοπίσει το upgrade για λογαριασμό σου.

$ date

Τα χειρότερα 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 και των οποίων το kubectl traffic περνάει από τουλάχιστον ένα 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, cpRemoteCommandv4.channel.k8s.iov5.channel.k8s.io
port-forwardPortForwardv1.portforward.k8s.iov2.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 ή μεταβλητήComponentDefaultΕνεργό by default από
KUBECTL_REMOTE_COMMAND_WEBSOCKETSkubectlonv1.30
KUBECTL_PORT_FORWARD_WEBSOCKETSkubectlonv1.31
TranslateStreamCloseWebsocketRequestskube-apiservertrue (Beta από το 1.30)v1.30
PortForwardWebsocketskube-apiservertrue (Beta από το 1.31)v1.31
AuthorizePodWebsocketUpgradeCreatePermissionkube-apiservertrue (Beta από το 1.35)v1.35
ExtendWebSocketsToKubeletkube-apiserver, kubelettrue (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 μπορεί να πει όχι:

  1. Το HTTP method. Το POST έγινε GET. Το feature gate reference το λέει ξεκάθαρα: «while SPDY requests utilize HTTP POST (naturally aligning with the create RBAC permission), the WebSocket protocol requires an HTTP GET request for the handshake.».
  2. Το header για το negotiation του subprotocol. Το X-Stream-Protocol-Version (εφεύρεση του Kubernetes) έγινε Sec-Websocket-Protocol (standard του RFC 6455).
  3. Το 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 σημαίνει WebSockets
  • POST .../exec?... με headers X-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. Πριν βγάλεις συμπεράσματα από αυτά, έλεγξε ποια από τα τέσσερα υπάρχουν πράγματι στην έκδοσή σου:

MetricComponentΔιαθέσιμο σεLabelsΤι σου λέει
apiserver_stream_translator_requests_totalkube-apiserverΥπάρχει πριν το v1.36, η έκδοση εισαγωγής δεν τεκμηριώνεται από πηγήcode (το 101 είναι το επιτυχημένο upgrade)τα WebSocket streams για exec και attach που ο API server μεταφράζει σε SPDY
apiserver_stream_tunnel_requests_totalkube-apiserverΥπάρχει πριν το v1.36, η έκδοση εισαγωγής δεν τεκμηριώνεται από πηγήcode (το 101 είναι το επιτυχημένο upgrade)τα WebSocket streams για port-forward που ο API server κάνει tunnel
apiserver_websocket_streaming_requests_totalkube-apiserverμόνο v1.36 και νεότεραsubresource (exec, attach, portforward), proxy_type (proxied_to_kubelet, translated_at_apiserver)τα WebSocket streaming requests που φτάνουν στον API server, χωρισμένα με βάση το πώς εξυπηρετήθηκαν
kubelet_websocket_streaming_requests_totalkubeletμόνο 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 να επιβάλλουν αυστηρά το permission create, όπως ακριβώς το υπάρχον 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 balancerLayerIdle timeoutΡυθμίζεται;
Application Load BalancerL7, τερματίζει το TLSidle_timeout.timeout_seconds, default 60 secondsΝαι
Network Load Balancer, TCP listenerL4, αδιαφανέςdefault 350 seconds για TCP flowsΝαι, από 60 έως 6000 seconds
Network Load Balancer, TLS listenerL4 με TLS termination350 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: Κανένα.

Τρεις πρακτικές συνέπειες:

  1. Το κείμενο του error ανήκει στον proxy, όχι στο Kubernetes. Θα ψάχνεις το error string ενός αγνώστου. Το error: proxy: unknown scheme: https δεν είναι μήνυμα του Kubernetes.
  2. Τα logs του API server είναι άδεια. Το request δεν έφτασε ποτέ. Μην κάψεις μισή ώρα προσπαθώντας να συσχετίσεις audit logs του API server. Πήγαινε στα access logs του proxy, του ingress ή του sidecar και ψάξε το request προς το subresource /exec ή /portforward, και το αν το response ήταν 101 ή κάτι άλλο.
  3. «Mitigations: None.» Για ένα hop που δεν υποστηρίζει κανένα από τα δύο transports δεν υπάρχει flag να γυρίσεις. Γι’ αυτό ακριβώς το audit γίνεται πριν το upgrade.

Αν αντιστοιχίσεις τα συμπτώματα με τα hops, βγαίνει η παρακάτω εικόνα:

ΣύμπτωμαΠιθανό hopΤι να ελέγξεις
Το kubectl get δουλεύει, το kubectl exec αποτυγχάνει ακαριαίαL7 hop που απορρίπτει το upgrade, ή το bug με το https:// proxy-urlkubectl exec --v=7: είδες ποτέ 101 Switching Protocols;
Το exec συνδέεται και μετά πεθαίνει ύστερα από περίπου 60 δευτερόλεπτα χωρίς πληκτρολόγησητο proxy-read-timeout του nginx, ή το idle_timeout.timeout_seconds ενός ALBΚαι τα δύο έχουν default 60
Το exec πεθαίνει μετά από περίπου 350 δευτερόλεπταΤο idle timeout του AWS NLB350 δευτερόλεπτα, σταθερό στους TLS listeners
Το port-forward δουλεύει, το exec όχι (ή το ανάποδο)Ένα gate ή env var ορισμένο μόνο για το ένα subprotocolΤα δύο έχουν ξεχωριστά gates: TranslateStreamCloseWebsocketRequests και PortForwardWebsockets
Το exec αποτυγχάνει μόνο για ένα service account ή ένα toolAuthorizePodWebsocketUpgradeCreatePermissionΔίνει το Role create στο pods/exec, και όχι μόνο get;
Το exec κρεμάει χωρίς output και χωρίς errorBuffering σε L7 hopBuffer filter του Envoy στο default chain, ή proxy buffering του nginx
Το exec αποτυγχάνει μόνο για pods σε συγκεκριμένα nodesExtendWebSocketsToKubelet μαζί με network policyKEP: ένα policy που «blocks the WebSocket protocol’s port and upgrade headers» ανάμεσα σε API server και kubelet

Τι κάνεις τώρα#

Με τη σειρά. Τίποτα από αυτά δεν θέλει maintenance window.

  1. Κάνε απογραφή των hops ανάμεσα σε κάθε σημείο απ’ όπου ξεκινάει kubectl (laptops, CI, bastions, operator pods) και τον API server. Σημείωσε ποια βλέπουν plaintext HTTP. Μόνο αυτά μπορούν να σπάσουν το handshake, τα υπόλοιπα μπορούν μόνο να σε κόψουν με timeout.
  2. Έλεγξε τις εκδόσεις των clients σου. Οτιδήποτε παλαιότερο από v1.33 πίσω από proxy URL με https:// είναι γνωστή νάρκη. Κάνε pin την έκδοση του client στα CI images, για να μην αλλάξει το transport κάτω από τα πόδια σου στη μέση του audit.
  3. Τρέξε το A/B του βήματος 2 σε κάθε διαδρομή. Τα KUBECTL_REMOTE_COMMAND_WEBSOCKETS=false και KUBECTL_PORT_FORWARD_WEBSOCKETS=false είναι το control group σου, και λήγουν στο GA.
  4. Φτιάξε το hop, με αυτή τη σειρά πιθανότητας: πρώτα τα idle timeouts (τα 60 δευτερόλεπτα είναι το default σχεδόν παντού, και το default είναι λάθος για interactive shells), μετά το upgrade passthrough, μετά το buffering, μετά τα HTTP/2 hops χωρίς Extended CONNECT.
  5. Κάνε audit στο RBAC για create στα pods/exec, pods/attach και pods/portforward.
  6. Κάνε grep στα repos σου για KUBECTL_REMOTE_COMMAND_WEBSOCKETS και KUBECTL_PORT_FORWARD_WEBSOCKETS. Κάθε εμφάνιση είναι δουλειά που χρωστάς στον εαυτό σου πριν κλειδώσουν τα gates.
  7. Και μετά κάνε 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: οι δύο counters stream_* μετράνε μόνο το μερίδιο που εξακολουθεί να μεταφράζεται στον 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 μου:

Αλυσίδα από hops, από τον kubectl client μέσω ενός ενδιάμεσου proxy μέχρι τον API server και το kubelet, με το WebSockets να κάνει fallback σε SPDY

EOF · 23 min · 4875 words
$ continue exploring
← Karmada v1.19 Multi-Component Scheduling - Ένα training job, τρία clusters, τέσσερις σιωπηλές αποτυχίες // Το Karmada v1.19 μπορεί να βάλει ολόκληρο PyTorchJob, master και workers, σε ένα member cluster. Τέσσερα kind clusters με ψεύτικες GPU δείχνουν τις τέσσερις προϋποθέσεις που χρειάζεται και τι γίνεται όταν λείπει η καθεμία: το job τρέχει τρεις φορές, ο scheduler δεν βρίσκει χώρο με 16 ελεύθερες GPU, και ένα job καταλήγει εκεί όπου οι workers του δεν θα ξεκινήσουν ποτέ. #sre #kubernetes #karmada Immutable Kubernetes Nodes ως OCI Images - Μια ματιά από κοντά στο bootc // Τι αλλάζει στην πράξη στο πώς κάνεις build, rollout και rollback ένα Kubernetes node όταν το λειτουργικό του έρχεται ως OCI image αντί για ένα σύνολο πακέτων, με το bootc και ένα μικρό πρακτικό παράδειγμα που λέγεται Corium. #sre #kubernetes #bootc →
$ grep -r --related
// author
Nick Nikolakakis
Nick Nikolakakis Principal SRE, Platform & AI Engineer // Writing about Kubernetes, SRE practices, cloud-native infrastructure, and AI systems
$ exit█ logout connection closed. cd ~/home ↵
ESC
Type to search...