Architecture: Control and Data Planes
Flyte manages application lifecycles through a decoupled architecture that separates user-facing API concerns from the underlying infrastructure management. This split is implemented via two primary services: the Control Plane (AppService) and the Data Plane (InternalAppService).
While both services implement the same AppServiceHandler interface defined in the Flyte IDL, they serve distinct roles in the system.
The Control Plane: AppService
The AppService (located in app/service/app_service.go) acts as the entry point for all application-related requests. It does not interact with Kubernetes directly; instead, it serves as a smart proxy to the data plane.
Proxying and Caching
When you call Get on the AppService, it first checks a local TTL cache. If the app is not in the cache, it forwards the request to the InternalAppService. This reduces cross-plane RPC traffic and improves response times for frequent UI polling.
// app/service/app_service.go
func (s *AppService) Get(
ctx context.Context,
req *connect.Request[flyteapp.GetRequest],
) (*connect.Response[flyteapp.GetResponse], error) {
// ... cache lookup logic ...
if app, hit := s.cache.Get(cacheKey(appID.AppId)); hit {
return connect.NewResponse(&flyteapp.GetResponse{App: app}), nil
}
resp, err := s.internalClient.Get(ctx, req)
// ... error handling ...
// Cache the response if it's not in a transitional state
if ok && appID.AppId != nil && s.cache != nil && !isTransitionalState(resp.Msg.GetApp()) {
s.cache.Add(cacheKey(appID.AppId), resp.Msg.GetApp())
}
return resp, nil
}
Handling Transitional States
A critical feature of the AppService is its awareness of "transitional states." If a user starts an application, there is a window of time where the DesiredState is STARTED but the Kubernetes status still reports STOPPED while pods are spinning up.
Flyte avoids caching these states to prevent the UI from appearing stuck. The isTransitionalState function identifies this mismatch:
func isTransitionalState(app *flyteapp.App) bool {
if app.GetSpec().GetDesiredState() == flyteapp.Spec_DESIRED_STATE_STOPPED {
return false
}
for _, cond := range app.GetStatus().GetConditions() {
if cond.GetDeploymentStatus() == flyteapp.Status_DEPLOYMENT_STATUS_STOPPED {
return true // Desired is STARTED, but status is still STOPPED
}
}
return false
}
The Data Plane: InternalAppService
The InternalAppService (located in app/internal/service/internal_app_service.go) is the authoritative manager of application resources. It has direct access to the Kubernetes cluster via an AppK8sClientInterface.
Resource Management
The data plane translates Flyte application definitions into Knative KService resources.
- Create: Deploys a new
KService. - Update: Modifies the
KServicespec or scales it to zero if theDesiredStateisSTOPPED. - Delete: Removes the
KServiceCRD entirely.
// app/internal/service/internal_app_service.go
func (s *InternalAppService) Update(
ctx context.Context,
req *connect.Request[flyteapp.UpdateRequest],
) (*connect.Response[flyteapp.UpdateResponse], error) {
app := req.Msg.GetApp()
appID := app.GetMetadata().GetId()
switch app.GetSpec().GetDesiredState() {
case flyteapp.Spec_DESIRED_STATE_STOPPED:
if err := s.k8s.Stop(ctx, appID); err != nil {
return nil, connect.NewError(connect.CodeInternal, err)
}
default:
// Deploy or redeploy the spec for STARTED/ACTIVE states
if err := s.k8s.Deploy(ctx, app); err != nil {
return nil, connect.NewError(connect.CodeInternal, err)
}
}
// ...
}
Deployment Modes
Flyte supports two deployment strategies for these planes, configured during system setup in app/setup.go.
Unified Mode
In unified mode, both the control plane and data plane run within the same process. The AppService still communicates with the InternalAppService via a Connect client, but the traffic is routed through a local HTTP mux. To avoid path collisions (since they share the same Protobuf service name), the data plane is mounted under an /internal prefix.
// app/setup.go
internalClient := appconnect.NewAppServiceClient(
http.DefaultClient,
internalAppURL+"/internal", // Data plane is isolated by prefix
connect.WithInterceptors(otelInterceptor),
)
Split Mode
In split mode, the control plane and data plane run as separate services. This allows you to scale the control plane (which handles high-volume API traffic and caching) independently from the data plane (which requires elevated Kubernetes permissions to manage CRDs). In this mode, internalAppURL is configured to point to the remote data plane service.
Configuration
The behavior of these planes is controlled by several configuration keys:
| Key | Default | Description |
|---|---|---|
apps.cacheTtl | 30s | TTL for the AppService in-memory cache. Set to 0 to disable caching. |
apps.internalAppServiceUrl | http://localhost:8091 | The endpoint where the control plane finds the data plane. |
internalApps.enabled | false | Whether the data plane (Kubernetes controller) should be initialized. |
internalApps.maxRequestTimeout | 3600s | The maximum timeout allowed for application requests (enforced by Knative). |
By separating these concerns, Flyte ensures that the user-facing API remains responsive and scalable while maintaining a robust, Kubernetes-native management layer for the actual application workloads.