Skip to content

Commit 83b459c

Browse files
cluster-autoscaler: add SAKURA cloud (sakuracloud) cloud provider
Adds a cloud provider for SAKURA cloud (sakura.ad.jp), a Japanese cloud that has no instance-group/ASG primitive: the provider creates and deletes servers directly (disk copy from a source archive, server creation on the shared segment, startup-note bootstrap, power on), following the same approach as the Hetzner provider. - Node group membership via the ca-group-<name> server tag - providerID convention sakuracloud://<zone>/<serverName> - Scale-from-zero via TemplateNodeInfo advertising configured labels/taints - Nodes with foreign providerIDs are reported as unmanaged (NodeGroupForNode returns nil) per the CloudProvider contract Verified end to end on a real mixed-provider cluster: pending pod -> scale-up 0->1 (disk+server provisioning), node joins and the pod schedules, then scale-down deletes the server together with its disks.
1 parent 5e07dd5 commit 83b459c

12 files changed

Lines changed: 1422 additions & 3 deletions

cluster-autoscaler/FAQ.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1013,7 +1013,7 @@ The following startup parameters are supported for cluster autoscaler:
10131013
| `check-capacity-provisioning-request-batch-timebox` | Maximum time to process a batch of provisioning requests. | 10s |
10141014
| `check-capacity-provisioning-request-max-batch-size` | Maximum number of provisioning requests to process in a single batch. | 10 |
10151015
| `cloud-config` | The path to the cloud provider configuration file. Empty string for no configuration file. | |
1016-
| `cloud-provider` | Cloud provider type. Available values: [aws,azure,gce,alicloud,cherryservers,cloudstack,baiducloud,magnum,digitalocean,exoscale,externalgrpc,huaweicloud,hetzner,oci,ovhcloud,clusterapi,ionoscloud,kamatera,kwok,linode,bizflycloud,brightbox,equinixmetal,vultr,tencentcloud,civo,scaleway,rancher,volcengine,utho,coreweave] | "gce" |
1016+
| `cloud-provider` | Cloud provider type. Available values: [aws,azure,gce,alicloud,cherryservers,cloudstack,baiducloud,magnum,digitalocean,exoscale,externalgrpc,huaweicloud,hetzner,oci,ovhcloud,clusterapi,ionoscloud,kamatera,kwok,linode,bizflycloud,brightbox,equinixmetal,vultr,tencentcloud,civo,scaleway,rancher,sakuracloud,volcengine,utho,coreweave] | "gce" |
10171017
| `cloud-provider-gce-l7lb-src-cidrs` | CIDRs opened in GCE firewall for L7 LB traffic proxy & health checks | 130.211.0.0/22,35.191.0.0/16 |
10181018
| `cloud-provider-gce-lb-src-cidrs` | CIDRs opened in GCE firewall for L4 LB traffic proxy & health checks | 130.211.0.0/22,209.85.152.0/22,209.85.204.0/22,35.191.0.0/16 |
10191019
| `cluster-name` | Autoscaled cluster name, if available | |

cluster-autoscaler/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ You should also take a look at the notes and "gotchas" for your specific cloud p
3838
* [OracleCloud](./cloudprovider/oci/README.md)
3939
* [OVHcloud](./cloudprovider/ovhcloud/README.md)
4040
* [Rancher](./cloudprovider/rancher/README.md)
41+
* [SakuraCloud](./cloudprovider/sakuracloud/README.md)
4142
* [Scaleway](./cloudprovider/scaleway/README.md)
4243
* [TencentCloud](./cloudprovider/tencentcloud/README.md)
4344
* [Utho](./cloudprovider/utho/README.md)

cluster-autoscaler/cloudprovider/router/router_all.go

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
//go:build !gce && !aws && !azure && !kubemark && !alicloud && !magnum && !digitalocean && !clusterapi && !huaweicloud && !ionoscloud && !linode && !hetzner && !bizflycloud && !brightbox && !equinixmetal && !oci && !vultr && !tencentcloud && !scaleway && !externalgrpc && !civo && !rancher && !volcengine && !baiducloud && !cherry && !cloudstack && !exoscale && !kamatera && !ovhcloud && !kwok && !utho && !coreweave
2-
// +build !gce,!aws,!azure,!kubemark,!alicloud,!magnum,!digitalocean,!clusterapi,!huaweicloud,!ionoscloud,!linode,!hetzner,!bizflycloud,!brightbox,!equinixmetal,!oci,!vultr,!tencentcloud,!scaleway,!externalgrpc,!civo,!rancher,!volcengine,!baiducloud,!cherry,!cloudstack,!exoscale,!kamatera,!ovhcloud,!kwok,!utho,!coreweave
1+
//go:build !gce && !aws && !azure && !kubemark && !alicloud && !magnum && !digitalocean && !clusterapi && !huaweicloud && !ionoscloud && !linode && !hetzner && !bizflycloud && !brightbox && !equinixmetal && !oci && !vultr && !tencentcloud && !scaleway && !externalgrpc && !civo && !rancher && !sakuracloud && !volcengine && !baiducloud && !cherry && !cloudstack && !exoscale && !kamatera && !ovhcloud && !kwok && !utho && !coreweave
2+
// +build !gce,!aws,!azure,!kubemark,!alicloud,!magnum,!digitalocean,!clusterapi,!huaweicloud,!ionoscloud,!linode,!hetzner,!bizflycloud,!brightbox,!equinixmetal,!oci,!vultr,!tencentcloud,!scaleway,!externalgrpc,!civo,!rancher,!sakuracloud,!volcengine,!baiducloud,!cherry,!cloudstack,!exoscale,!kamatera,!ovhcloud,!kwok,!utho,!coreweave
33

