Skip to content

Commit 6bc52cb

Browse files
craig[bot]cpj2195
andcommitted
Merge #158964
158964: roachprod: Add support for GCE bulk VM creation using SDK r=cpj2195 a=cpj2195 This patch adds support to use GCP's Go SDK to bulk create a large number of identical VM's. This way the rate limiting is handled by the internal GCP API rather than being client side. - CLI: 100 VMs = ~4 concurrent gcloud CLI calls with batching - BulkInsert: 100 VMs = 1 API call per zone I have abstracted out the SDK implementation for bulk creation in a separate file behind a CLI flag --gce-use-bulk-insert as shown in the screenshot below. Default behaviour is WITH bulk-insert. <img width="1692" height="952" alt="Screenshot 2025-12-15 at 10 03 41 AM" src="https://github.com/user-attachments/assets/f33ce6d4-e7b0-4fb0-a39e-dd799dc53354" /> Fixes : [CRDB-52609](https://cockroachlabs.atlassian.net/browse/CRDB-52609) Epic: none Release note: None Co-authored-by: cpj2195 <[email protected]>
2 parents 5b67446 + a68e491 commit 6bc52cb

File tree

4 files changed

+437
-1
lines changed

4 files changed

+437
-1
lines changed

pkg/roachprod/vm/gce/BUILD.bazel

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ load("@io_bazel_rules_go//go:def.bzl", "go_library", "go_test")
33
go_library(
44
name = "gce",
55
srcs = [
6+
"create_sdk.go",
67
"dns.go",
78
"dns_sdk.go",
89
"fast_dns.go",

pkg/roachprod/vm/gce/create_sdk.go

Lines changed: 374 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,374 @@
1+
// Copyright 2018 The Cockroach Authors.
2+
//
3+
// Use of this software is governed by the CockroachDB Software License
4+
// included in the /LICENSE file.
5+
6+
package gce
7+
8+
import (
9+
"context"
10+
"fmt"
11+
"strings"
12+
13+
compute "cloud.google.com/go/compute/apiv1"
14+
computepb "cloud.google.com/go/compute/apiv1/computepb"
15+
"github.com/cockroachdb/cockroach/pkg/roachprod/logger"
16+
"github.com/cockroachdb/cockroach/pkg/roachprod/ui"
17+
"github.com/cockroachdb/cockroach/pkg/roachprod/vm"
18+
"github.com/cockroachdb/cockroach/pkg/util/syncutil"
19+
"github.com/cockroachdb/errors"
20+
"google.golang.org/protobuf/proto"
21+
)
22+
23+
// buildInstanceProperties creates a computepb.InstanceProperties struct for the
24+
// GCP Compute SDK. This is the SDK equivalent of computeInstanceArgs().
25+
func (p *Provider) buildInstanceProperties(
26+
l *logger.Logger,
27+
opts vm.CreateOpts,
28+
providerOpts *ProviderOpts,
29+
startupScriptContent string,
30+
zone string,
31+
labels map[string]string,
32+
) (*computepb.InstanceProperties, error) {
33+
// Determine which image to use.
34+
image := providerOpts.Image
35+
imageProject := defaultImageProject
36+
37+
if providerOpts.useArmAMI() {
38+
image = ARM64Image
39+
}
40+
41+
if opts.Arch == string(vm.ArchFIPS) {
42+
image = FIPSImage
43+
imageProject = FIPSImageProject
44+
}
45+
46+
// Build the boot disk configuration.
47+
disks := []*computepb.AttachedDisk{
48+
{
49+
AutoDelete: proto.Bool(true),
50+
Boot: proto.Bool(true),
51+
InitializeParams: &computepb.AttachedDiskInitializeParams{
52+
DiskSizeGb: proto.Int64(int64(opts.OsVolumeSize)),
53+
DiskType: proto.String(providerOpts.BootDiskType),
54+
SourceImage: proto.String(fmt.Sprintf("projects/%s/global/images/%s", imageProject, image)),
55+
},
56+
},
57+
}
58+
59+
// Add additional disks (SSDs or persistent disks).
60+
if !providerOpts.BootDiskOnly {
61+
if opts.SSDOpts.UseLocalSSD {
62+
// Local SSDs are physically attached to the host machine.
63+
for i := 0; i < providerOpts.SSDCount; i++ {
64+
disks = append(disks, &computepb.AttachedDisk{
65+
AutoDelete: proto.Bool(true),
66+
Type: proto.String(computepb.AttachedDisk_SCRATCH.String()),
67+
Mode: proto.String(computepb.AttachedDisk_READ_WRITE.String()),
68+
InitializeParams: &computepb.AttachedDiskInitializeParams{
69+
DiskType: proto.String("local-ssd"),
70+
},
71+
Interface: proto.String(computepb.AttachedDisk_NVME.String()),
72+
})
73+
}
74+
} else {
75+
// Persistent disks are network-attached storage.
76+
for i := 0; i < providerOpts.PDVolumeCount; i++ {
77+
disks = append(disks, &computepb.AttachedDisk{
78+
AutoDelete: proto.Bool(true),
79+
InitializeParams: &computepb.AttachedDiskInitializeParams{
80+
DiskSizeGb: proto.Int64(int64(providerOpts.PDVolumeSize)),
81+
DiskType: proto.String(providerOpts.PDVolumeType),
82+
},
83+
})
84+
}
85+
}
86+
}
87+
88+
// Configure networking.
89+
if len(zone) < 3 {
90+
return nil, errors.Newf("invalid zone %q: must be at least 3 characters", zone)
91+
}
92+
region := zone[:len(zone)-2]
93+
project := p.GetProject()
94+
networkInterfaces := []*computepb.NetworkInterface{
95+
{
96+
Subnetwork: proto.String(fmt.Sprintf("projects/%s/regions/%s/subnetworks/default", project, region)),
97+
AccessConfigs: []*computepb.AccessConfig{
98+
{
99+
Name: proto.String("External NAT"),
100+
Type: proto.String(computepb.AccessConfig_ONE_TO_ONE_NAT.String()),
101+
},
102+
},
103+
},
104+
}
105+
106+
// Configure scheduling.
107+
scheduling := &computepb.Scheduling{}
108+
if providerOpts.preemptible {
109+
scheduling.Preemptible = proto.Bool(true)
110+
scheduling.OnHostMaintenance = proto.String(computepb.Scheduling_TERMINATE.String())
111+
scheduling.AutomaticRestart = proto.Bool(false)
112+
} else if providerOpts.UseSpot {
113+
scheduling.ProvisioningModel = proto.String(computepb.Scheduling_SPOT.String())
114+
} else if providerOpts.TerminateOnMigration {
115+
scheduling.OnHostMaintenance = proto.String(computepb.Scheduling_TERMINATE.String())
116+
} else {
117+
scheduling.OnHostMaintenance = proto.String(computepb.Scheduling_MIGRATE.String())
118+
}
119+
120+
// Configure the service account.
121+
var serviceAccounts []*computepb.ServiceAccount
122+
sa := providerOpts.ServiceAccount
123+
if sa == "" && p.GetProject() == p.defaultProject {
124+
sa = providerOpts.defaultServiceAccount
125+
}
126+
if sa != "" {
127+
serviceAccounts = []*computepb.ServiceAccount{
128+
{
129+
Email: proto.String(sa),
130+
Scopes: []string{"https://www.googleapis.com/auth/cloud-platform"},
131+
},
132+
}
133+
}
134+
135+
// Configure metadata (including the startup script).
136+
metadata := &computepb.Metadata{
137+
Items: []*computepb.Items{
138+
{
139+
Key: proto.String("startup-script"),
140+
Value: proto.String(startupScriptContent),
141+
},
142+
},
143+
}
144+
145+
// Build the final InstanceProperties.
146+
props := &computepb.InstanceProperties{
147+
MachineType: proto.String(providerOpts.MachineType),
148+
Disks: disks,
149+
NetworkInterfaces: networkInterfaces,
150+
Scheduling: scheduling,
151+
ServiceAccounts: serviceAccounts,
152+
Metadata: metadata,
153+
}
154+
155+
if providerOpts.MinCPUPlatform != "" {
156+
props.MinCpuPlatform = proto.String(providerOpts.MinCPUPlatform)
157+
}
158+
159+
// Set the labels on the instance properties.
160+
props.Labels = labels
161+
162+
return props, nil
163+
}
164+
165+
// parseLabelsString converts a "key=value,key2=value2" string into a map.
166+
func parseLabelsString(labels string) (map[string]string, error) {
167+
result := make(map[string]string)
168+
if labels == "" {
169+
return result, nil
170+
}
171+
172+
pairs := strings.Split(labels, ",")
173+
for _, pair := range pairs {
174+
kv := strings.Split(pair, "=")
175+
if len(kv) != 2 {
176+
return nil, errors.Newf("invalid label format: %q (expected key=value)", pair)
177+
}
178+
result[kv[0]] = kv[1]
179+
}
180+
return result, nil
181+
}
182+
183+
// bulkInsertInstances uses the GCP Compute SDK to create multiple VMs in a
184+
// single zone using the BulkInsert API.
185+
//
186+
// Benefits over CLI-based approach:
187+
// - CLI: 100 VMs = ~4 concurrent gcloud CLI calls with batching
188+
// - BulkInsert: 100 VMs = 1 API call per zone
189+
func (p *Provider) bulkInsertInstances(
190+
ctx context.Context,
191+
l *logger.Logger,
192+
zone string,
193+
hostNames []string,
194+
instanceProps *computepb.InstanceProperties,
195+
) error {
196+
project := p.GetProject()
197+
198+
client, err := compute.NewInstancesRESTClient(ctx)
199+
if err != nil {
200+
return errors.Wrap(err, "failed to create GCE compute client")
201+
}
202+
defer func() { _ = client.Close() }()
203+
204+
// Build the per-instance properties map for exact VM naming.
205+
perInstanceProps := make(map[string]*computepb.BulkInsertInstanceResourcePerInstanceProperties)
206+
for _, name := range hostNames {
207+
perInstanceProps[name] = &computepb.BulkInsertInstanceResourcePerInstanceProperties{
208+
Name: proto.String(name),
209+
}
210+
}
211+
212+
// Build the BulkInsert request.
213+
req := &computepb.BulkInsertInstanceRequest{
214+
Project: project,
215+
Zone: zone,
216+
BulkInsertInstanceResourceResource: &computepb.BulkInsertInstanceResource{
217+
Count: proto.Int64(int64(len(hostNames))),
218+
MinCount: proto.Int64(int64(len(hostNames))),
219+
InstanceProperties: instanceProps,
220+
PerInstanceProperties: perInstanceProps,
221+
},
222+
}
223+
224+
// Execute the BulkInsert request (async operation).
225+
op, err := client.BulkInsert(ctx, req)
226+
if err != nil {
227+
return errors.Wrapf(err, "BulkInsert request failed for zone %s", zone)
228+
}
229+
230+
// Wait for the operation to complete with a spinner for progress feedback.
231+
err = func() error {
232+
defer ui.NewDefaultSpinner(l, fmt.Sprintf("BulkInsert: creating %d instances in %s", len(hostNames), zone)).Start()()
233+
return op.Wait(ctx)
234+
}()
235+
if err != nil {
236+
return errors.Wrapf(err, "BulkInsert operation failed for zone %s", zone)
237+
}
238+
l.Printf("BulkInsert: successfully created %d instances in zone %s", len(hostNames), zone)
239+
return nil
240+
}
241+
242+
// createInstancesSDK creates VMs using the GCP Compute SDK's BulkInsert API.
243+
// This is an alternative to the CLI-based approach that is more efficient
244+
// for creating large numbers of VMs.
245+
func (p *Provider) createInstancesSDK(
246+
l *logger.Logger,
247+
opts vm.CreateOpts,
248+
providerOpts *ProviderOpts,
249+
labels string,
250+
zoneToHostNames map[string][]string,
251+
usedZones []string,
252+
) (vm.List, error) {
253+
project := p.GetProject()
254+
255+
// Compute the extraMountOpts for the startup script.
256+
extraMountOpts := ""
257+
if !providerOpts.BootDiskOnly {
258+
if opts.SSDOpts.UseLocalSSD {
259+
extraMountOpts = "discard"
260+
if opts.SSDOpts.NoExt4Barrier {
261+
extraMountOpts = fmt.Sprintf("%s,nobarrier", extraMountOpts)
262+
}
263+
} else {
264+
extraMountOpts = "discard"
265+
}
266+
}
267+
268+
// Generate the startup script content as a string.
269+
startupScriptContent, err := generateStartupScriptContent(
270+
extraMountOpts,
271+
opts.SSDOpts.FileSystem,
272+
providerOpts.UseMultipleDisks,
273+
opts.Arch == string(vm.ArchFIPS),
274+
providerOpts.EnableCron,
275+
providerOpts.BootDiskOnly,
276+
)
277+
if err != nil {
278+
return nil, errors.Wrap(err, "failed to generate startup script content")
279+
}
280+
281+
// Parse the labels string into a map.
282+
labelsMap, err := parseLabelsString(labels)
283+
if err != nil {
284+
return nil, errors.Wrap(err, "failed to parse labels")
285+
}
286+
287+
// Create VMs using BulkInsert - one API call per zone.
288+
l.Printf("Creating instances using BulkInsert API, distributed across [%s]",
289+
strings.Join(usedZones, ", "))
290+
291+
g := newLimitedErrorGroup()
292+
for zone, zoneHosts := range zoneToHostNames {
293+
zone := zone
294+
zoneHosts := zoneHosts
295+
296+
g.Go(func() error {
297+
// Build the InstanceProperties for this zone (includes labels).
298+
instanceProps, err := p.buildInstanceProperties(
299+
l, opts, providerOpts, startupScriptContent, zone, labelsMap,
300+
)
301+
if err != nil {
302+
return errors.Wrapf(err, "failed to build instance properties for zone %s", zone)
303+
}
304+
305+
// Call BulkInsert for this zone.
306+
err = p.bulkInsertInstances(
307+
context.Background(),
308+
l,
309+
zone,
310+
zoneHosts,
311+
instanceProps,
312+
)
313+
if err != nil {
314+
return errors.Wrapf(err, "BulkInsert failed for zone %s", zone)
315+
}
316+
317+
return nil
318+
})
319+
}
320+
321+
if err := g.Wait(); err != nil {
322+
return nil, err
323+
}
324+
325+
// Fetch the created VMs to populate vmList using parallel list calls per zone.
326+
var vmList vm.List
327+
var vmListMutex syncutil.Mutex
328+
329+
g = newLimitedErrorGroup()
330+
for zone, zoneHosts := range zoneToHostNames {
331+
zone := zone
332+
zoneHosts := zoneHosts
333+
334+
g.Go(func() error {
335+
// Build a set of hostnames for quick lookup.
336+
hostNameSet := make(map[string]struct{}, len(zoneHosts))
337+
for _, name := range zoneHosts {
338+
hostNameSet[name] = struct{}{}
339+
}
340+
341+
// Build name filter for server-side filtering.
342+
nameFilter := fmt.Sprintf("zone:%s AND name:(%s)", zone, strings.Join(zoneHosts, " "))
343+
344+
listArgs := []string{
345+
"compute", "instances", "list",
346+
"--filter", nameFilter,
347+
"--project", project,
348+
"--format", "json",
349+
}
350+
351+
var instances []jsonVM
352+
if err := runJSONCommand(listArgs, &instances); err != nil {
353+
return errors.Wrapf(err, "failed to list VMs in zone %s", zone)
354+
}
355+
356+
// Filter to only include our created VMs (safety check).
357+
vmListMutex.Lock()
358+
defer vmListMutex.Unlock()
359+
for _, instance := range instances {
360+
if _, ok := hostNameSet[instance.Name]; ok {
361+
v := instance.toVM(project, p.dnsProvider.PublicDomain())
362+
vmList = append(vmList, *v)
363+
}
364+
}
365+
return nil
366+
})
367+
}
368+
369+
if err := g.Wait(); err != nil {
370+
return nil, err
371+
}
372+
373+
return vmList, nil
374+
}

0 commit comments

Comments
 (0)