@@ -424,7 +424,7 @@ func (c *Client) DeployWorkload(ctx context.Context,
424424 //nolint:gosec // G706: statefulset name from Kubernetes API response
425425 slog .Info ("applied statefulset" , "name" , createdStatefulSet .Name )
426426
427- if transportTypeRequiresHeadlessService (transportType ) && options != nil {
427+ if transportTypeRequiresBackendServices (transportType ) && options != nil {
428428 // Create a headless service for DNS discovery
429429 err := c .createHeadlessService (ctx , containerName , namespace , containerLabels , options )
430430 if err != nil {
@@ -898,67 +898,111 @@ func createServicePorts(options *runtime.DeployWorkloadOptions) ([]*corev1apply.
898898 return servicePorts , nil
899899}
900900
901- // createHeadlessService creates a headless Kubernetes service for the StatefulSet
902- func (c * Client ) createHeadlessService (
901+ // serviceConfig holds the configuration for creating a Kubernetes service via applyService.
902+ type serviceConfig struct {
903+ // nameSuffix is appended to "mcp-<containerName>" to form the service name.
904+ // Use "-headless" for the headless service or "" for the MCP service.
905+ nameSuffix string
906+ // headless makes the service a headless service (ClusterIP: None).
907+ headless bool
908+ // sessionAffinity enables ClientIP session affinity with the given timeout.
909+ sessionAffinity bool
910+ // sessionAffinityTimeoutSeconds sets the timeout for ClientIP session affinity.
911+ // Only used when sessionAffinity is true. Kubernetes defaults to 10800s (3h) if unset.
912+ sessionAffinityTimeoutSeconds int32
913+ }
914+
915+ // applyService creates or updates a Kubernetes service using server-side apply.
916+ func (c * Client ) applyService (
903917 ctx context.Context ,
904918 containerName string ,
905919 namespace string ,
906920 labels map [string ]string ,
907921 options * runtime.DeployWorkloadOptions ,
908- ) error {
909- // Create service ports from the container ports
922+ cfg serviceConfig ,
923+ ) ( string , error ) {
910924 servicePorts , err := createServicePorts (options )
911925 if err != nil {
912- return err
926+ return "" , err
913927 }
914928
915- // If no ports were configured, don't create a service
916929 if len (servicePorts ) == 0 {
917- slog .Info ("no ports configured for SSE transport , skipping service creation" )
918- return nil
930+ slog .Debug ("no ports configured, skipping service creation" )
931+ return "" , nil
919932 }
920933
921- // Create service type based on whether we have node ports
934+ svcName := fmt .Sprintf ("mcp-%s%s" , containerName , cfg .nameSuffix )
935+
936+ // Determine service type based on whether any ports have NodePort set.
937+ // Headless services (ClusterIP: None) cannot be NodePort, so skip the
938+ // promotion for those — Kubernetes rejects clusterIP=None + type=NodePort.
922939 serviceType := corev1 .ServiceTypeClusterIP
923- for _ , sp := range servicePorts {
924- if sp .NodePort != nil {
925- serviceType = corev1 .ServiceTypeNodePort
926- break
940+ if ! cfg .headless {
941+ for _ , sp := range servicePorts {
942+ if sp .NodePort != nil {
943+ serviceType = corev1 .ServiceTypeNodePort
944+ break
945+ }
927946 }
928947 }
929948
930- // we want to generate a service name that is unique for the headless service
931- // to avoid conflicts with the proxy service
932- svcName := fmt .Sprintf ("mcp-%s-headless" , containerName )
949+ spec := corev1apply .ServiceSpec ().
950+ WithSelector (map [string ]string {
951+ "app" : containerName ,
952+ }).
953+ WithPorts (servicePorts ... ).
954+ WithType (serviceType )
955+
956+ if cfg .headless {
957+ spec = spec .WithClusterIP ("None" )
958+ }
959+
960+ if cfg .sessionAffinity {
961+ spec = spec .
962+ WithSessionAffinity (corev1 .ServiceAffinityClientIP ).
963+ WithSessionAffinityConfig (corev1apply .SessionAffinityConfig ().
964+ WithClientIP (corev1apply .ClientIPConfig ().
965+ WithTimeoutSeconds (cfg .sessionAffinityTimeoutSeconds )))
966+ }
933967
934- // Create the service apply configuration
935968 serviceApply := corev1apply .Service (svcName , namespace ).
936969 WithLabels (labels ).
937- WithSpec (corev1apply .ServiceSpec ().
938- WithSelector (map [string ]string {
939- "app" : containerName ,
940- }).
941- WithPorts (servicePorts ... ).
942- WithType (serviceType ).
943- WithClusterIP ("None" )) // "None" makes it a headless service
944-
945- // Apply the service using server-side apply
946- fieldManager := serviceFieldManager
970+ WithSpec (spec )
971+
947972 _ , err = c .client .CoreV1 ().Services (namespace ).
948973 Apply (ctx , serviceApply , metav1.ApplyOptions {
949- FieldManager : fieldManager ,
974+ FieldManager : serviceFieldManager ,
950975 Force : true ,
951976 })
952-
953977 if err != nil {
954- return fmt .Errorf ("failed to apply service: %w" , err )
978+ return "" , fmt .Errorf ("failed to apply service %s : %w" , svcName , err )
955979 }
956980
957- slog .Info ("created headless service for HTTP transport" , "name" , containerName )
981+ slog .Debug ("applied service" , "name" , svcName )
982+ return svcName , nil
983+ }
958984
959- return nil
985+ // createHeadlessService creates a headless Kubernetes service for the StatefulSet
986+ func (c * Client ) createHeadlessService (
987+ ctx context.Context ,
988+ containerName string ,
989+ namespace string ,
990+ labels map [string ]string ,
991+ options * runtime.DeployWorkloadOptions ,
992+ ) error {
993+ _ , err := c .applyService (ctx , containerName , namespace , labels , options , serviceConfig {
994+ nameSuffix : "-headless" ,
995+ headless : true ,
996+ })
997+ return err
960998}
961999
1000+ // mcpServiceSessionAffinityTimeout is the timeout in seconds for ClientIP session affinity
1001+ // on the MCP service. This controls how long kube-proxy pins a client IP to the same backend pod.
1002+ // Note: this provides proxy-runner-level stickiness (L4), not per-MCP-session stickiness (L7).
1003+ // True per-session routing would require Mcp-Session-Id-based routing at the proxy layer.
1004+ const mcpServiceSessionAffinityTimeout int32 = 1800
1005+
9621006// createMCPService creates a regular ClusterIP service with SessionAffinity for the MCP server StatefulSet.
9631007// This service provides load balancing with client-IP-based session stickiness, which the proxy-runner
9641008// uses as its target host. The headless service is retained for DNS discovery purposes.
@@ -969,47 +1013,14 @@ func (c *Client) createMCPService(
9691013 labels map [string ]string ,
9701014 options * runtime.DeployWorkloadOptions ,
9711015) error {
972- // Create service ports from the container ports
973- servicePorts , err := createServicePorts (options )
1016+ svcName , err := c .applyService (ctx , containerName , namespace , labels , options , serviceConfig {
1017+ sessionAffinity : true ,
1018+ sessionAffinityTimeoutSeconds : mcpServiceSessionAffinityTimeout ,
1019+ })
9741020 if err != nil {
9751021 return err
9761022 }
977-
978- // If no ports were configured, don't create a service
979- if len (servicePorts ) == 0 {
980- slog .Info ("no ports configured for MCP transport, skipping service creation" )
981- return nil
982- }
983-
984- svcName := fmt .Sprintf ("mcp-%s" , containerName )
985-
986- // Create the service apply configuration with SessionAffinity
987- serviceApply := corev1apply .Service (svcName , namespace ).
988- WithLabels (labels ).
989- WithSpec (corev1apply .ServiceSpec ().
990- WithSelector (map [string ]string {
991- "app" : containerName ,
992- }).
993- WithPorts (servicePorts ... ).
994- WithType (corev1 .ServiceTypeClusterIP ).
995- WithSessionAffinity (corev1 .ServiceAffinityClientIP ))
996-
997- // Apply the service using server-side apply
998- fieldManager := serviceFieldManager
999- _ , err = c .client .CoreV1 ().Services (namespace ).
1000- Apply (ctx , serviceApply , metav1.ApplyOptions {
1001- FieldManager : fieldManager ,
1002- Force : true ,
1003- })
1004-
1005- if err != nil {
1006- return fmt .Errorf ("failed to apply MCP service: %w" , err )
1007- }
1008-
1009- slog .Info ("created MCP service with session affinity" , "name" , svcName )
1010-
1011- // TODO: rename SSEHeadlessServiceName to MCPServiceName
1012- options .SSEHeadlessServiceName = svcName
1023+ options .MCPServiceName = svcName
10131024 return nil
10141025}
10151026
@@ -1030,8 +1041,8 @@ func extractPortMappingsFromPod(pod *corev1.Pod) []runtime.PortMapping {
10301041 return ports
10311042}
10321043
1033- // transportTypeRequiresHeadlessService returns true if the transport type requires a headless service
1034- func transportTypeRequiresHeadlessService (transportType string ) bool {
1044+ // transportTypeRequiresBackendServices returns true if the transport type requires backend services
1045+ func transportTypeRequiresBackendServices (transportType string ) bool {
10351046 return transportType == string (transtypes .TransportTypeSSE ) || transportType == string (transtypes .TransportTypeStreamableHTTP )
10361047}
10371048
0 commit comments