44
/*
55
Copyright The Kubernetes Authors.
@@ -46,6 +46,7 @@ import (
4646
_ "k8s.io/autoscaler/cluster-autoscaler/cloudprovider/oci/instancepools"
4747
_ "k8s.io/autoscaler/cluster-autoscaler/cloudprovider/ovhcloud"
4848
_ "k8s.io/autoscaler/cluster-autoscaler/cloudprovider/rancher"
49+
_ "k8s.io/autoscaler/cluster-autoscaler/cloudprovider/sakuracloud"
4950
_ "k8s.io/autoscaler/cluster-autoscaler/cloudprovider/scaleway"
5051
_ "k8s.io/autoscaler/cluster-autoscaler/cloudprovider/tencentcloud"
5152
_ "k8s.io/autoscaler/cluster-autoscaler/cloudprovider/utho"
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
//go:build sakuracloud
2+
// +build sakuracloud
3+
4+
/*
5+
Copyright The Kubernetes Authors.
6+
7+
Licensed under the Apache License, Version 2.0 (the "License");
8+
you may not use this file except in compliance with the License.
9+
You may obtain a copy of the License at
10+
11+
http://www.apache.org/licenses/LICENSE-2.0
12+
13+
Unless required by applicable law or agreed to in writing, software
14+
distributed under the License is distributed on an "AS IS" BASIS,
15+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
16+
See the License for the specific language governing permissions and
17+
limitations under the License.
18+
*/
19+
20+
package router
21+
22+
import (
23+
// Blank import to register a cloudprovider outside main or test package.
24+
// This is by design.
25+
_ "k8s.io/autoscaler/cluster-autoscaler/cloudprovider/sakuracloud"
26+
)
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
maintainers:
2+
- shinichitazawa
3+
4+
labels:
5+
- area/provider/sakuracloud
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
# Cluster Autoscaler for SAKURA cloud
2+
3+
The cluster autoscaler for [SAKURA cloud](https://cloud.sakura.ad.jp/) scales
4+
worker nodes in a self-managed cluster. SAKURA cloud has no instance-group /
5+
ASG primitive, so the autoscaler provisions and deletes servers directly
6+
(disk copy from a source archive, server creation on the shared segment,
7+
startup-note bootstrap), the same approach as the Hetzner provider.
8+
9+
## Configuration
10+
11+
Environment variables:
12+
13+
- `SAKURACLOUD_ACCESS_TOKEN` / `SAKURACLOUD_ACCESS_TOKEN_SECRET`: API key.
14+
- `SAKURACLOUD_CLUSTER_CONFIG`: JSON document:
15+
16+
```json
17+
{
18+
"zone": "is1a",
19+
"nodeGroups": {
20+
"sakura-cil": {
21+
"minSize": 0,
22+
"maxSize": 2,
23+
"core": 2,
24+
"memoryGB": 4,
25+
"diskGB": 20,
26+
"sourceArchiveID": "<ubuntu 24.04 archive ID>",
27+
"startupNoteID": "<startup script note ID>",
28+
"labels": {"cloud": "sakura", "role": "sakura-spot"},
29+
"taints": [{"key": "dedicated", "value": "sakura-ops", "effect": "NoSchedule"}]
30+
}
31+
}
32+
}
33+
```
34+
35+
Run with `--cloud-provider=sakuracloud`.
36+
37+
## Node group membership and providerID
38+
39+
- Servers belonging to a node group carry the tag `ca-group-<name>`.
40+
- The providerID convention is `sakuracloud://<zone>/<serverName>`. The
41+
bootstrap startup note must register the kubelet with a matching
42+
`--provider-id`; the disk config sets the hostname to the server name, so
43+
the note can derive it as `sakuracloud://<zone>/$(hostname -s)`.
44+
- Nodes with any other providerID scheme (mixed-provider clusters) are
45+
reported as unmanaged (`NodeGroupForNode` returns nil), per the
46+
CloudProvider contract.
47+
48+
## Scale-from-zero
49+
50+
`TemplateNodeInfo` advertises cpu/memory from the group config plus the
51+
configured labels and taints, so pods with matching nodeSelector/tolerations
52+
trigger scale-up from zero.
53+
54+
## Notes
55+
56+
- The startup note (SAKURA cloud "note", class `shell`) is executed on first
57+
boot and must install the kubelet/agent and join the cluster. Keep join
58+
credentials in the note, not in `SAKURACLOUD_CLUSTER_CONFIG`.
59+
- Scale-down deletes the server together with its disks.
60+
61+
## Implementation notes (observed API behavior)
62+
63+
- The server plan must be specified by spec (`{"CPU": n, "MemoryMB": m}`);
64+
the plan-ID form returned by `/product/server` is rejected with 400 by the
65+
current API.
66+
- After `PUT /disk/:id/config` (hostname + startup note injection) the disk
67+
transiently leaves the `available` state; powering the server on before it
68+
settles fails with `409 disk_is_not_available`, so the provider waits for
69+
the disk to become available again.
70+
- The `/server` list response does not reliably include the instance power
71+
state, so deletion always force-powers-off first and tolerates a 409
72+
(already down) before deleting the server together with its disks.
Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,191 @@
1+
/*
2+
Copyright 2026 The Kubernetes Authors.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
package sakuracloud
18+
19+
import (
20+
"fmt"
21+
"strings"
22+
23+
apiv1 "k8s.io/api/core/v1"
24+
"k8s.io/apimachinery/pkg/api/resource"
25+
"k8s.io/client-go/informers"
26+
"k8s.io/klog/v2"
27+
"sigs.k8s.io/cluster-autoscaler/pkg/cloudprovider"
28+
"sigs.k8s.io/cluster-autoscaler/pkg/cloudprovider/builder"
29+
coreoptions "sigs.k8s.io/cluster-autoscaler/pkg/core/options"
30+
autoscalerErrors "sigs.k8s.io/cluster-autoscaler/pkg/utils/errors"
31+
"sigs.k8s.io/cluster-autoscaler/pkg/utils/gpu"
32+
)
33+
34+
// ProviderName is the cloud provider name for SAKURA cloud.
35+
const ProviderName = "sakuracloud"
36+
37+
var _ cloudprovider.CloudProvider = (*sakuracloudCloudProvider)(nil)
38+
39+
func init() {
40+
builder.RegisterCloudProvider(ProviderName, func(opts *coreoptions.AutoscalerOptions, do cloudprovider.NodeGroupDiscoveryOptions, rl *cloudprovider.ResourceLimiter, informerFactory informers.SharedInformerFactory) cloudprovider.CloudProvider {
41+
return BuildSakuraCloud(opts, do, rl)
42+
})
43+
builder.SetDefaultCloudProvider(ProviderName)
44+
}
45+
46+
const (
47+
// GPULabel is the label added to nodes with GPU resource.
48+
GPULabel = "sakuracloud/gpu-node"
49+
50+
providerIDPrefix = "sakuracloud://"
51+
)
52+
53+
// providerIDForServer builds "sakuracloud://<zone>/<serverName>". The node's
54+
// kubelet must be started with the matching --provider-id (the bootstrap
55+
// startup note derives it from the hostname, which equals the server name).
56+
func providerIDForServer(zone, serverName string) string {
57+
return fmt.Sprintf("%s%s/%s", providerIDPrefix, zone, serverName)
58+
}
59+
60+
// serverNameFromProviderID extracts the server name from a providerID.
61+
func serverNameFromProviderID(providerID string) (string, error) {
62+
rest, ok := strings.CutPrefix(providerID, providerIDPrefix)
63+
if !ok {
64+
return "", fmt.Errorf("providerID %q does not have prefix %q", providerID, providerIDPrefix)
65+
}
66+
parts := strings.SplitN(rest, "/", 2)
67+
if len(parts) != 2 || parts[1] == "" {
68+
return "", fmt.Errorf("providerID %q: expected format %szone/serverName", providerID, providerIDPrefix)
69+
}
70+
return parts[1], nil
71+
}
72+
73+
// sakuracloudCloudProvider implements cloudprovider.CloudProvider for
74+
// SAKURA cloud (sakura.ad.jp).
75+
type sakuracloudCloudProvider struct {
76+
manager *sakuracloudManager
77+
resourceLimiter *cloudprovider.ResourceLimiter
78+
}
79+
80+
// Name returns the name of the cloud provider.
81+
func (d *sakuracloudCloudProvider) Name() string {
82+
return ProviderName
83+
}
84+
85+
// NodeGroups returns all node groups configured for this cloud provider.
86+
func (d *sakuracloudCloudProvider) NodeGroups() []cloudprovider.NodeGroup {
87+
groups := make([]cloudprovider.NodeGroup, 0, len(d.manager.nodeGroups))
88+
for _, group := range d.manager.nodeGroups {
89+
groups = append(groups, group)
90+
}
91+
return groups
92+
}
93+
94+
// NodeGroupForNode returns the node group for the given node. Nodes whose
95+
// providerID is not sakuracloud:// (a mixed-provider cluster: control plane,
96+
// other clouds) are not managed by this provider and yield nil, per the
97+
// CloudProvider contract.
98+
func (d *sakuracloudCloudProvider) NodeGroupForNode(node *apiv1.Node) (cloudprovider.NodeGroup, error) {
99+
serverName, err := serverNameFromProviderID(node.Spec.ProviderID)
100+
if err != nil {
101+
klog.V(4).Infof("sakuracloud: node %s has foreign providerID %q, treating as unmanaged", node.Name, node.Spec.ProviderID)
102+
return nil, nil
103+
}
104+
server := d.manager.serverByName(serverName)
105+
if server == nil {
106+
return nil, nil
107+
}
108+
groupName, ok := server.groupName()
109+
if !ok {
110+
return nil, nil
111+
}
112+
group, exists := d.manager.nodeGroups[groupName]
113+
if !exists {
114+
return nil, nil
115+
}
116+
return group, nil
117+
}
118+
119+
// HasInstance returns whether a given node has a corresponding instance.
120+
func (d *sakuracloudCloudProvider) HasInstance(node *apiv1.Node) (bool, error) {
121+
return true, cloudprovider.ErrNotImplemented
122+
}
123+
124+
// Pricing is not implemented.
125+
func (d *sakuracloudCloudProvider) Pricing() (cloudprovider.PricingModel, autoscalerErrors.AutoscalerError) {
126+
return nil, cloudprovider.ErrNotImplemented
127+
}
128+
129+
// GetAvailableMachineTypes is not implemented.
130+
func (d *sakuracloudCloudProvider) GetAvailableMachineTypes() ([]string, error) {
131+
return []string{}, nil
132+
}
133+
134+
// NewNodeGroup is not implemented: node groups are statically configured.
135+
func (d *sakuracloudCloudProvider) NewNodeGroup(
136+
machineType string,
137+
labels map[string]string,
138+
systemLabels map[string]string,
139+
taints []apiv1.Taint,
140+
extraResources map[string]resource.Quantity,
141+
) (cloudprovider.NodeGroup, error) {
142+
return nil, cloudprovider.ErrNotImplemented
143+
}
144+
145+
// GetResourceLimiter returns resource constraints for the cluster.
146+
func (d *sakuracloudCloudProvider) GetResourceLimiter() (*cloudprovider.ResourceLimiter, error) {
147+
return d.resourceLimiter, nil
148+
}
149+
150+
// GPULabel returns the label added to nodes with GPU resource.
151+
func (d *sakuracloudCloudProvider) GPULabel() string {
152+
return GPULabel
153+
}
154+
155+
// GetAvailableGPUTypes returns all available GPU types.
156+
func (d *sakuracloudCloudProvider) GetAvailableGPUTypes() map[string]struct{} {
157+
return nil
158+
}
159+
160+
// GetNodeGpuConfig returns the GPU config of the given node.
161+
func (d *sakuracloudCloudProvider) GetNodeGpuConfig(node *apiv1.Node) *cloudprovider.GpuConfig {
162+
return gpu.GetNodeGPUFromCloudProvider(d, node)
163+
}
164+
165+
// Cleanup closes open resources.
166+
func (d *sakuracloudCloudProvider) Cleanup() error {
167+
return nil
168+
}
169+
170+
// Refresh is called before every main loop iteration.
171+
func (d *sakuracloudCloudProvider) Refresh() error {
172+
if err := d.manager.refreshServers(); err != nil {
173+
return err
174+
}
175+
for _, group := range d.manager.nodeGroups {
176+
group.syncTargetSize()
177+
}
178+
return nil
179+
}
180+
181+
// BuildSakuraCloud builds the SAKURA cloud provider.
182+
func BuildSakuraCloud(_ *coreoptions.AutoscalerOptions, _ cloudprovider.NodeGroupDiscoveryOptions, rl *cloudprovider.ResourceLimiter) cloudprovider.CloudProvider {
183+
manager, err := newManager()
184+
if err != nil {
185+
klog.Fatalf("Failed to create SAKURA cloud manager: %v", err)
186+
}
187+
return &sakuracloudCloudProvider{
188+
manager: manager,
189+
resourceLimiter: rl,
190+
}
191+
}

0 commit comments

Comments
 (0)