Skip to content

Commit 9ff52c1

Browse files
authored
Merge pull request #1469 from Altinity/gcs_multipart
add gcs.allow_multipart_upload and gcs.allow_multipart_download for parallel transfers of large files
2 parents 75493eb + fab2598 commit 9ff52c1

10 files changed

Lines changed: 291 additions & 9 deletions

File tree

ChangeLog.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
# v2.8.0
22
NEW FEATURES
3+
- add `gcs.allow_multipart_upload` (env `GCS_ALLOW_MULTIPART_UPLOAD`, default `false`, experimental) with `gcs.upload_concurrency` and `gcs.multipart_upload_min_size` (default 1GB) — files bigger than `multipart_upload_min_size` upload to GCS as multiple parallel parts (part size is `chunk_size`) via gRPC client and compose into the final object; not compatible with `endpoint`, `force_http`, `encryption_key`, fix [#1028](https://github.com/Altinity/clickhouse-backup/issues/1028)
4+
- add `gcs.allow_multipart_download` (env `GCS_ALLOW_MULTIPART_DOWNLOAD`, default `false`) with `gcs.download_concurrency` — download each file as parallel range reads (part size is `chunk_size`) into a temporary file, mirrors `s3.allow_multipart_download`, fix [#1028](https://github.com/Altinity/clickhouse-backup/issues/1028)
35
- add `general.disable_environment_override` (settable ONLY in the config file, no environment variable name on purpose, default `false`) — when `true`, config values come only from the config file: all environment variables and the `--env` CLI flag are ignored during config loading; protects against accidental overrides such as Kubernetes service-discovery variables, fix [#1079](https://github.com/Altinity/clickhouse-backup/issues/1079)
46

57
# v2.7.4

ReadMe.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -367,6 +367,15 @@ gcs:
367367
client_pool_size: 500 # GCS_CLIENT_POOL_SIZE, default max(upload_concurrency, download concurrency) * 3, should be at least 3 times bigger than `UPLOAD_CONCURRENCY` or `DOWNLOAD_CONCURRENCY` in each upload and download case to avoid stuck
368368
delete_concurrency: 50 # GCS_DELETE_CONCURRENCY, how many objects delete in parallel during clean/delete operations
369369
upload_buffer_size: 131072 # GCS_UPLOAD_BUFFER_SIZE, io.CopyBuffer size in bytes feeding the GCS object writer, default 128KB; raise (e.g. 1048576 = 1MB) on high-bandwidth networks, see https://github.com/Altinity/clickhouse-backup/issues/1376
370+
# GCS_ALLOW_MULTIPART_UPLOAD, experimental: upload files bigger than `multipart_upload_min_size` as multiple parts (part size is `chunk_size`, minimum 5MiB) in parallel and compose them into the final object, see https://github.com/Altinity/clickhouse-backup/issues/1028
371+
# requires direct gRPC connection to storage.googleapis.com, not compatible with `endpoint`, `force_http` and `encryption_key`
372+
# temporary parts are written to the `gcs-go-sdk-pu-tmp/` prefix in the bucket root and deleted after compose; setup a bucket lifecycle rule for this prefix to clean up leftovers after crashes
373+
allow_multipart_upload: false
374+
upload_concurrency: 0 # GCS_UPLOAD_CONCURRENCY, how many parts of one file upload in parallel when `allow_multipart_upload` enabled, default min(4 + CPU/2, 16)
375+
multipart_upload_min_size: 1073741824 # GCS_MULTIPART_UPLOAD_MIN_SIZE, files smaller than this size in bytes use the regular single-stream upload, default 1GB
376+
# GCS_ALLOW_MULTIPART_DOWNLOAD, download each file as parallel range reads (part size is `chunk_size`) into a temporary file, requires additional disk space, see https://github.com/Altinity/clickhouse-backup/issues/1028
377+
allow_multipart_download: false
378+
download_concurrency: 1 # GCS_DOWNLOAD_CONCURRENCY, how many parts of one file download in parallel when `allow_multipart_download` enabled, default `max(CPU/2, 1) + 1`
370379
# GCS_OBJECT_LABELS, allow setup metadata for each object during upload, use {macro_name} from system.macros and {backupName} for current backup name
371380
# The format for this env variable is "key1:value1,key2:value2". For YAML please continue using map syntax
372381
object_labels: {}

pkg/config/config.go

Lines changed: 39 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,21 @@ type GCSConfig struct {
176176
EncryptionKey string `yaml:"encryption_key" envconfig:"GCS_ENCRYPTION_KEY"`
177177
// UploadBufferSize - io.CopyBuffer size feeding the GCS object writer, see https://github.com/Altinity/clickhouse-backup/issues/1376
178178
UploadBufferSize int `yaml:"upload_buffer_size" envconfig:"GCS_UPLOAD_BUFFER_SIZE"`
179+
// AllowMultipartUpload enables experimental parallel composite uploads via a gRPC client for files
180+
// bigger than MultipartUploadMinSize, part size is chunk_size, see https://github.com/Altinity/clickhouse-backup/issues/1028.
181+
// Not compatible with `endpoint`, `force_http` and `encryption_key`.
182+
AllowMultipartUpload bool `yaml:"allow_multipart_upload" envconfig:"GCS_ALLOW_MULTIPART_UPLOAD"`
183+
// UploadConcurrency - how many parts of one file upload in parallel when allow_multipart_upload enabled,
184+
// 0 means SDK default min(4 + NumCPU/2, 16)
185+
UploadConcurrency int `yaml:"upload_concurrency" envconfig:"GCS_UPLOAD_CONCURRENCY"`
186+
// MultipartUploadMinSize - files smaller than this size use the regular single-stream upload
187+
MultipartUploadMinSize int64 `yaml:"multipart_upload_min_size" envconfig:"GCS_MULTIPART_UPLOAD_MIN_SIZE"`
188+
// AllowMultipartDownload - download each file as parallel range reads into a temporary file
189+
// (requires additional disk space), part size is chunk_size, mirrors s3.allow_multipart_download,
190+
// see https://github.com/Altinity/clickhouse-backup/issues/1028
191+
AllowMultipartDownload bool `yaml:"allow_multipart_download" envconfig:"GCS_ALLOW_MULTIPART_DOWNLOAD"`
192+
// DownloadConcurrency - how many parts of one file download in parallel when allow_multipart_download enabled
193+
DownloadConcurrency int `yaml:"download_concurrency" envconfig:"GCS_DOWNLOAD_CONCURRENCY"`
179194
}
180195

181196
// AzureBlobConfig - Azure Blob settings section
@@ -608,6 +623,25 @@ func ValidateConfig(cfg *Config) error {
608623
}
609624
}
610625
}
626+
if cfg.General.RemoteStorage == "gcs" && cfg.GCS.AllowMultipartUpload {
627+
// parallel composite upload works only via gRPC client to storage.googleapis.com,
628+
// and Compose requires the same CSEK for source parts and destination object
629+
if cfg.GCS.Endpoint != "" {
630+
return errors.New("gcs `allow_multipart_upload` requires gRPC client and is not compatible with `endpoint`")
631+
}
632+
if cfg.GCS.ForceHttp {
633+
return errors.New("gcs `allow_multipart_upload` requires gRPC client and is not compatible with `force_http`")
634+
}
635+
if cfg.GCS.EncryptionKey != "" {
636+
return errors.New("gcs `allow_multipart_upload` is not compatible with `encryption_key`")
637+
}
638+
}
639+
if cfg.General.RemoteStorage == "gcs" && cfg.GCS.AllowMultipartDownload && cfg.GCS.DownloadConcurrency <= 1 {
640+
return errors.Errorf(
641+
"`allow_multipart_download` require `download_concurrency` in `gcs` section more than 1 (3-4 recommends) current value: %d",
642+
cfg.GCS.DownloadConcurrency,
643+
)
644+
}
611645
if cfg.GetCompressionFormat() == "unknown" {
612646
return errors.Errorf("'%s' is unknown remote storage", cfg.General.RemoteStorage)
613647
}
@@ -884,9 +918,11 @@ func DefaultConfig() *Config {
884918
StorageClass: "STANDARD",
885919
ClientPoolSize: int(max(uploadConcurrency*3, downloadConcurrency*3, objectDiskServerSideCopyConcurrency)),
886920
// 16Mb default chunk size, fix https://github.com/Altinity/clickhouse-backup/issues/1292
887-
ChunkSize: 16 * 1024 * 1024,
888-
DeleteConcurrency: 50,
889-
UploadBufferSize: 128 * 1024,
921+
ChunkSize: 16 * 1024 * 1024,
922+
DeleteConcurrency: 50,
923+
UploadBufferSize: 128 * 1024,
924+
MultipartUploadMinSize: 1024 * 1024 * 1024,
925+
DownloadConcurrency: int(downloadConcurrency + 1),
890926
},
891927
COS: COSConfig{
892928
RowURL: "",

pkg/config/config_test.go

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -387,6 +387,34 @@ func TestValidateConfigErrors(t *testing.T) {
387387
cfg.General.RemoteStorage = "s3"
388388
cfg.S3.HTTPIdleConnTimeout = "1parsec"
389389
}, "invalid s3 http_idle_conn_timeout"},
390+
{"gcs allow_multipart_upload with default config", func(cfg *Config) {
391+
cfg.General.RemoteStorage = "gcs"
392+
cfg.GCS.AllowMultipartUpload = true
393+
}, ""},
394+
{"gcs allow_multipart_upload incompatible with endpoint", func(cfg *Config) {
395+
cfg.General.RemoteStorage = "gcs"
396+
cfg.GCS.AllowMultipartUpload = true
397+
cfg.GCS.Endpoint = "http://gcs:8080/storage/v1/"
398+
}, "gcs `allow_multipart_upload` requires gRPC client and is not compatible with `endpoint`"},
399+
{"gcs allow_multipart_upload incompatible with force_http", func(cfg *Config) {
400+
cfg.General.RemoteStorage = "gcs"
401+
cfg.GCS.AllowMultipartUpload = true
402+
cfg.GCS.ForceHttp = true
403+
}, "gcs `allow_multipart_upload` requires gRPC client and is not compatible with `force_http`"},
404+
{"gcs allow_multipart_upload incompatible with encryption_key", func(cfg *Config) {
405+
cfg.General.RemoteStorage = "gcs"
406+
cfg.GCS.AllowMultipartUpload = true
407+
cfg.GCS.EncryptionKey = "dGVzdC1rZXktdGVzdC1rZXktdGVzdC1rZXktdGVzdA=="
408+
}, "gcs `allow_multipart_upload` is not compatible with `encryption_key`"},
409+
{"gcs allow_multipart_download with default config", func(cfg *Config) {
410+
cfg.General.RemoteStorage = "gcs"
411+
cfg.GCS.AllowMultipartDownload = true
412+
}, ""},
413+
{"gcs allow_multipart_download requires download_concurrency > 1", func(cfg *Config) {
414+
cfg.General.RemoteStorage = "gcs"
415+
cfg.GCS.AllowMultipartDownload = true
416+
cfg.GCS.DownloadConcurrency = 1
417+
}, "`allow_multipart_download` require `download_concurrency` in `gcs` section more than 1"},
390418
{"unknown remote storage", func(cfg *Config) {
391419
cfg.General.RemoteStorage = "banana"
392420
}, "'banana' is unknown remote storage"},

pkg/storage/gcs.go

Lines changed: 118 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import (
2020
"github.com/pkg/errors"
2121

2222
"cloud.google.com/go/storage"
23+
"cloud.google.com/go/storage/transfermanager"
2324
"github.com/rs/zerolog/log"
2425
"golang.org/x/sync/errgroup"
2526
"google.golang.org/api/impersonate"
@@ -31,6 +32,7 @@ import (
3132
// GCS - presents methods for manipulate data on GCS
3233
type GCS struct {
3334
client *storage.Client
35+
grpcClient *storage.Client // used only for parallel composite uploads, see https://github.com/Altinity/clickhouse-backup/issues/1028
3436
Config *config.GCSConfig
3537
clientPool *pool.ObjectPool
3638
encryptionKey []byte // Customer-Supplied Encryption Key (CSEK)
@@ -239,6 +241,20 @@ func (gcs *GCS) Connect(ctx context.Context) error {
239241
return errors.Wrap(err, "GCS Connect storage.NewClient")
240242
}
241243

244+
if gcs.Config.AllowMultipartUpload {
245+
grpcOptions := []option.ClientOption{option.WithTelemetryDisabled()}
246+
if credOption != nil {
247+
grpcOptions = append(grpcOptions, credOption)
248+
}
249+
gcs.grpcClient, err = storage.NewGRPCClient(ctx, grpcOptions...)
250+
if err != nil {
251+
return errors.Wrap(err, "GCS Connect storage.NewGRPCClient")
252+
}
253+
// client-level retry policy, part uploads inside parallel upload create
254+
// fresh ObjectHandle's from the client, so per-object Retryer doesn't cover them
255+
gcs.grpcClient.SetRetry(storage.WithPolicy(storage.RetryAlways))
256+
}
257+
242258
// Validate and decode the encryption key if provided
243259
if gcs.Config.EncryptionKey != "" {
244260
key, err := base64.StdEncoding.DecodeString(gcs.Config.EncryptionKey)
@@ -257,6 +273,11 @@ func (gcs *GCS) Connect(ctx context.Context) error {
257273

258274
func (gcs *GCS) Close(ctx context.Context) error {
259275
gcs.clientPool.Close(ctx)
276+
if gcs.grpcClient != nil {
277+
if err := gcs.grpcClient.Close(); err != nil {
278+
return errors.Wrap(err, "GCS Close grpcClient")
279+
}
280+
}
260281
if err := gcs.client.Close(); err != nil {
261282
return errors.Wrap(err, "GCS Close")
262283
}
@@ -373,15 +394,76 @@ func (gcs *GCS) GetFileReaderAbsolute(ctx context.Context, key string) (io.ReadC
373394
return reader, nil
374395
}
375396

376-
func (gcs *GCS) GetFileReaderWithLocalPath(ctx context.Context, key, _ string, _ int64) (io.ReadCloser, error) {
397+
func (gcs *GCS) GetFileReaderWithLocalPath(ctx context.Context, key, localPath string, remoteSize int64) (io.ReadCloser, error) {
398+
/* unfortunately, multipart download require allocate additional disk space
399+
and don't allow us to decompress data directly from stream */
400+
if gcs.Config.AllowMultipartDownload {
401+
log.Debug().Msgf("GCS->GetFileReaderWithLocalPath: multipart download %s, size=%d", key, remoteSize)
402+
writer, err := os.CreateTemp(localPath, strings.ReplaceAll(key, "/", "_"))
403+
if err != nil {
404+
return nil, errors.Wrap(err, "GCS GetFileReaderWithLocalPath CreateTemp")
405+
}
406+
if err = gcs.downloadMultipart(ctx, key, writer); err != nil {
407+
_ = writer.Close()
408+
_ = os.Remove(writer.Name())
409+
return nil, err
410+
}
411+
return writer, nil
412+
}
377413
return gcs.GetFileReader(ctx, key)
378414
}
379415

416+
// downloadMultipart downloads the object as parallel range reads via transfermanager.Downloader,
417+
// see https://github.com/Altinity/clickhouse-backup/issues/1028
418+
func (gcs *GCS) downloadMultipart(ctx context.Context, key string, writer io.WriterAt) error {
419+
fullKey := path.Join(gcs.Config.Path, key)
420+
partSize := int64(gcs.Config.ChunkSize)
421+
if partSize <= 0 {
422+
partSize = 16 * 1024 * 1024
423+
}
424+
attempt := func(encryptionKey []byte) error {
425+
downloader, err := transfermanager.NewDownloader(gcs.client,
426+
transfermanager.WithWorkers(gcs.Config.DownloadConcurrency),
427+
transfermanager.WithPartSize(partSize),
428+
)
429+
if err != nil {
430+
return errors.Wrap(err, "GCS downloadMultipart NewDownloader")
431+
}
432+
if err = downloader.DownloadObject(ctx, &transfermanager.DownloadObjectInput{
433+
Bucket: gcs.Config.Bucket,
434+
Object: fullKey,
435+
Destination: writer,
436+
EncryptionKey: encryptionKey,
437+
}); err != nil {
438+
return errors.Wrap(err, "GCS downloadMultipart DownloadObject")
439+
}
440+
_, err = downloader.WaitAndClose()
441+
return err
442+
}
443+
// Do NOT apply encryption for object_disks files - they are not encrypted
444+
// because ClickHouse needs to read them directly without encryption key
445+
encryptionKey := gcs.encryptionKey
446+
if gcs.Config.ObjectDiskPath != "" && strings.HasPrefix(fullKey, gcs.Config.ObjectDiskPath) {
447+
encryptionKey = nil
448+
}
449+
err := attempt(encryptionKey)
450+
if err != nil && encryptionKey != nil && gcs.isNotEncryptedError(err) {
451+
// If the object is not encrypted but we tried to read it with encryption key,
452+
// retry without encryption (for backward compatibility with old backups)
453+
log.Warn().Msgf("gcs.downloadMultipart: object %s not encrypted, retrying without encryption key", fullKey)
454+
err = attempt(nil)
455+
}
456+
return errors.Wrap(err, "GCS downloadMultipart WaitAndClose")
457+
}
458+
380459
func (gcs *GCS) PutFile(ctx context.Context, key string, r io.ReadCloser, localSize int64) error {
381460
return gcs.PutFileAbsolute(ctx, path.Join(gcs.Config.Path, key), r, localSize)
382461
}
383462

384-
func (gcs *GCS) PutFileAbsolute(ctx context.Context, key string, r io.ReadCloser, _ int64) error {
463+
func (gcs *GCS) PutFileAbsolute(ctx context.Context, key string, r io.ReadCloser, localSize int64) error {
464+
if gcs.grpcClient != nil && localSize >= gcs.Config.MultipartUploadMinSize {
465+
return gcs.putFileMultipart(ctx, key, r, localSize)
466+
}
385467
pClientObj, err := gcs.clientPool.BorrowObject(ctx)
386468
if err != nil {
387469
log.Error().Msgf("gcs.PutFile: gcs.clientPool.BorrowObject error: %+v", err)
@@ -425,6 +507,40 @@ func (gcs *GCS) PutFileAbsolute(ctx context.Context, key string, r io.ReadCloser
425507
return nil
426508
}
427509

510+
// putFileMultipart uploads a large file with the experimental parallel composite upload,
511+
// temporary parts go to the `gcs-go-sdk-pu-tmp/` prefix in the bucket root and are composed
512+
// into the final object, see https://github.com/Altinity/clickhouse-backup/issues/1028.
513+
// encryption_key is rejected together with allow_multipart_upload in config validation, so no CSEK handling here.
514+
func (gcs *GCS) putFileMultipart(ctx context.Context, key string, r io.ReadCloser, localSize int64) error {
515+
log.Debug().Msgf("GCS->putFileMultipart %s, size=%d", key, localSize)
516+
obj := gcs.grpcClient.Bucket(gcs.Config.Bucket).Object(key)
517+
writer := obj.NewWriter(ctx)
518+
writer.EnableParallelUpload = true
519+
// PartSize <= 0 falls back to the SDK default 16MiB, values below 5MiB are bumped to 5MiB by the SDK
520+
writer.ParallelUploadConfig = storage.ParallelUploadConfig{
521+
PartSize: gcs.Config.ChunkSize,
522+
MaxConcurrency: gcs.Config.UploadConcurrency,
523+
}
524+
writer.StorageClass = gcs.Config.StorageClass
525+
if len(gcs.Config.ObjectLabels) > 0 {
526+
writer.Metadata = gcs.Config.ObjectLabels
527+
}
528+
uploadBufferSize := gcs.Config.UploadBufferSize
529+
if uploadBufferSize <= 0 {
530+
uploadBufferSize = 128 * 1024
531+
}
532+
buffer := make([]byte, uploadBufferSize)
533+
if _, err := io.CopyBuffer(writer, r, buffer); err != nil {
534+
log.Warn().Msgf("gcs.putFileMultipart: can't copy buffer: %+v", err)
535+
return errors.Wrap(err, "GCS putFileMultipart CopyBuffer")
536+
}
537+
if err := writer.Close(); err != nil {
538+
log.Warn().Msgf("gcs.putFileMultipart: can't close writer: %+v", err)
539+
return errors.Wrap(err, "GCS putFileMultipart writer.Close")
540+
}
541+
return nil
542+
}
543+
428544
func (gcs *GCS) StatFile(ctx context.Context, key string) (RemoteFile, error) {
429545
return gcs.StatFileAbsolute(ctx, path.Join(gcs.Config.Path, key))
430546
}

pkg/storage/general.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -785,7 +785,7 @@ func (bd *BackupDestination) UploadPath(ctx context.Context, baseLocalPath strin
785785
}
786786
retry := retrier.New(retrier.ExponentialBackoff(RetriesOnFailure, common.AddRandomJitter(RetriesDuration, RetriesJitter)), RertierClassifier)
787787
err = retry.RunCtx(ctx, func(ctx context.Context) error {
788-
return bd.PutFile(ctx, path.Join(remotePath, filename), bwlimit.ReadCloser(ctx, f, limiter), 0)
788+
return bd.PutFile(ctx, path.Join(remotePath, filename), bwlimit.ReadCloser(ctx, f, limiter), fInfo.Size())
789789
})
790790
if err != nil {
791791
closeFile()
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
general:
2+
remote_storage: gcs
3+
upload_concurrency: 4
4+
download_concurrency: 4
5+
restore_schema_on_cluster: "{cluster}"
6+
allow_object_disk_streaming: true
7+
s3:
8+
disable_ssl: false
9+
disable_cert_verification: true
10+
clickhouse:
11+
host: clickhouse
12+
port: 9000
13+
restart_command: bash -c 'echo "FAKE RESTART"'
14+
timeout: 10m
15+
gcs:
16+
bucket: altinity-qa-test
17+
path: backup/{cluster}/{shard}/{version}
18+
object_disk_path: object_disks/{cluster}/{shard}/{version}
19+
credentials_file: /etc/clickhouse-backup/credentials.json
20+
compression_format: tar
21+
# experimental parallel composite upload and multipart download, see https://github.com/Altinity/clickhouse-backup/issues/1028
22+
# low thresholds and 5MiB chunk_size so the ~30MB test archive splits into multiple parallel parts
23+
allow_multipart_upload: true
24+
multipart_upload_min_size: 1048576
25+
allow_multipart_download: true
26+
download_concurrency: 4
27+
chunk_size: 5242880

test/integration/containers.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1035,7 +1035,7 @@ func (tc *TestContainers) clickHouseBinds(curDir, configsDir string) []string {
10351035
"config-custom-kopia.yml", "config-custom-restic.yml", "config-custom-rsync.yml",
10361036
"config-database-mapping.yml",
10371037
"config-ftp.yaml", "config-ftp-old.yaml",
1038-
"config-gcs.yml", "config-gcs-custom-endpoint.yml",
1038+
"config-gcs.yml", "config-gcs-custom-endpoint.yml", "config-gcs-parallel.yml",
10391039
"config-s3.yml", "config-s3-embedded.yml", "config-s3-embedded-url.yml",
10401040
"config-s3-embedded-local.yml", "config-s3-nodelete.yml", "config-s3-plain-embedded.yml",
10411041
"config-sftp-auth-key.yaml", "config-sftp-auth-password.yaml",

0 commit comments

Comments
 (0)