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 |
| 8 | FROM alpine:3 |
8 | FROM alpine:3 |
| 9 | RUN apk add --no-cache imagemagick imagemagick-jpeg imagemagick-heic imagemagick-webp exiftool |
9 | RUN apk add --no-cache imagemagick imagemagick-jpeg imagemagick-heic imagemagick-webp exiftool |
| 10 | COPY --from=build /gallery /usr/local/bin/gallery |
10 | COPY --from=build /gallery /usr/local/bin/gallery |
| |
11 | RUN addgroup -g 1000 gallery && adduser -D -H -u 1000 -G gallery gallery |
| |
12 | USER gallery |
| 11 | ENV GALLERY_PHOTOS=/photos GALLERY_CACHE=/cache GALLERY_ADDR=:8080 |
13 | ENV GALLERY_PHOTOS=/photos GALLERY_CACHE=/cache GALLERY_ADDR=:8080 |
| 12 | EXPOSE 8080 |
14 | EXPOSE 8080 |
| 13 | ENTRYPOINT ["gallery"] |
15 | ENTRYPOINT ["gallery"] |
README.org
+2
| @@ -61,3 +61,5 @@ GALLERY_PHOTOS_DIR=/path/to/photos docker compose up -d --build |
| 61 | |
61 | |
| 62 | The container listens on =127.0.0.1:8003=; put a reverse proxy in front of it. |
62 | The container listens on =127.0.0.1:8003=; put a reverse proxy in front of it. |
| 63 | Photos are mounted read-only; derivatives and =meta.json= live in =./cache=. |
63 | Photos are mounted read-only; derivatives and =meta.json= live in =./cache=. |
| |
64 | The container runs as UID/GID 1000, so =./cache= must be writable by that user |
| |
65 | and 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 | |
| 42 | func (m *Magick) Resize(src string, width int, dst io.Writer) error { |
42 | func (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. |
| |
55 | func 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. |
| 56 | func Default() *Registry { |
64 | func 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 | |
| |
155 | func 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 | } |