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
8FROM alpine:3 8FROM alpine:3
9RUN apk add --no-cache imagemagick imagemagick-jpeg imagemagick-heic imagemagick-webp exiftool 9RUN apk add --no-cache imagemagick imagemagick-jpeg imagemagick-heic imagemagick-webp exiftool
10COPY --from=build /gallery /usr/local/bin/gallery 10COPY --from=build /gallery /usr/local/bin/gallery
11RUN addgroup -g 1000 gallery && adduser -D -H -u 1000 -G gallery gallery
12USER gallery
11ENV GALLERY_PHOTOS=/photos GALLERY_CACHE=/cache GALLERY_ADDR=:8080 13ENV GALLERY_PHOTOS=/photos GALLERY_CACHE=/cache GALLERY_ADDR=:8080
12EXPOSE 8080 14EXPOSE 8080
13ENTRYPOINT ["gallery"] 15ENTRYPOINT ["gallery"]
README.org +2
@@ -61,3 +61,5 @@ GALLERY_PHOTOS_DIR=/path/to/photos docker compose up -d --build
61 61
62The container listens on =127.0.0.1:8003=; put a reverse proxy in front of it. 62The container listens on =127.0.0.1:8003=; put a reverse proxy in front of it.
63Photos are mounted read-only; derivatives and =meta.json= live in =./cache=. 63Photos 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() {
71 if err != nil { 71 if err != nil {
72 log.Fatal(err) 72 log.Fatal(err)
73 } 73 }
74 rend := render.New(*cache, runtime.NumCPU()) 74 rend := render.New(*cache, runtime.GOMAXPROCS(0))
75 store := &library.Store{} 75 store := &library.Store{}
76 var scanMu sync.Mutex 76 var scanMu sync.Mutex
77 rescan := func() error { 77 rescan := func() error {
docs/specs/2026-09-25-gallery-design.org +2 −2
@@ -86,7 +86,7 @@ type Format interface {
86- Both share one ImageMagick-backed implementation parameterised by extensions: 86- Both share one ImageMagick-backed implementation parameterised by extensions:
87 - =Metadata=: =exiftool -json -n= with the fields above. Width and height are 87 - =Metadata=: =exiftool -json -n= with the fields above. Width and height are
88 swapped when the EXIF orientation is 5-8. 88 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:-=
90 streamed to =dst=. Output is always JPEG in v1. 90 streamed to =dst=. Output is always JPEG in v1.
91- Adding a format (HEIC, AVIF, WebP, RAW) is a new registration. Adding video 91- Adding a format (HEIC, AVIF, WebP, RAW) is a new registration. Adding video
92 is a new =Kind= plus a template partial. Neither touches =library= or 92 is a new =Kind= plus a template partial. Neither touches =library= or
@@ -132,7 +132,7 @@ type Format interface {
132- Concurrent requests for the same key are collapsed with =singleflight=. 132- Concurrent requests for the same key are collapsed with =singleflight=.
133- Output is written to a temp file in the target directory and renamed into 133- Output is written to a temp file in the target directory and renamed into
134 place, so a failed or interrupted resize never leaves a partial file. 134 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)=.
136- Responses carry =Cache-Control: public, max-age=31536000, immutable=; the URL 136- Responses carry =Cache-Control: public, max-age=31536000, immutable=; the URL
137 changes when the source does because pages link with a =?v=<mtime>= query. 137 changes when the source does because pages link with a =?v=<mtime>= query.
138 138
internal/format/magick.go +11 −3
@@ -40,9 +40,7 @@ func (m *Magick) Metadata(path string) (Meta, error) {
40} 40}
41 41
42func (m *Magick) Resize(src string, width int, dst io.Writer) error { 42func (m *Magick) Resize(src string, width int, dst io.Writer) error {
43 cmd := exec.Command("magick", src, 43 cmd := exec.Command("magick", resizeArgs(src, width)...)
44 "-auto-orient", "-resize", strconv.Itoa(width)+"x",
45 "-strip", "-quality", "82", "jpg:-")
46 var stderr bytes.Buffer 44 var stderr bytes.Buffer
47 cmd.Stdout = dst 45 cmd.Stdout = dst
48 cmd.Stderr = &stderr 46 cmd.Stderr = &stderr
@@ -52,6 +50,16 @@ func (m *Magick) Resize(src string, width int, dst io.Writer) error {
52 return nil 50 return nil
53} 51}
54 52
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
55// Default returns the formats supported in v1. 63// Default returns the formats supported in v1.
56func Default() *Registry { 64func Default() *Registry {
57 r := &Registry{} 65 r := &Registry{}
internal/format/magick_test.go +9
@@ -6,6 +6,7 @@ import (
6 "image/jpeg" 6 "image/jpeg"
7 "os/exec" 7 "os/exec"
8 "path/filepath" 8 "path/filepath"
9 "strings"
9 "testing" 10 "testing"
10 "time" 11 "time"
11) 12)
@@ -150,3 +151,11 @@ func TestMagickMetadataRejectsNewlineInPath(t *testing.T) {
150 t.Fatal("want error") 151 t.Fatal("want error")
151 } 152 }
152} 153}
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}