fix(seaweedfs): build SeaweedFS in-tree with a bounded S3 read-ahead (#4819)

## What this PR does

Large objects served through a Bucket's S3 endpoint stop tens of
megabytes into the download (cozystack/cozystack#3782). The cause is not
TLS: the SeaweedFS S3 gateway is OOM-killed mid-response. Its read-ahead
for a sequential GET is meant to stay four chunks in front of the
reader, but SeaweedFS counts only newly started chunk downloads toward
that window and re-runs the prefetch after every 256 KiB read, so the
gateway pulls up to about a gigabyte of the object into memory and
exceeds the 512Mi limit the S3 deployment gets from its resources
preset. The client sees `unexpected eof` over HTTP/1.1 and
`INTERNAL_ERROR` over HTTP/2, which is what the CDI importer reported.
Upgrading does not help yet: 4.31, 4.45 and the latest release 4.48 all
carry the defect.

The defect is reported upstream in seaweedfs/seaweedfs#11672, and the
fix with its regression test is merged there in
seaweedfs/seaweedfs#11674, after 4.48. Until a SeaweedFS release
carrying it is vendored, this PR builds SeaweedFS in-tree the way #3343
builds cilium-operator: `images/seaweedfs` downloads the 4.45 source
archive and checks it against a pinned checksum, applies
`patches/reader-cache-prefetch-window.diff`, runs the tests of the
packages the patch touches (including the regression test the patch
adds) once, compiles `weed` with the Go toolchain upstream's own builder
uses for the tag, and lays it over the upstream
`chrislusf/seaweedfs:4.45` image pinned by digest, so everything else in
the image stays the upstream build. `weed version` reports
`cozystack-patched` in the commit field. All SeaweedFS components run
the in-tree image.

`make update` re-pins the Dockerfile from the vendored chart: the
version, the source checksum, the upstream image digest and the builder
toolchain. It fails when the patch no longer applies, which is how the
first release carrying the upstream fix will show that the patch is
ready to drop. Renovate is disabled for that Dockerfile, since it would
move those pins one at a time, and whitespace checks are off for the
patch.

Tests:

- `tests/image_test.yaml` pins every component (master, volume, filer,
S3) to the in-tree image with a digest.
- `make test-image-version` fails when the Dockerfile's version drifts
from the vendored chart's appVersion.
- `make update-image`, which `make update` runs, was checked to be
idempotent on the current chart, to restore deliberately corrupted pins,
and to fail with a clear message when the patch context no longer
matches.
- `hack/build-matrix_test.bats` now expects seaweedfs to be a build unit
next to objectstorage-controller.
- The Go regression test inside the patch fails on unpatched 4.45
(read-ahead reaches 60 chunks past the reader) and passes with the
patch; the image build runs it.

Verified on a test cluster against an 840 MiB object, with every
SeaweedFS component running the image this package builds and the S3
gateway at the production limits (512Mi, 1 CPU). Unpatched 4.31, 4.45
and 4.48 gateways were OOM-killed on every full GET. The patched gateway
served nine full GETs (six sequential over HTTP/1.1 and HTTP/2, three in
parallel) with matching checksums and no restarts; it read from the
volume servers about as many bytes as it sent, its live heap after a GET
was about 40 MiB, and three parallel GETs peaked at about 140 MiB. The
image this PR pins reports `4.45 cozystack-patched` and was rerun
through the same checks.

Drop the patch, and the in-tree build with it, once a SeaweedFS release
carrying the upstream fix is vendored.

### Screenshots

Not a UI change.

### Downstream repositories

- [x] No downstream repository is affected by this change
- [ ] [cozystack/website](https://github.com/cozystack/website) -
follow-up:
- [ ]
[cozystack/terraform-provider-cozystack](https://github.com/cozystack/terraform-provider-cozystack)
- follow-up:
- [ ]
[cozystack/ansible-cozystack](https://github.com/cozystack/ansible-cozystack)
- follow-up:
- [ ] [cozystack/ccp](https://github.com/cozystack/ccp) - follow-up:
- [ ] [cozystack/talm](https://github.com/cozystack/talm) - follow-up:
- [ ] [cozystack/cozyhr](https://github.com/cozystack/cozyhr) -
follow-up:
- [ ] [cozystack/cozy-proxy](https://github.com/cozystack/cozy-proxy) -
follow-up:
- [ ]
[cozystack/cozystack-telemetry-server](https://github.com/cozystack/cozystack-telemetry-server)
- follow-up:
- [ ]
[cozystack/external-apps-example](https://github.com/cozystack/external-apps-example)
- follow-up:
- [ ] [cozystack/examples](https://github.com/cozystack/examples) -
follow-up:
- [ ] [cozystack/community](https://github.com/cozystack/community) -
follow-up:

### Release note

```release-note
fix(seaweedfs): stop the S3 gateway from being OOM-killed while serving large objects, which cut downloads (including CDI imports from a Bucket URL) off tens of megabytes in. SeaweedFS is now built in-tree with a patch that bounds the gateway's read-ahead.
```


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **New Features**
* SeaweedFS now runs a Cozystack-built image based on version 4.45,
pinned to a specific image digest.

* **Bug Fixes**
* Sequential small reads now keep read-ahead within the configured
prefetch window, preventing it from extending unexpectedly.

* **Build & Maintenance**
* SeaweedFS image updates are aligned with the chart version and verify
the source archive, builder image, and patches before building.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Aleksei Sviridkin authored and GitHub committed 2026-10-13 20:08:11 +03:00
commit 588d8292e7
9 files changed
+281 -2

No files matched your search

+1
View File
@@ -1,3 +1,4 @@
# Unified diff context has a marker before Go tabs and blank lines.
packages/system/capi-providers-infraprovider/images/cluster-api-provider-kubevirt/patches/*.diff -whitespace
packages/system/cilium/images/cilium-operator/patches/*.diff -whitespace
packages/system/seaweedfs/images/seaweedfs/patches/*.diff -whitespace
+6
View File
@@ -100,6 +100,12 @@
"matchUpdateTypes": ["major", "minor"],
"enabled": false
},
{
"description": "The seaweedfs rebuild compiles one upstream release with the Go toolchain upstream's docker/Dockerfile.go_build uses at that tag, lays it over the upstream image of that tag, and `make update` in packages/system/seaweedfs re-pins the builder, the base image digest and the source checksum from there. A Renovate bump of any of them builds a combination upstream never shipped, and the next `make update` silently reverts it. Updates are disabled here; all of them move with the SeaweedFS version.",
"matchManagers": ["dockerfile"],
"matchFileNames": ["packages/system/seaweedfs/images/seaweedfs/**"],
"enabled": false
},
{
"description": "kubevirt.io/csi-driver is pinned to an untagged pseudo-version commit and publishes no release tags, so Renovate's go datasource returns no-result and the lookup failure lingers on the Dependency Dashboard. Disable it; this module is advanced deliberately alongside the kubevirt-csi-driver image build.",
"matchManagers": ["gomod"],
+1
View File
@@ -37,6 +37,7 @@ build: build-deps
make -C packages/system/capi-providers-infraprovider image
make -C packages/system/multus image
make -C packages/system/bucket image
make -C packages/system/seaweedfs image
make -C packages/system/objectstorage-controller image
make -C packages/system/securitygroup-controller image
make -C packages/system/grafana-operator image
+2 -2
View File
@@ -110,7 +110,7 @@ load test_helper
tmp=$(mktemp)
echo "packages/system/seaweedfs/values.yaml" > "$tmp"
out=$(hack/build-matrix.sh "$tmp")
[ "$out" = '["packages/system/objectstorage-controller"]' ]
[ "$out" = '["packages/system/seaweedfs","packages/system/objectstorage-controller"]' ]
rm -f "$tmp"
}
@@ -118,7 +118,7 @@ load test_helper
tmp=$(mktemp)
printf 'packages/system/seaweedfs/values.yaml\npackages/system/objectstorage-controller/values.yaml\n' > "$tmp"
out=$(hack/build-matrix.sh "$tmp")
[ "$out" = '["packages/system/objectstorage-controller"]' ]
[ "$out" = '["packages/system/seaweedfs","packages/system/objectstorage-controller"]' ]
rm -f "$tmp"
}
+55
View File
@@ -1,11 +1,36 @@
export NAME=seaweedfs-system
SEAWEEDFS_TAG=$(shell grep -F 'ARG VERSION=' images/seaweedfs/Dockerfile | cut -f2 -d=)
include ../../../hack/common-envs.mk
include ../../../hack/package.mk
.PHONY: image
image:
docker buildx build images/seaweedfs \
$(call image-tags,seaweedfs,$(SEAWEEDFS_TAG)) \
$(call cache-args,seaweedfs) \
--metadata-file images/seaweedfs.json \
$(BUILDX_ARGS)
REPOSITORY="$(REGISTRY)/seaweedfs" \
yq -i '.seaweedfs.image.repository = strenv(REPOSITORY)' values.yaml
TAG=$(IMAGE_TAG)@$$(yq e '."containerimage.digest"' images/seaweedfs.json -o json -r) \
yq -i '.seaweedfs.image.tag = strenv(TAG)' values.yaml
rm -f images/seaweedfs.json
test:
helm unittest .
$(MAKE) test-guard-fail-closed
$(MAKE) test-image-version
# The image replaces weed in the upstream image of ARG VERSION, so a chart moved
# by `make update` without the Dockerfile would run the previous release's
# binary under the new chart.
.PHONY: test-image-version
test-image-version:
@chart=$$(yq e '.appVersion' charts/seaweedfs/Chart.yaml); \
[ "$(SEAWEEDFS_TAG)" = "$$chart" ] \
|| { echo "FAIL: images/seaweedfs/Dockerfile builds $(SEAWEEDFS_TAG), the vendored chart is $$chart"; exit 1; }
@echo "ok: images/seaweedfs builds the chart's appVersion $(SEAWEEDFS_TAG)"
# The naming guard refuses to render an UPGRADE that cannot see the cluster
# (templates/naming-guard.yaml): a client-side lookup silently returns nothing,
@@ -37,4 +62,34 @@ update:
patch --no-backup-if-mismatch -p4 < patches/s3-service-name-consumers.patch
patch --no-backup-if-mismatch -p4 < patches/cluster-scoped-names-per-namespace.patch
#patch --no-backup-if-mismatch -p4 < patches/retention-policy-delete.yaml
$(MAKE) update-image
# Re-pins images/seaweedfs/Dockerfile to the vendored chart's appVersion: the
# source archive checksum, the upstream image digest and upstream's builder
# toolchain for that tag. The patches are Go, so they are checked against the
# whole source tree of the tag; once a release carries a fix, its patch stops
# applying here and is dropped.
.PHONY: update-image
update-image:
version=$$(yq e '.appVersion' charts/seaweedfs/Chart.yaml) && \
upstream=$$(docker buildx imagetools inspect "chrislusf/seaweedfs:$${version}" --format '{{json .Manifest}}' | yq e '.digest' -) && \
case "$$upstream" in sha256:*) ;; *) echo "ERROR: no digest for chrislusf/seaweedfs:$${version}" >&2; exit 1;; esac && \
$(SED_INPLACE) -e "s/^ARG VERSION=.*/ARG VERSION=$${version}/" -e "s/^ARG UPSTREAM_DIGEST=.*/ARG UPSTREAM_DIGEST=$${upstream}/" images/seaweedfs/Dockerfile && \
tmpdir=$$(mktemp -d) && \
{ curl --fail --silent --show-error --location -o "$$tmpdir/source.tar.gz" \
"https://github.com/seaweedfs/seaweedfs/archive/refs/tags/$${version}.tar.gz" && \
mkdir "$$tmpdir/src" && tar -xzf "$$tmpdir/source.tar.gz" --strip-components=1 -C "$$tmpdir/src" || { rm -rf "$$tmpdir"; exit 1; }; } && \
source=$$(sha256sum "$$tmpdir/source.tar.gz" | cut -d' ' -f1) && \
$(SED_INPLACE) "s/^ARG SOURCE_SHA256=.*/ARG SOURCE_SHA256=$${source}/" images/seaweedfs/Dockerfile && \
{ git -C "$$tmpdir/src" apply --check $(CURDIR)/images/seaweedfs/patches/*.diff || \
{ echo "ERROR: images/seaweedfs/patches/*.diff no longer apply to seaweedfs $${version}; regenerate them, or drop the ones the new tag already carries" >&2; \
rm -rf "$$tmpdir"; exit 1; }; } && \
golang=$$(sed -n 's/^FROM --platform=\$$BUILDPLATFORM \([^ ]*\) AS builder$$/\1/p' "$$tmpdir/src/docker/Dockerfile.go_build") && \
{ [ -n "$$golang" ] || { echo "ERROR: no builder FROM line in upstream docker/Dockerfile.go_build $${version}" >&2; rm -rf "$$tmpdir"; exit 1; }; } && \
builder=$$(docker buildx imagetools inspect "$$golang" --format '{{json .Manifest}}' | yq e '.digest' -) && \
{ case "$$builder" in sha256:*) ;; *) echo "ERROR: no digest for $${golang}" >&2; rm -rf "$$tmpdir"; exit 1;; esac; } && \
$(SED_INPLACE) "s|^FROM --platform=\$$BUILDPLATFORM [^ ]* AS builder$$|FROM --platform=\$$BUILDPLATFORM $${golang}@$${builder} AS builder|" images/seaweedfs/Dockerfile && \
{ grep -qF "FROM --platform=\$$BUILDPLATFORM $${golang}@$${builder} AS builder" images/seaweedfs/Dockerfile || \
{ echo "ERROR: the builder FROM line in images/seaweedfs/Dockerfile was not re-pinned; its shape no longer matches the update recipe" >&2; rm -rf "$$tmpdir"; exit 1; }; } && \
rm -rf "$$tmpdir"
@@ -0,0 +1,68 @@
# Rebuild the weed binary from the upstream sources with the Cozystack patches
# applied, and lay it over the upstream image of the same tag. Only the Go
# binary changes: the Rust volume server and worker, the entrypoint and the
# runtime packages are the upstream image's own, byte for byte.
#
# patches/reader-cache-prefetch-window.diff bounds the read-ahead of a
# sequential chunk reader to the prefetch count it was given. Upstream counts
# only the downloads it newly starts toward that window and runs the prefetch
# after every ReadAt, so the S3 GET path, reading 256 KiB at a time out of
# 4 MiB chunks, pushes the window four chunks further on each read until the
# ReaderCache limit of 256 downloaders is reached. The gateway then holds up to
# about a gigabyte of a single object in memory, is OOM-killed under the 512Mi
# limit the S3 deployment gets from its resources preset, and the client sees
# the response cut off tens of megabytes in:
# https://github.com/cozystack/cozystack/issues/3782. Upstream fixed it in
# seaweedfs/seaweedfs#11674 after 4.48; drop the patch on the first vendored
# release that carries it.
#
# `make update` re-pins VERSION from the vendored chart, SOURCE_SHA256 from the
# source archive of that tag, UPSTREAM_DIGEST from the upstream image of that
# tag and the builder from upstream's docker/Dockerfile.go_build, and checks
# that the patches still apply.
ARG VERSION=4.45
ARG SOURCE_SHA256=800a91693e2a4e974ef0e2a157b6a04eff9f4df507303b1cff31b0601081fc30
ARG UPSTREAM_DIGEST=sha256:fc9f76fa993ad69966ffeb2f65d0318fcae39c6f8e20cf68ef7b3a5cb97769e5
# The Go toolchain of upstream's builder (docker/Dockerfile.go_build), which
# compiles the upstream weed.
FROM --platform=$BUILDPLATFORM golang:1.26-alpine@sha256:3082400e369fa24d5fc60bca20edab3f6d604e0c5a690ec66b295eff4dd87ade AS builder
ARG GOPROXY
ARG VERSION
ARG SOURCE_SHA256
RUN apk add --no-cache git curl
WORKDIR /go/src/github.com/seaweedfs/seaweedfs
RUN curl -fsSL -o /tmp/source.tar.gz https://github.com/seaweedfs/seaweedfs/archive/refs/tags/${VERSION}.tar.gz && \
echo "${SOURCE_SHA256} /tmp/source.tar.gz" | sha256sum -c - && \
tar -xzf /tmp/source.tar.gz --strip-components=1 && rm /tmp/source.tar.gz
COPY patches /patches
RUN git apply /patches/*.diff
ENV CGO_ENABLED=0
# go build never compiles a _test.go, so nothing else runs the tests the
# patches carry. The list is read into a variable first: a for loop drops the
# exit status of a command substitution, so a patch git cannot read would leave
# the list empty and the step green.
RUN files=$(git apply --numstat /patches/*.diff) || exit 1; \
for dir in $(printf '%s\n' "$files" | cut -f3 | grep '\.go$' | xargs -r -n1 dirname | sort -u); do \
go test "./$dir" || exit 1; \
done
# Declared after the tests so they run once on the build platform rather than
# once per target platform.
ARG TARGETOS
ARG TARGETARCH
# The source archive carries no git history for upstream's `git rev-parse`, so
# the commit field of `weed version` names this build instead.
RUN cd weed && GOOS=$TARGETOS GOARCH=$TARGETARCH go build \
-ldflags "-X github.com/seaweedfs/seaweedfs/weed/util/version.COMMIT=cozystack-patched" \
-o /out/weed .
FROM chrislusf/seaweedfs:${VERSION}@${UPSTREAM_DIGEST}
COPY --from=builder /out/weed /usr/bin/weed
@@ -0,0 +1,102 @@
diff --git a/weed/filer/reader_cache.go b/weed/filer/reader_cache.go
--- a/weed/filer/reader_cache.go
+++ b/weed/filer/reader_cache.go
@@ -75,8 +75,10 @@
return
}
- cached := 0
- for x := chunkViews; x != nil && cached < count; x = x.Next {
+ // The window is the next count chunks, whether or not a download for them
+ // is already running: counting only newly started downloads would push the
+ // window count chunks further on every call.
+ for x, i := chunkViews, 0; x != nil && i < count; x, i = x.Next, i+1 {
chunkView := x.Value
if _, found := rc.downloaders[chunkView.FileId]; found {
continue
@@ -98,7 +100,6 @@
go cacher.startCaching()
<-cacher.cacheStartedCh
rc.downloaders[chunkView.FileId] = cacher
- cached++
}
return
diff --git a/weed/filer/reader_at_prefetch_window_test.go b/weed/filer/reader_at_prefetch_window_test.go
new file mode 100644
--- /dev/null
+++ b/weed/filer/reader_at_prefetch_window_test.go
@@ -0,0 +1,73 @@
+package filer
+
+import (
+ "context"
+ "fmt"
+ "strconv"
+ "strings"
+ "sync/atomic"
+ "testing"
+
+ "github.com/seaweedfs/seaweedfs/weed/util/chunk_cache"
+ util_http "github.com/seaweedfs/seaweedfs/weed/util/http"
+)
+
+// A sequential reader that consumes a chunk in several small ReadAt calls, as
+// the S3 GET path does with its 256 KiB copy buffer over 4 MiB chunks, must
+// keep its read-ahead at prefetchCount chunks. Each ReadAt triggers a
+// prefetch; if chunks already being fetched are not counted toward the
+// window, every call starts prefetchCount more downloads and the read-ahead
+// runs to the ReaderCache downloader limit, buffering most of the object.
+func TestChunkReadAtSequentialPrefetchStaysWithinWindow(t *testing.T) {
+ const chunkSize = 64 << 10
+ const chunkCount = 64
+ const sliceSize = 16 << 10
+
+ var readerChunk atomic.Int64
+ var maxLead atomic.Int64
+ rc := NewReaderCache(256, (*chunk_cache.TieredChunkCache)(nil), func(context.Context, string) ([]string, error) {
+ return []string{"unused"}, nil
+ }, nil)
+ defer rc.destroy()
+ rc.fetchChunkDataFn = func(_ context.Context, buffer []byte, _ []string, _ []byte, _ bool, _ bool, _ int64, fileId string, _ util_http.RefreshUrlsFunc) (int, error) {
+ idx, err := strconv.Atoi(strings.TrimPrefix(fileId, "chunk"))
+ if err != nil {
+ return 0, err
+ }
+ lead := int64(idx) - readerChunk.Load()
+ for {
+ cur := maxLead.Load()
+ if lead <= cur || maxLead.CompareAndSwap(cur, lead) {
+ break
+ }
+ }
+ return len(buffer), nil
+ }
+
+ views := NewIntervalList[*ChunkView]()
+ for i := 0; i < chunkCount; i++ {
+ views.AppendInterval(&Interval[*ChunkView]{
+ StartOffset: int64(i * chunkSize),
+ StopOffset: int64((i + 1) * chunkSize),
+ Value: &ChunkView{
+ FileId: fmt.Sprintf("chunk%d", i),
+ ViewSize: chunkSize,
+ ViewOffset: int64(i * chunkSize),
+ ChunkSize: chunkSize,
+ },
+ })
+ }
+ reader := NewChunkReaderAtFromClient(context.Background(), rc, views, chunkSize*chunkCount, DefaultPrefetchCount)
+
+ buf := make([]byte, sliceSize)
+ for off := int64(0); off < chunkSize*chunkCount; off += sliceSize {
+ readerChunk.Store(off / chunkSize)
+ if _, err := reader.ReadAt(buf, off); err != nil && off+sliceSize < chunkSize*chunkCount {
+ t.Fatalf("ReadAt(%d): %v", off, err)
+ }
+ }
+
+ if got := maxLead.Load(); got > DefaultPrefetchCount {
+ t.Fatalf("read-ahead reached %d chunks past the reader, want at most %d", got, DefaultPrefetchCount)
+ }
+}
@@ -0,0 +1,39 @@
suite: seaweedfs image
# Every SeaweedFS component must run the in-tree build from images/seaweedfs,
# which carries the patches in images/seaweedfs/patches. The vendored chart
# falls back to chrislusf/seaweedfs at its appVersion when the image keys are
# unset, and that is the unpatched upstream binary. The reference must carry a
# digest, as every first-party reference does (docs/agents/image-refs.md).
templates:
- charts/seaweedfs/templates/master/master-statefulset.yaml
- charts/seaweedfs/templates/volume/volume-statefulset.yaml
- charts/seaweedfs/templates/filer/filer-statefulset.yaml
- charts/seaweedfs/templates/s3/s3-deployment.yaml
# filer-statefulset includes it for its checksum annotation.
- charts/seaweedfs/templates/s3/s3-secret.yaml
release:
name: seaweedfs
namespace: cozy-seaweedfs
tests:
- it: runs the in-tree image in the master
template: charts/seaweedfs/templates/master/master-statefulset.yaml
asserts: &inTreeImage
- matchRegex:
path: spec.template.spec.containers[0].image
pattern: ^ghcr\.io/cozystack/cozystack/seaweedfs:[^@]+@sha256:[0-9a-f]{64}$
- it: runs the in-tree image in the volume servers
template: charts/seaweedfs/templates/volume/volume-statefulset.yaml
asserts: *inTreeImage
- it: runs the in-tree image in the filer
template: charts/seaweedfs/templates/filer/filer-statefulset.yaml
asserts: *inTreeImage
- it: runs the in-tree image in the S3 gateway
template: charts/seaweedfs/templates/s3/s3-deployment.yaml
asserts: *inTreeImage
+7
View File
@@ -25,6 +25,13 @@ seaweedfs:
# chart directly from its ExternalArtifact without re-rendering extra/seaweedfs.
# The guard also refuses to render at all if this pin is ever lifted.
fullnameOverride: seaweedfs
# Built in-tree from images/seaweedfs, which carries the patches in
# images/seaweedfs/patches. Without this block the vendored chart falls back
# to chrislusf/seaweedfs at its appVersion, the unpatched upstream build.
# `make image` rewrites both keys on every build.
image:
repository: "ghcr.io/cozystack/cozystack/seaweedfs"
tag: "4.45@sha256:2a063deb9992ac0c06f8c09589ae063a30ac8c87af2c6aa1f4d4420cc544535c"
master:
# At the shipped replicationFactor of 2 the size limit and
# extraEnvironmentVars.WEED_MASTER_VOLUME_GROWTH_COPY_2 below are one