Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 7 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,10 @@ jobs:
- windows

name: build (${{ matrix.os }}/go-${{ matrix.go-version }})
runs-on: ${{ matrix.os }}-latest
# Pin macOS to macos-15: macos-latest moved to macos-26 in June 2026,
# whose linker/dyld rejects the older Go toolchains' test binaries
# (missing LC_UUID load command). macos-15 is the last image these pass on.
runs-on: ${{ matrix.os == 'macos' && 'macos-15' || format('{0}-latest', matrix.os) }}
steps:
- uses: actions/checkout@v2
- uses: actions/setup-go@v2
Expand All @@ -42,10 +45,11 @@ jobs:
- windows

name: test (${{ matrix.os }}/go-${{ matrix.go-version }})
runs-on: ${{ matrix.os }}-latest
# See build job: pin macOS to macos-15 (macos-latest is macos-26).
runs-on: ${{ matrix.os == 'macos' && 'macos-15' || format('{0}-latest', matrix.os) }}
services:
minio:
image: ${{ (matrix.os == 'ubuntu') && 'bitnami/minio:2023.7.18' || ''}}
image: ${{ (matrix.os == 'ubuntu') && 'bitnamilegacy/minio:2023.7.18' || ''}}
ports:
- 45677:9000
options: >-
Expand Down
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -566,6 +566,20 @@ all variants will return your GCS buckets.
acceleration and GCS. If a custom endpoint is provided, it'll fallback to
path-style.

### Multiple endpoints (load balancing)

For high-throughput workloads, `s5cmd` can distribute requests across multiple S3-compatible endpoints using round-robin load balancing.

s5cmd --endpoint-urls "http://10.26.16.129:17410,http://10.26.16.130:17410" cp 's3://bucket/*' .

or using the environment variable:

S3_ENDPOINT_URLS="http://10.26.16.129:17410,http://10.26.16.130:17410" s5cmd cp 's3://bucket/*' .

Each request is dispatched to the next endpoint in round-robin order across all workers.

> **Note:** `--endpoint-url` and `--endpoint-urls` cannot be used together.

### Retry logic

`s5cmd` uses an exponential backoff retry mechanism for transient or potential
Expand Down
26 changes: 26 additions & 0 deletions command/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,11 @@ var app = &cli.App{
Usage: "override default S3 host for custom services",
EnvVars: []string{"S3_ENDPOINT_URL"},
},
&cli.StringSliceFlag{
Name: "endpoint-urls",
Usage: "multiple S3 endpoints for load balancing (comma-separated or multiple flags)",
EnvVars: []string{"S3_ENDPOINT_URLS"},
},
&cli.BoolFlag{
Name: "no-verify-ssl",
Usage: "disable SSL certificate verification",
Expand Down Expand Up @@ -98,6 +103,7 @@ var app = &cli.App{
logLevel := c.String("log")
isStat := c.Bool("stat")
endpointURL := c.String("endpoint-url")
endpointURLs := c.StringSlice("endpoint-urls")

log.Init(logLevel, printJSON)
parallel.Init(workerCount)
Expand All @@ -118,10 +124,18 @@ var app = &cli.App{
return err
}

// Validate endpoint-url and endpoint-urls are not used together
if endpointURL != "" && len(endpointURLs) > 0 {
err := fmt.Errorf(`"endpoint-url" and "endpoint-urls" flags cannot be used together`)
printError(commandFromContext(c), c.Command.Name, err)
return err
}

if isStat {
stat.InitStat()
}

// Validate single endpoint URL
if endpointURL != "" {
if !strings.HasPrefix(endpointURL, "http") {
err := fmt.Errorf(`bad value for --endpoint-url %v: scheme is missing. Must be of the form http://<hostname>/ or https://<hostname>/`, endpointURL)
Expand All @@ -130,6 +144,17 @@ var app = &cli.App{
}
}

// Validate multiple endpoint URLs
if len(endpointURLs) > 0 {
for _, ep := range endpointURLs {
if !strings.HasPrefix(ep, "http") {
err := fmt.Errorf(`bad value for --endpoint-urls %v: scheme is missing. Must be of the form http://<hostname>/ or https://<hostname>/`, ep)
printError(commandFromContext(c), c.Command.Name, err)
return err
}
}
}

return nil
},
CommandNotFound: func(c *cli.Context, command string) {
Expand Down Expand Up @@ -181,6 +206,7 @@ func NewStorageOpts(c *cli.Context) storage.Options {
return storage.Options{
DryRun: c.Bool("dry-run"),
Endpoint: c.String("endpoint-url"),
Endpoints: c.StringSlice("endpoint-urls"),
MaxRetries: c.Int("retry-count"),
NoSignRequest: c.Bool("no-sign-request"),
NoVerifySSL: c.Bool("no-verify-ssl"),
Expand Down
57 changes: 57 additions & 0 deletions e2e/app_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -312,3 +312,60 @@ func TestAppEndpointShouldHaveScheme(t *testing.T) {
})
}
}

func TestAppEndpointURLsValidation(t *testing.T) {
t.Parallel()

testcases := []struct {
name string
flags []string
expectedError error
expectedExitCode int
}{
{
name: "valid_endpoint_urls",
flags: []string{"--endpoint-urls", "http://s3-1:9000,http://s3-2:9000"},
expectedExitCode: 0,
},
{
name: "endpoint_urls_env_var_valid",
flags: []string{"--endpoint-urls", "https://minio.example.com"},
expectedExitCode: 0,
},
{
name: "endpoint_urls_missing_scheme",
flags: []string{"--endpoint-urls", "s3-1:9000"},
expectedError: fmt.Errorf(`ERROR bad value for --endpoint-urls s3-1:9000: scheme is missing. Must be of the form http://<hostname>/ or https://<hostname>/`),
expectedExitCode: 1,
},
{
name: "endpoint_url_and_endpoint_urls_conflict",
flags: []string{"--endpoint-url", "http://s3-1:9000", "--endpoint-urls", "http://s3-2:9000"},
expectedError: fmt.Errorf(`ERROR "endpoint-url" and "endpoint-urls" flags cannot be used together`),
expectedExitCode: 1,
},
}

for _, tc := range testcases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()

// Invoke the binary directly instead of the setup() helper,
// which injects "--endpoint-url" and would collide with the
// "--endpoint-urls" flag under test.
cmd := icmd.Command(s5cmdPath, tc.flags...)
result := icmd.RunCmd(cmd)

result.Assert(t, icmd.Expected{ExitCode: tc.expectedExitCode})

if tc.expectedError == nil && result.Stderr() == "" {
return
}

assertLines(t, result.Stderr(), map[int]compareFunc{
0: equals("%v", tc.expectedError),
})
})
}
}
Loading
Loading