Commit 9854108a6b

9854108a6b3392713659e6a7ec1b3c43e6cbd851

parent: a9b9f7c357

Verified · cmc

cmc <hello@cleberg.net> · 2026-09-26 18:52 UTC

Respect container CPU quota, cap ImageMagick memory, run as non-root

- Size the resize semaphore with runtime.GOMAXPROCS(0).
- magick runs with -limit memory 512MiB -limit map 1GiB.
- The image runs as gallery (UID/GID 1000).

Closes #5

Layout: unified · split

Dockerfile +2
@@ -8,6 +8,8 @@ RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /gallery ./cmd/gallery
88FROM alpine:3
99RUN apk add --no-cache imagemagick imagemagick-jpeg imagemagick-heic imagemagick-webp exiftool
1010COPY --from=build /gallery /usr/local/bin/gallery
11RUN addgroup -g 1000 gallery && adduser -D -H -u 1000 -G gallery gallery
12USER gallery
1113ENV GALLERY_PHOTOS=/photos GALLERY_CACHE=/cache GALLERY_ADDR=:8080
1214EXPOSE 8080
1315ENTRYPOINT ["gallery"]
README.org +2
@@ -61,3 +61,5 @@ GALLERY_PHOTOS_DIR=/path/to/photos docker compose up -d --build
6161
6262The container listens on =127.0.0.1:8003=; put a reverse proxy in front of it.
6363Photos are mounted read-only; derivatives and =meta.json= live in =./cache=.
64The container runs as UID/GID 1000, so =./cache= must be writable by that user
65and the photos readable by it.
cmd/gallery/main.go +1 −1
@@ -71,7 +71,7 @@ func main() {
7171 if err != nil {
7272 log.Fatal(err)
7373 }
74 rend := render.New(*cache, runtime.NumCPU())
74 rend := render.New(*cache, runtime.GOMAXPROCS(0))
7575 store := &library.Store{}
7676 var scanMu sync.Mutex
7777 rescan := func() error {
docs/specs/2026-09-25-gallery-design.org +2 −2
@@ -86,7 +86,7 @@ type Format interface {
8686- Both share one ImageMagick-backed implementation parameterised by extensions:
8787 - =Metadata=: =exiftool -json -n= with the fields above. Width and height are
8888 swapped when the EXIF orientation is 5-8.
89 - =Resize=: =magick <src> -auto-orient -resize <w>x -strip -quality 82 jpg:-=
89 - =Resize=: =magick -limit memory 512MiB -limit map 1GiB <src> -auto-orient -resize <w>x -strip -quality 82 jpg:-=
9090 streamed to =dst=. Output is always JPEG in v1.
9191- Adding a format (HEIC, AVIF, WebP, RAW) is a new registration. Adding video
9292 is a new =Kind= plus a template partial. Neither touches =library= or
@@ -132,7 +132,7 @@ type Format interface {
132132- Concurrent requests for the same key are collapsed with =singleflight=.
133133- Output is written to a temp file in the target directory and renamed into
134134 place, so a failed or interrupted resize never leaves a partial file.
135- A process-wide semaphore caps concurrent =magick= runs at =runtime.NumCPU()=.
135- A process-wide semaphore caps concurrent =magick= runs at =runtime.GOMAXPROCS(0)=.
136136- Responses carry =Cache-Control: public, max-age=31536000, immutable=; the URL
137137 changes when the source does because pages link with a =?v=<mtime>= query.
138138
internal/format/magick.go +11 −3
@@ -40,9 +40,7 @@ func (m *Magick) Metadata(path string) (Meta, error) {
4040}
4141
4242func (m *Magick) Resize(src string, width int, dst io.Writer) error {
43 cmd := exec.Command("magick", src,
44 "-auto-orient", "-resize", strconv.Itoa(width)+"x",
45 "-strip", "-quality", "82", "jpg:-")
43 cmd := exec.Command("magick", resizeArgs(src, width)...)
4644 var stderr bytes.Buffer
4745 cmd.Stdout = dst
4846 cmd.Stderr = &stderr
@@ -52,6 +50,16 @@ func (m *Magick) Resize(src string, width int, dst io.Writer) error {
5250 return nil
5351}
5452
53// resizeArgs caps ImageMagick's pixel cache so several concurrent resizes of
54// large sources cannot exhaust memory.
55func resizeArgs(src string, width int) []string {
56 return []string{
57 "-limit", "memory", "512MiB", "-limit", "map", "1GiB",
58 src, "-auto-orient", "-resize", strconv.Itoa(width) + "x",
59 "-strip", "-quality", "82", "jpg:-",
60 }
61}
62
5563// Default returns the formats supported in v1.
5664func Default() *Registry {
5765 r := &Registry{}
internal/format/magick_test.go +9
@@ -6,6 +6,7 @@ import (
66 "image/jpeg"
77 "os/exec"
88 "path/filepath"
9 "strings"
910 "testing"
1011 "time"
1112)
@@ -150,3 +151,11 @@ func TestMagickMetadataRejectsNewlineInPath(t *testing.T) {
150151 t.Fatal("want error")
151152 }
152153}
154
155func TestResizeArgsLimitResources(t *testing.T) {
156 got := strings.Join(resizeArgs("/p/a.jpg", 960), " ")
157 want := "-limit memory 512MiB -limit map 1GiB /p/a.jpg -auto-orient -resize 960x -strip -quality 82 jpg:-"
158 if got != want {
159 t.Fatalf("got %s\nwant %s", got, want)
160 }
161}