README.md is rewritten from scratch, with the old text as the reference for what a reader needs covered and the code on next as the source of truth. It now opens with what pixa is, what it is for and how to use it, then the deployment notes, then the design: concepts, routes, image URLs, signed and encrypted URLs, source hosts, metadata, limits and storage. The settings reference follows, one list item per setting under the heading the config test reads, with the cache_max_bytes default stated with its 500 MiB floor. Development and testing come last. Sentences are short, every identifier is in backticks, and project history, milestones and issue links are gone. Model: fable-5-1
pixa
pixa is a GPL-3.0-licensed Go web server by @sneak that fetches images from other servers, resizes and converts them, and serves the results.
A web page asks pixa for an image at a size and in a format. pixa fetches the original image, makes the requested image from it, and serves that. It keeps both on disk. Later requests for the same image are served from disk, without a fetch or a conversion.
This document calls the server that holds an original image the upstream host.
Getting Started
# clone and build
git clone https://git.eeqj.de/sneak/pixa.git
cd pixa
make build
# make a config file from the example and put a real signing key in it
cp configs/config.example.yml config.yml
openssl rand -base64 32 # prints a new signing key
$EDITOR config.yml # replace the signing_key placeholder with it
./bin/pixad --config config.yml
# or build and run the Docker image
make docker
docker run -p 8080:8080 -e PIXA_SIGNING_KEY="$(openssl rand -base64 32)" pixa:latest
make build compiles pixa on your machine. That needs the libraries
script/bootstrap --cgo installs (see Deployment).
The signing key is pixa's one secret. It must be 32 characters or longer. The placeholder in the example file is refused at startup.
Once pixa runs, it serves images through URLs of this form:
/v1/image/<host>/<path>/<size>.<format>
For example, this URL serves https://cdn.example.com/photos/cat.jpg at 800x600
as WebP:
/v1/image/cdn.example.com/photos/cat.jpg/800x600.webp
A URL for a host in allowlist_hosts needs nothing more. A URL for any other
host needs a signature (see Signed URLs). Someone who knows the signing key can
also make an encrypted URL on pixa's own web page (see Encrypted URLs). An
encrypted URL works for any host and hides the original image's URL.
The example config allowlists no host. Every image on an allowlisted host is served to anyone, without a signature, at any size and in any format. So allowlist only hosts you control, where nobody else can put images.
A container takes its settings from environment variables (see Configuration).
Only PIXA_SIGNING_KEY is required. Without it, the container exits at startup
and names the variable. Every other setting has a built-in default. A config
file mounted at /etc/pixa/config.yml is read when present. An environment
variable wins over the same setting in the file.
Deployment
pixa listens on plain HTTP. It runs behind a reverse proxy: a server in front of
it that receives the public requests and passes them on. The reverse proxy
terminates TLS, the encryption behind https.
configs/Caddyfile is an example for Caddy. Caddy gets its
own TLS certificate and does everything in this list without further settings.
The reverse proxy must:
- terminate TLS. The login and generator pages work only over HTTPS (see Routes).
- pass the
Host,OriginandRefererheaders on unchanged. pixa refuses a form from its pages unlessOriginorReferernames the host inHost. It builds encrypted URLs fromHost. It checksRefereragainstreferer_blocklist. - set
X-Forwarded-Forto the client's address, withtrusted_proxiesnaming the address its requests reach pixa from. The login limit andimage_requests_per_minutethen count each client by its own address. - wait for pixa's answer for at least
downstream_timeout(default60s). That is the longest pixa takes to fetch, convert and send an image.
The reverse proxy may also refuse /metrics, as the example does. Only a
scraper that reaches pixa directly can then read it. pixa itself asks for
metrics.username and metrics.password there.
pixa does the rest itself:
- it checks signatures and encrypted URLs;
- it applies the allowlist;
- it refuses upstream hosts with private or local addresses;
- it limits login attempts and each client's image requests;
- it limits the size of upstream responses and the dimensions of images;
- it sends the security headers,
Strict-Transport-Securityincluded, with every response.
The state directory (state_dir, /var/lib/pixa in the container) holds the
database and the disk cache:
- It needs a persistent volume. Without one, every restart starts with an empty cache. Losing the directory also ends every login session (see Encrypted URLs).
- In the container, the startup script gives the directory to the user pixa runs
as, uid 65532, and sets its mode to
750. Outside the container, the user running pixa must be able to write it. cache_max_byteslimits the cached images. The database and files still being written come on top. Eviction runs in the background, so the cache can pass the limit for a while. Leave room on the volume beyond it.- Set
cache_max_bytesfor a lasting deployment. Its default is worked out at each start from the free space on the volume (see Configuration). Anything else that fills or frees space on the volume moves it.
A load balancer's health check can request /.well-known/healthcheck.json. It
answers 200 whenever pixa is running, in maintenance mode too (see
maintenance_mode).
On SIGTERM or SIGINT, pixa stops accepting connections. The requests in
progress and the images being processed get 5 seconds to finish. A request not
finished by then is cut off. pixa then writes the last-used times of cached
images that still wait to be written (see Storage), and exits. It exits with
status 1 when:
- images were still being processed after those 5 seconds;
- some of those times could not be written;
- another part of pixa failed to stop.
Otherwise it exits with 0. docker stop waits 10 seconds before it kills the
container.
Outside Docker, pixa needs libvips and libheif at run time. libvips is the image
library pixa converts images with. pixa does not start unless libvips has its
JPEG XL support. On Alpine that is the vips-jxl package. The nix and brew
packages of libvips include it. So do the apt packages from Debian 12 and Ubuntu
24.04 on. Compiling pixa also needs the development files of libvips and
libheif, pkg-config and a C compiler. script/bootstrap --cgo installs all of
these. The Dockerfile installs its own packages. Plain script/bootstrap
installs git, make and Go; script/setup and script/cibuild run it. It also
installs Node, Yarn and prettier, which format the markdown. It installs none of
the C libraries: the checks compile pixa in Docker. Docker itself must already
be installed.
Running under upaas
upaas is the deployment tool pixa is meant to run under. Its app for pixa needs:
-
Port: pixa listens on container port
8080. -
Volume: container path
/var/lib/pixa, where pixa keeps its database and cache. -
Environment variables:
PIXA_SIGNING_KEY(required): the secret for signed URLs, encrypted URLs and login. It is 32 characters or more, for example fromopenssl rand -base64 32.PIXA_ALLOWLIST_HOSTS: upstream hosts served without a signature, comma-separated; default none. Every image on a listed host is served to anyone, so list only hosts you control, where nobody else can put images.PIXA_CACHE_MAX_BYTES: the disk cache limit in bytes;0turns the disk cache off. Set it. The volume is a directory on the host's filesystem, shared with the host and every other app. The default is 75% of the sum of that filesystem's free space and what the cache holds, and at least 500 MiB. So the cache can grow to three quarters or more of the space they all have left, and to all of it when that is 500 MiB or less.- the rest are in the list under Configuration.
-
Memory: pixa can use up to
max_concurrent_processingtimes 4.2 GiB for the images it processes at once. On top of that, the fetched source images waiting for processing can takeupstream_connectionstimesupstream_max_response_size. 4.2 GiB is a measured value. It is the most resident memory grew while pixa processed one 8192x8192 source, the largest it accepts, saving it at full size to each output format in turn. Memory grew most for a 16-bit JPEG XL of random pixels saved as WebP. At the defaults that is half the CPUs pixa sees times 4.2 GiB, plus 64 times 50 MiB. On a host where pixa sees 48 CPUs, that is 24 times 4.2 GiB plus 3.1 GiB, about 104 GiB. SetPIXA_MAX_CONCURRENT_PROCESSINGandPIXA_UPSTREAM_CONNECTIONSso that this fits the memory the container may use. -
Temporary files: while pixa processes a large source, libvips writes the decoded pixels to a file in
/tmp. The file is removed from the directory once it is written, so a listing of/tmpdoes not show it. It keeps its space until the image is done: on disk, or in memory where/tmpis a tmpfs. For a GIF, WebP, AVIF or JPEG XL source the decoded pixels are held in memory as well, and the 4.2 GiB above includes that memory. For an 8192x8192 source the file was measured at:- 192 MiB for an 8-bit RGB JPEG;
- 512 MiB for a 16-bit RGBA PNG or JPEG XL;
- 1 GiB for a 32-bit floating-point RGBA JPEG XL.
So count up to 1 GiB per image processed at once: 24 GiB at the defaults on a host where pixa sees 48 CPUs. This
/tmpis in the container's own filesystem, not on the volume at/var/lib/pixa.cache_max_bytesdoes not count these files, and the 4.2 GiB above does not include them. -
Health check: the image's
HEALTHCHECKrequests/.well-known/healthcheck.json. upaas reads the container's health 60 seconds after a deploy and marks the deploy failed unless it ishealthy. The probe takes the port fromPORT(default8080). A port changed only in a mounted config file is not seen by it, so change the port withPORT.
Rationale
A web application that shows images needs each one at several sizes and in several formats. pixa makes them on request from one original, and keeps what it made. The application stores one image per picture and hands out URLs. pixa is one binary. At run time it needs only the image libraries named under Deployment. Signed URLs with an expiry keep strangers from using it to resize anything they like. Allowlisted hosts give open access where that is wanted.
Design
Concepts
These terms are the names the code uses:
- Source: the image at one upstream URL, named by its host, path and query.
- Source key: the SHA-256 hash of a source's host, path and query. SHA-256 is a hash function: it turns any text into a fixed-length fingerprint. The source key names the source's file and its row in the database.
- Transform: the output size, format, quality and fit that an image request
asks for. With the format
auto, pixa chooses the output format from theAcceptheader. - Image request: a source and a transform. Each image route reads one from its URL.
- Variant: the image pixa serves for an image request, made from its source with its transform.
- Variant key: the SHA-256 hash of a variant's source, size, format, quality
and fit. It names the variant's file and its row in the database, and gives
its
ETag. - Signed URL: a
/v1/image/URL whosesigparameter signs its image request and its expiryexpwithsigning_key. - Encrypted URL: a
/v1/e/URL whose token holds its image request and any expiry, encrypted with a key derived fromsigning_key. - Login session: an encrypted cookie that keeps a browser logged in to the generator page. It lasts 30 days, or until a logout (see Encrypted URLs).
- Negative cache: the source URLs pixa does not fetch again for 5 minutes,
after a fetch failed in one of these ways:
- the upstream answered with an error status;
- the body's first bytes were not those of the format its
Content-Typenames.
- Image cache: the part of pixa that keeps the sources, the variants and the negative cache (see Storage).
- Disk cache limit (
cache_max_bytes): the size in bytes that the cached sources and variants may reach together. - Image proxy: the part of pixa that serves a variant from the image cache when it can, and otherwise makes it from the source.
Routes
pixa answers these routes. Any other path answers 404. A path in this list asked
with a method the list does not give answers 405. A browser's CORS preflight
request to any path under /v1/ answers 200, in maintenance mode too. CORS is
the browser rule that lets a page on one site read responses from another. The
preflight is the OPTIONS request the browser sends first.
GET /— the login page, or the generator page with a login session (see Encrypted URLs). Needs nothing. Answers 200.POST /— log in with the signing key typed into the login page. Needs the login page's form (below). Answers 303 to/, with a login session cookie, for the right key. Answers 200 with the login page and an error for a wrong key. Answers 429 over the login limit (see Limits).POST /generate— make an encrypted URL from the generator page's form. Needs a login session and the generator page's form. Without a login session it answers 303 to/. Answers 200 with the page showing the URL. Answers 400 with the page naming a field that is not valid. Answers 500 when the URL cannot be made.POST /logout— log out with the generator page's Logout button. That ends every login session, in every browser (see Encrypted URLs), and removes the cookie. Needs a login session and the generator page's form. Without a login session it only removes the cookie. Answers 303 to/. Answers 500 when the logout cannot be saved in the database.GETorHEAD/v1/image/<host>/<path>/<size>.<format>, or/v1/image/<host>/<path>/<size>with no format — an image, fetched, resized and converted (see Image URLs). Needs a signature, unless the host is allowlisted (see Source Hosts). Answers:- 200 with the image.
- 304 when
If-None-Matchmatches the image'sETag. - 400 for a URL or parameter that is not valid.
- 400 when the format is
autoand theAcceptheader is not valid. - 406 when the format is
autoandAcceptallows none of the formats it chooses from. - 401 for a missing or wrong signature.
- 401 for a missing
exp. - 401 for an
expin the past. - 403 when the request's
Referernames a host inreferer_blocklist. This is checked before the signature, the cache and the upstream fetch. - 403 when the upstream host, or a host it redirects to, is
localhostor ends in.localhostor.local. - 403 when the upstream host, or a host it redirects to, has an address in a
blocked network (see
blocked_networks). - 429 over
image_requests_per_minute(see Limits). - 499, with no body, when the client goes away before its image is ready, unless its request is the one making the image (see Limits).
- 415 when the upstream answers with a
Content-Typethat is not one of the source formats (see Image URLs). - 415 when the upstream sends a body whose first bytes are not those of the
format its
Content-Typenames. - 415 when the upstream sends an image whose first bytes libvips reads as another format.
- 502 when the upstream answers with an error status.
- 502 for 5 minutes after that for the same source URL, when pixa has neither the image nor its source in its cache.
- 502 likewise for 5 minutes after a body whose first bytes are not those of
its
Content-Type. - 503 when pixa is busy (see Limits).
- 503 in maintenance mode.
- 504 when the upstream has not sent its response headers within
upstream_fetch_timeout. - 500 when that time runs out while the image itself is still arriving.
- 500 for any other failure.
GETorHEAD/v1/e/<token>/<name>— an image through an encrypted URL (see Encrypted URLs). Needs nothing but the URL. Answers:- 200 with the image.
- 304 when
If-None-Matchmatches the image'sETag. - 400 for a token that does not decrypt.
- 400 for a token that asks for a size or fit that is not valid.
- 410 once the token has expired.
- 400 when the token's format is
autoand theAcceptheader is not valid. - These answers, for the same causes as on
/v1/image/:- 406
- 403
- 415
- 429
- 499, with no body
- 502
- 503
- 504
- 500
GET /robots.txt— asks every crawler to stay away (Disallow: /). Needs nothing. Answers 200.GET /.well-known/healthcheck.json— JSON withstatus(ok),now,uptime_seconds,uptime_human,version,appnameandmaintenance_mode. Needs nothing. Answers 200, always.GET /static/<file>— the stylesheet and script the login and generator pages load. Needs nothing. Answers 200, or 404 for a file that does not exist. It answers any other method as it answersGET.GET /metrics— Prometheus metrics (see Architecture). Needs HTTP basic authentication withmetrics.usernameandmetrics.password. Answers 200. Answers 401 without them. Answers 404 when they are not set, as the route then does not exist.
Every response carries an X-Request-Id header with the request's ID. A client
can quote it when reporting a problem. The ID is the request's own
X-Request-Id header, which a reverse proxy may send, when that is 1 to 64
letters, digits, -, _ or .. Otherwise it is a random one pixa makes, which
tells nothing about the machine or the other requests. pixa's log line for the
request carries the same ID as request_id. So do the lines it logs when it
fetches, converts and serves an image. The fetch sends it to the upstream host
as X-Request-Id.
The POST routes accept only a form that pixa's own page served. The page puts
a token in the form and sets a cookie to match. A request without both is
refused with 403. So another site cannot submit the form from a visitor's
browser. The login and generator pages are meant to be opened over HTTPS. While
debug is off, a form sent from a page opened over plain HTTP is refused
with 403. While debug is on, a form sent from a page opened over HTTPS is
refused instead. Plain HTTP is for development on the browser's own machine. The
login session cookie is always marked Secure. Over plain HTTP a browser keeps
such a cookie only for its own machine (localhost), if at all. A form is also
refused with 403 when the page's host is not the Host header pixa receives. So
a reverse proxy in front of pixa must pass that header on unchanged. A form body
over 1 MiB is refused with 413. The image routes answer the errors listed for
them with a JSON body holding error, status and timestamp.
Image URLs
An image URL has one of these forms. The second has no format:
/v1/image/<host>/<path>/<size>.<format>?sig=<signature>&exp=<expiration>&q=<quality>&fit=<fit>
/v1/image/<host>/<path>/<size>?sig=<signature>&exp=<expiration>&q=<quality>&fit=<fit>
<host>: the upstream host, with a port when the source URL has one (see Source Hosts).<path>: the source URL's path, and its query after%3F(below).<size>:origor<width>x<height>, for example800x600. A side of0is worked out from the other side and the source's proportions (below).origis0x0: the source's own size.<format>:orig(ororiginal),jpeg(orjpg),png,webp,avif,jxl(JPEG XL),gif, orauto(below).origis the source's own format. A URL with no format is served as JPEG XL, the default, as withjxl.sigandexp: the signature and its expiry, needed unless the host is allowlisted (see Signed URLs).q: the output quality, a whole number from 1 to 100. It is85when the URL has noq.fit: how the image is fitted to<size>:cover,contain,fill,insideoroutside(below). It iscoverwhen the URL has nofit.
q and fit are part of what is cached, so each value of either is a separate
cached image. A request whose query string cannot be decoded, or gives any
parameter more than once, is refused with 400. So is a q, fit or exp that
is empty or not valid.
pixa fetches sources over HTTPS only, and only from hosts with valid
certificates. With allow_http set, it fetches every source over plain HTTP
instead. That is for testing only.
The source path and query. A source URL's query follows its path after
%3F, the encoded ?:
/v1/image/<host>/<path>%3F<query>/<size>.<format>
pixa decodes the percent-escapes in the image URL's path once. It then reads the
host up to the first /, and the size and format after the last /. Between
them it reads the source path up to the first ?, and the source query after
it. So these must be percent-encoded, as %3F and %25:
- the
?before the query; - any
?in the query; - every
%in the source path or query.
Any other character may be written as itself or percent-encoded. /, ;, ,,
= and & work either way. Neither the source path nor the source query may
hold a #. An image URL that writes one as %23 is refused with 400. For
example, the image
https://cdn.example.com/img?url=https://origin.example.com/my%20cat.jpg at
800x600 as WebP is:
/v1/image/cdn.example.com/img%3Furl=https://origin.example.com/my%2520cat.jpg/800x600.webp
Signed URLs shows this URL with its signature.
pixa keeps the source path decoded and fetches it percent-encoded again. Every
character other than these is sent as a percent-escape: letters, digits, -,
., _, ~, /, $, &, +, ,, :, ;, = and @. So:
my%20cat.jpgin the image URL is the source path/my cat.jpg, fetched as/my%20cat.jpg;my%2520cat.jpgis the source path/my%20cat.jpg, fetched as/my%2520cat.jpg;100%25.pngis fetched as/100%25.png.
The source query is sent as the source URL writes it, its percent-escapes
included. Two kinds of character are percent-encoded first: those a browser
percent-encodes in a query, and a % that does not begin a percent-escape. The
first kind is a space, ", ', <, >, control characters and characters
outside ASCII. pixa signs and caches the query in that form. So
img%3Fname=my%20cat and img%3Fname=my%2520cat in the image URL are the same
source query, name=my%20cat.
Sizes. The largest width or height is 8192, for the source and for the
output. A size with one side 0 has that side worked out from the other side
and the source's proportions. The side is rounded down, but is at least 1. When
it would come out above 8192, the request is refused with 400. The fit modes:
cover: scale the image to cover the size, keeping its proportions, and cut out the centre. The output is exactly the size.contain: scale the image to fit inside the size, keeping its proportions. The output can be smaller than the size on one side.fill: stretch the image to the size, without keeping its proportions.inside: ascontain, but never enlarge the image.outside: scale the image to cover the size, keeping its proportions, without cutting. The output can be larger than the size on one side. An 8192x1 source at100x0is 100x1 by its proportions, but comes out at 8192x1. A side that would come out above 8192 is refused with 400.
An AVIF image of more than 16,777,216 pixels is refused with 400 before anything
is decoded. That is the number of pixels in 4096x4096 or 8192x2048. A larger
AVIF encode could take more than 40 seconds (see Limits). The limit includes
orig of an AVIF source, and auto when it chooses AVIF. So a client whose
Accept names image/avif but not image/jxl is refused such a size.
Source formats. The source may be JPEG, PNG, GIF, WebP, AVIF or JPEG XL. The
upstream's Content-Type must name its format, and the image's first bytes must
match it. An upstream answering with any other Content-Type, SVG included, is
refused with 415. So is a body whose first bytes are not those of the format its
Content-Type names: a JPEG sent as image/png, text sent as an image, or an
empty body. pixa then does not fetch that source URL again for 5 minutes, as
after an upstream error status. Meanwhile it answers 502 for an image that would
need that fetch. An image whose first bytes libvips reads as another format is
refused with 415 too. An SVG whose first bytes pass the check for AVIF is one
example.
The format auto. With auto, pixa chooses the format for each request
from its Accept header, in this order:
- JPEG XL, when the header names
image/jxl; - AVIF, when it names
image/avif; - WebP, when it names
image/webp; - JPEG, when the first of
image/jpeg,image/*and*/*that it names allows it, or when there is noAcceptheader or it is empty.
An entry with q=0 refuses its format. Other q values do not change the
order. JPEG XL, AVIF and WebP must be named, as clients that cannot show them
also send image/* and */*. pixa never sends a format the client refused.
When the header allows none of the four, the answer is 406. A header that does
not parse, or has a q that is not a number from 0 to 1, is refused with 400.
The signature, or the token of an encrypted URL, covers auto itself, so one
URL serves every client. Each format chosen is cached as a separate image. Every
answer that depends on Accept carries Vary: Accept: the image, a 304, and
the 400 and 406 above. So a shared cache keeps the formats apart too.
Response headers. An image is served with
Cache-Control: public, max-age=<seconds>, immutable. When the URL has an
expiry (an exp, or the expiry of an encrypted URL), max-age is the whole
seconds left until then, at most one year. So no browser or proxy cache keeps
the image after pixa would refuse the URL. A URL with no expiry gets one year.
immutable only stops a client revalidating while its copy is fresh. The
response also carries X-Pixa-Cache: HIT for an image served from the cache,
and MISS for one made for this request. The ETag is the first 16 characters
of the variant key, in quotes.
Signed URLs
A signed URL is a /v1/image/ URL with two query parameters: exp, the Unix
time when it expires, and sig, its signature. The signature is an HMAC-SHA256:
a hash of the request's details, keyed with signing_key, that only someone who
knows the key can make. Signatures match exactly: every part of the signed data
must be exactly what was signed. There is no suffix, wildcard or partial
matching.
The signed data is one line of nine parts, joined with ::
HMAC-SHA256(secret, "host:path:query:width:height:format:expiration:quality:fit")
host: the source host as the URL writes it up to the first/, in lower case. An IPv6 address keeps its brackets, and a port is kept after its:. Examples:cdn.example.com,cdn.example.com:8443,[2001:db8::1]:8443.path: the source path with its percent-escapes decoded, as under Image URLs, then with each%written as%25and each:as%3A. So/photos/my%20cat.jpgin the URL is signed as/photos/my cat.jpg, and/photos/a:b.jpgas/photos/a%3Ab.jpg.query: the source query as under Image URLs, with%and:written as inpath. It is empty when there is no query. Neither part then holds a:, so path/a:bwith no query and path/awith queryb:are signed differently.widthandheight: the requested size in pixels. A side of0is signed as0, andorigas0and0.format: the output format as listed under Image URLs, withoriginalsigned asorig,jpgasjpeg, and no format asjxl. So a URL with no format has the signature of the same URL ending in.jxl.autois signed asauto, not as the format chosen for the request.expiration: the URL'sexp, the Unix time in seconds when the signature expires. A request whoseexpis not a whole number, an emptyexp=included, is refused with 400.quality: the URL'sq, or85when the URL has noq.fit: the URL'sfit, orcoverwhen the URL has nofit.
The URL's sig is the HMAC-SHA256 result in base64url, the URL-safe alphabet of
RFC 4648, with the trailing = padding kept: 44 characters in all. pixa
compares it exactly. A signature encoded without padding, as Node's base64url
and Go's base64.RawURLEncoding do, is refused with 401.
Example. With the signing key example-signing-key-for-documentation,
resize https://cdn.example.com/photos/cat.jpg to 800x600 WebP, expiring at
1704067200, with the default quality and fit:
- Build the signed data:
cdn.example.com:/photos/cat.jpg::800:600:webp:1704067200:85:cover - Compute its HMAC-SHA256 with the signing key.
- Encode the result as base64url, keeping the
=padding:-ay7KHpfqmtIGbibDGbUuBDkymi-Ymdn0NkC6j5EJag= - The URL:
/v1/image/cdn.example.com/photos/cat.jpg/800x600.webp?sig=-ay7KHpfqmtIGbibDGbUuBDkymi-Ymdn0NkC6j5EJag=&exp=1704067200
For the same image at quality 40 with fit contain, the signed data ends in
:40:contain. The signature is 5IwXUx6vf7yefhaUvFzgXZvG2o0Df4RJxPTK3pKq5VU=,
and the URL is
/v1/image/cdn.example.com/photos/cat.jpg/800x600.webp?sig=5IwXUx6vf7yefhaUvFzgXZvG2o0Df4RJxPTK3pKq5VU=&exp=1704067200&q=40&fit=contain.
For a source URL with a query,
https://cdn.example.com/img?url=https://origin.example.com/my%20cat.jpg, at
800x600 WebP with the same key, expiry, quality and fit:
- The signed data, with the query's
:written as%3Aand its%as%25:cdn.example.com:/img:url=https%3A//origin.example.com/my%2520cat.jpg:800:600:webp:1704067200:85:cover - The signature:
37aFa9GSVCZM1kRY9SzMOk2fOg_M7Wt5gKNR4xM-FYA= - The URL, with the
?before the query written as%3Fand the query's%as%25:/v1/image/cdn.example.com/img%3Furl=https://origin.example.com/my%2520cat.jpg/800x600.webp?sig=37aFa9GSVCZM1kRY9SzMOk2fOg_M7Wt5gKNR4xM-FYA=&exp=1704067200
Encrypted URLs
An encrypted URL is an image URL made on pixa's own web page by someone who knows the signing key. It works for any upstream host, allowlisted or not, without a signature. Whoever gets it can neither read the source URL from it nor change what it asks for.
- Open
/in a browser over HTTPS and log in with the signing key. The login session lasts 30 days, or until someone logs out with the generator page's Logout button. - On the generator page, give the source image's URL, the width and height, the
format, quality and fit, and how long the URL lasts. Then submit the form
(
POST /generate). - The page shows the URL,
https://<host>/v1/e/<token>/img.<format>, and when it expires.<host>is the host the page was opened on. Whiledebugis on, the URL starts withhttpinstead. The name after the token is ignored. It only gives the URL a file extension:jpgfororigandauto, andjxlfor a form with no format.
The form's fields:
- The format is JPEG XL unless another is chosen. A form sent with an empty format, or none, also makes a JPEG XL URL.
- Width and height both empty or
0keep the source's size. When only one of them is empty or0, that side is worked out from the source's proportions when the image is made (see Image URLs). - How long the URL lasts is chosen from 1 minute to 1 year, or never.
The page refuses a form with 400 when:
- the source URL does not parse, or has no host;
- the source URL's path holds a
..segment, as/v1/image/refuses one; - a width, height, quality or expiry is not a number, or is out of range;
- the fit is not one of the five fit modes;
- the source URL would be fetched written another way (below).
pixa fetches a source URL only as it was written. The page refuses one that pixa
would fetch another way, and shows the URL pixa would fetch. A URL with a user
name and password, which pixa does not send, is one example. A path holding
%2F, which pixa would fetch as /, or (, which it would fetch as %28, is
another (see Image URLs).
The image is made only when the URL is requested. A side that would come out above 8192, or an AVIF output of more than 16,777,216 pixels, is refused with 400 then (see Image URLs).
Every login is made with the one signing key, so logging out ends every login session, in every browser, a copy of the session cookie included. pixa keeps a random value in its database. It makes the value when the database has none, and replaces it at each logout. A login session carries the value current at its login. It is refused once the value has changed, also after a restart. The check compares no time, so a login after a logout works whatever the host's clock says. The 30 days are still measured by the host's clock.
The token holds the source's host, path and query, and the size, format,
quality, fit and expiry. It is encrypted with a key derived from signing_key.
The host is kept in lower case and an empty path as /, as for /v1/image/. So
https://CDN.Example.com and https://cdn.example.com/ are one source, fetched
and cached once. The source URL's scheme is not kept. The image is fetched like
any other (see Image URLs), and the blocked networks still apply.
The expiry is fixed in the token when the URL is made. It cannot be changed or
revoked afterwards. Until then the image is served with a max-age that ends at
the expiry (see Image URLs). After it, the URL answers 410 URL has expired. A
URL made to last forever holds no expiry. It stops working only when
signing_key changes. Changing the key makes every encrypted URL already handed
out answer 400, and ends every login session.
Source Hosts
Hosts in allowlist_hosts are served without a signature. Any other host needs
one. An entry is written in one of two ways:
cdn.example.com: an exact match, only that host;.example.com: a suffix match, which matchescdn.example.com,images.example.comandexample.comitself.
An IP address is matched exactly. Write an IPv6 address without brackets. An
entry that is neither a host name nor an IP address aborts startup. A host name
here is letters, digits, hyphens, underscores and dots, with at most one leading
dot. So an entry with a port, or a *. wildcard, aborts startup.
pixa lower-cases the host of a /v1/image/ URL before anything else reads it.
So CDN.Example.com is matched, fetched, cached and counted against
upstream_connections_per_host as cdn.example.com. A URL whose host has a
port, as in cdn.example.com:8443, needs a signature even when the host is
allowlisted. So does one whose host ends in : with no port. The host must be
one of:
- a host name or an IPv4 address: letters, digits, hyphens, underscores and dots;
- an IPv6 address in brackets.
Either may be followed by : and a port. Any other host is refused with 400.
One holding @ is an example. So is one with a ?, #, %, \ or space
written percent-encoded.
Image Metadata
pixa decodes and re-encodes every image it serves, and removes all metadata from the output. This cannot be turned off. The metadata removed is:
- EXIF, which can hold the GPS position, the camera make, model and serial number, the capture time and an embedded thumbnail;
- XMP and IPTC;
- the ICC colour profile.
The rest of the rules:
- The
origformat means the source's own format, not the source's bytes. Anorigimage is re-encoded and stripped like any other. - An image with an EXIF orientation is turned upright first, so it displays the same without the tag. A requested size applies to the upright image.
- An image with an ICC profile is converted to sRGB first, since clients show an image with no profile as sRGB. Colours outside sRGB are clipped: the most saturated ones in a Display P3 photo, for example.
- JPEG XL output is the one exception, with libvips 8.16 and later. libvips then writes an EXIF block of its own, which pixa cannot stop. The block holds the image's size and fixed values: an orientation of 1 and a resolution of 72 dpi. Nothing in it comes from the source.
Limits
Shared work. Several requests for one variant can miss the cache at the same time. They then share one upstream fetch, or one read of the cached source, and one conversion. The first request does the work. The others wait for its image or its error, and hold no upstream connection or processing slot of their own. A waiting request stops waiting when its own client goes away. It is then answered 499. The work goes on for the others even when the first request's client goes away. The shared fetch sends the first request's ID upstream. The lines logged for the fetch and the conversion carry that ID.
Timeouts. downstream_timeout (default 60s) bounds the whole answer to
one request. The upstream fetch counts toward it. So do the waits for an
upstream connection and for a processing slot, up to 10 seconds each. Keep it
longer than upstream_fetch_timeout plus 20 seconds.
A conversion whose request's downstream_timeout has ended stops waiting for a
processing slot. Once it has one, it gives the slot back without beginning to
encode. For a shared conversion, the first request's deadline counts. A request
waiting for a shared conversion that stops this way is answered 503
server busy, try again later, though its own downstream_timeout may not have
ended. govips, the library pixa drives libvips through, gives no way to stop an
encode once it has begun. So an encode keeps its slot until it finishes, which
can be after downstream_timeout.
Encode times. The limits on image size bound how long an encode can take. They are 8192 on each side of the source and of the output, and 16,777,216 pixels for an AVIF output. The WebP and JPEG XL encoders run below libvips' default effort. AVIF runs at the lowest effort govips can set. With one libvips thread, as pixa runs it, 8192x8192 random pixels with an alpha channel take up to about:
- 23 seconds as WebP, and 16 without an alpha channel;
- 27 seconds as JPEG XL, and 13 without an alpha channel.
An AVIF output of 4096x4096, 8192x2048 or 2048x8192 random pixels with an alpha
channel takes up to about 34 seconds. At 8192x8192 and quality 85 it would take
about 96. These times hold at any quality and at 8 or 16 bits per sample. No
encode within the limits takes more than about 40 seconds of the default
downstream_timeout of 60.
Processing and connections. max_concurrent_processing images are decoded
and encoded at once. A request that finds every slot in use waits up to 10
seconds for one. If none frees up, it is answered 503
server busy, try again later, unless downstream_timeout has ended first.
upstream_connections bounds the fetches in progress to all upstream hosts
together, and upstream_connections_per_host those to one host. A fetch holds
its connection until its image has been processed. A fetch that finds every
shared connection in use waits up to 10 seconds the same way, then answers 503.
Client limits. The login form (POST /) is limited to 5 attempts per minute
per client address. An IPv6 client is counted by its /64. An attempt over the
limit is refused with 429 and a Retry-After header. One client may send
image_requests_per_minute requests per minute (default 600) to /v1/image/
and /v1/e/ together, GET and HEAD, cache hits included. A request over the
limit is refused with 429 and a Retry-After header. A browser's CORS preflight
is not counted. 0 turns the limit off.
The client address. Behind a reverse proxy, the client address comes from
X-Forwarded-For only when the address pixa sees the request come from is in
trusted_proxies. Otherwise all users behind the proxy count as one client.
That address is not always the proxy's own. A proxy on the Docker host that
connects to pixa over 127.0.0.1 is seen as the gateway of the container's
Docker network, such as 172.17.0.1 on the default bridge. A proxy that
connects through another of the host's addresses is seen with that address. To
be sure, read it as remoteIP in pixa's request log while it is not in
trusted_proxies (see trusted_proxies under Configuration). The default
trusted_proxies is the RFC 1918 ranges, the private address ranges. With that
default, a client with a private address can choose the address it is counted
by, through its own X-Forwarded-For, because its own address is trusted too.
Setting trusted_proxies to only the proxy's address closes this. Behind a CDN
or proxy whose addresses are not in trusted_proxies, all requests arriving
from one address count as one client. Such a deployment needs a higher
image_requests_per_minute, or 0.
Request limits. pixa reads a request's headers within 10 seconds and the whole request within 30 seconds. Headers are limited to 8 KiB. An idle connection is closed after 120 seconds. A form body over 1 MiB is refused with 413.
Storage
- Sources:
<state_dir>/cache/sources/<ab>/<cd>/<source key>, one file for each source URL - Database:
<state_dir>/state.sqlite3 - Variants:
<state_dir>/cache/variants/<ab>/<cd>/<variant key>
<ab> and <cd> are the first and second pairs of characters of the file's
name. The database is one SQLite file. SQLite is the database library pixa
embeds; nothing else needs to run.
The image cache keeps these tables in the database:
source_content: a row for each source, by source key, with its size and when it was last usedvariant_content: a row for each variant, by variant key (columncache_key), with its size and when it was last usedcache_usage: the total size of the cached sources and variants, which eviction readsnegative_cache: a row for each source URL in the negative cache, with when it expires
The logout table holds the random value that a logout replaces (see Encrypted
URLs).
pixa reads a cached source's format from its file's first bytes. When they match none of the source formats, pixa fetches the source again. Two source URLs that serve the same bytes are stored twice, once under each URL. A source is stored by writing its file and then its row. When the row cannot be written, the file is removed again.
Each source and variant file is written under a temporary name and synced to disk before it is renamed into place. So a crash never leaves part of a file under its own name.
When pixa starts, and every five minutes after, it checks the cached files against the database. It removes each source or variant whose file is empty, or not the size its row records. It logs a warning naming the file. A later request for that image fetches the source or makes the variant again. The check also drops the rows of variants whose files are missing. When pixa cannot read a variant file's size for another reason, it keeps the variant's row and logs a warning naming the variant key and the error. During a check, pixa skips a directory it cannot list and a file whose size it cannot read. It logs a warning naming the path and the error, and goes on with the rest of the check. A file renamed or removed after pixa listed its directory gets no warning.
A check reads the database once for every cached file, so the checks are spaced out. After a check ends, the next one waits at least ten times as long as that check took. After a check that took one minute, the next one starts 10 to 15 minutes after that check ended.
A cache hit opens the variant's file and nothing else. It makes no database
call. The content type comes from the file's first bytes, which are those of the
format pixa wrote it in. A file whose first bytes are not those of a format pixa
writes is not served, and the image is converted again. A hit hands the time it
was served to a background writer and does not wait for the writer. Eviction
orders by that time. The hit is counted in memory. A miss that uses a cached
source also hands that writer the time it used the source. Once a second the
writer writes the waiting times in one transaction. Up to 10,000 access times of
variants, and as many of sources, can wait to be written. A time handed over
beyond that is dropped. Its image then only looks less recently used to
eviction. script/bench measures how many hits pixa serves per second (see Load
Test).
Eviction removes the least recently used sources and variants until the cache is
within cache_max_bytes. It runs after each store, and every five minutes. It
also removes the expired rows of the negative cache every five minutes, at most
1000 per statement, so a request waits behind 1000 deletions at most.
Configuration
Every setting can be given as an environment variable, in a YAML config file
(--config), or both. A variable present in the environment wins over the file,
even when it is empty. The file wins over the built-in default. The one
exception is a variable set in the file's env: section. pixa sets that
variable while loading the file, so it wins over the environment pixa started
with. It also wins over the file's key for the same setting. A variable's value
is parsed as the same text in the file would be. A setting that holds a list
takes comma-separated entries, with the spaces around each trimmed. An empty
variable is an empty list. A value that does not parse or is invalid aborts
startup, naming the setting and its variable. A key in the file set to null,
key: with nothing after it included, aborts startup too: omit the key to use
the default. pixa refuses to start when a variable starts with PIXA_ but is
not in the list below, such as a misspelled one or PIXA_PORT. The error names
the variable, as it names an unknown key in the file. pixa also accepts
PIXA_CONFIG_PATH, which names the config file, like --config. The variables
set by the file's env: section are checked the same way.
pixa reads at most one config file. It reads the file given with --config (or
-c), or else the one PIXA_CONFIG_PATH names. pixa refuses to start when that
file does not exist, cannot be read or does not parse. Without --config or
PIXA_CONFIG_PATH, pixa looks in /etc/pixa/, then in ~/.config/pixa/, then
in the working directory. In each directory it looks for config.yml, then
config.yaml. It reads the first file it finds. It skips a missing file without
a message. pixa refuses to start when a file it finds cannot be read or does not
parse, and the error names the file. It also refuses to start when it may not
enter one of these directories, whether or not the file is there, since it
cannot tell. With no file, pixa uses the environment and the defaults.
Durations are written as Go durations, such as 30s or 2m, and must be
positive. A bare number has no unit and aborts startup. Sizes are a whole number
of bytes. A CIDR range is an address range written as an address and a prefix
length, such as 10.0.0.0/8.
Each item below is one setting, named by its config key:
signing_key: variablePIXA_SIGNING_KEY, required. The secret for signed URLs, encrypted URLs and login. It must be 32 characters or longer. The placeholder inconfigs/config.example.ymlis refused. pixa uses the key as it is for two things: as the HMAC key of signed URLs (see Signed URLs), and as the password of the login page. From it, pixa derives four more keys at startup, with HKDF-SHA256, a key derivation function. One signs the session cookie and one encrypts it. One encrypts the token of an encrypted URL. One signs the token cookie of the login and generator pages' forms.port: variablePORT, default8080. The port to listen on, from 1 to 65535.state_dir: variablePIXA_STATE_DIR, default/var/lib/pixa. The directory for the database and the disk cache. pixa creates it at startup when it is missing. It refuses to start when it cannot create or write the directory.db_url: variablePIXA_DB_URL, defaultfile:<state_dir>/state.sqlite3?_pragma=journal_mode(WAL). The SQLite database to open. The default keeps the database in WAL mode, SQLite's write-ahead log, which lets reads go on during a write. In the default,state_diris percent-encoded: a%,?or#in the name is written as%25,%3For%23. For astate_dirof/, or one starting with//, the default starts withfile:////, so SQLite does not read the first name in the path as a host. pixa adds_pragma=busy_timeout(5000)to anydb_url. A write that finds another program writing to the file then waits up to five seconds for it instead of failing. pixa opens one connection, so its own reads and writes run one at a time. WAL mode comes only from the URL: keep_pragma=journal_mode(WAL)in one you set. An emptydb_urlaborts startup: omit the key to use the default.cache_max_bytes: variablePIXA_CACHE_MAX_BYTES, default below. The disk cache limit in bytes. When the key is omitted, pixa works the limit out at startup. It takes the free space on the filesystem holding<state_dir>/cache/, adds the bytes the cache already holds, and takes 75% of the sum. The default is at least 500 MiB. A value you set is used exactly as given.0turns the disk cache off: every request then fetches and converts its image, and only the negative cache is used. A negative value aborts startup.allowlist_hosts: variablePIXA_ALLOWLIST_HOSTS, default none. The upstream hosts whose images pixa serves without a signature, written as under Source Hosts. Every image on a listed host is served to anyone, so list only hosts you control. An entry that is neither a host name nor an IP address aborts startup.referer_blocklist: variablePIXA_REFERER_BLOCKLIST, default none. Hosts whose pages may not show pixa's images, to stop other sites hotlinking them. Entries are written and matched as forallowlist_hosts. A request to/v1/image/or/v1/e/whoseRefererheader names a listed host is refused with 403. That happens before its signature or token is checked, and before the cache or the upstream host is used. It is refused even when the image is cached. A request with noReferer, or one that does not parse as a URL with a host, is served, as many clients send none. So a site whose pages send noReferer, for example withReferrer-Policy: no-referrer, is not stopped. The login and generator pages are not covered.blocked_networks: variablePIXA_BLOCKED_NETWORKS, default none. CIDR ranges pixa never fetches from, on top of the built-in ones. The built-in ranges are loopback, private, link-local, carrier-grade NAT, benchmarking, NAT64 and the like, and always apply. An invalid CIDR aborts startup. An IPv6 address in the IPv4-mapped form (::ffff:a.b.c.d) is matched only as the IPv4 address it holds. So an IPv6 range such as::/0refuses neither it nor a plain IPv4 address. An entry written in that form is read as the IPv4 range it covers:::ffff:10.0.0.0/104refuses what10.0.0.0/8refuses. Such an entry shorter than/96covers more than IPv4 addresses and aborts startup.trusted_proxies: variablePIXA_TRUSTED_PROXIES, default the RFC 1918 private ranges:10.0.0.0/8,172.16.0.0/12and192.168.0.0/16. CIDR ranges of the reverse proxies in front of pixa.X-Forwarded-Foris believed only when the request reaches pixa from an address in one of these ranges. The client address is then the rightmost entry of the header that is not itself a trusted proxy. Otherwise the header is ignored and the address the request came from is used. So a client connecting from outside these ranges cannot choose its address. An explicitly empty list ([], or an empty variable) trusts no one. A list you set replaces the default. An invalid CIDR aborts startup. An IPv4-mapped entry or address is handled as underblocked_networks. Set this to the address pixa sees for requests that come through your proxy, such as172.17.0.1/32. Do so when the default does not cover it, or to trust nothing else (see Limits). To find that address, set this to[], send a request through the proxy, and readremoteIPin pixa's log line for it.allow_http: variablePIXA_ALLOW_HTTP, defaultfalse. Whiletrue, pixa fetches every source over plain HTTP, and follows redirects to plain HTTP. It is for testing only.upstream_connections_per_host: variablePIXA_UPSTREAM_CONNECTIONS_PER_HOST, default20. The most connections to one upstream host at once. It must be at least 1.upstream_connections: variablePIXA_UPSTREAM_CONNECTIONS, default64. The most connections to all upstream hosts together at once, on top of the limit per host. It must be at least 1. A fetch holds its connection until its image has been processed (see Limits).max_concurrent_processing: variablePIXA_MAX_CONCURRENT_PROCESSING, default half the CPUs pixa can use, rounded down, and at least 1. The most images decoded and encoded at once. The number of CPUs follows a container's CPU limit. A value that is not a whole number of at least 1 aborts startup. Each image can take a lot of memory (see Running under upaas).image_requests_per_minute: variablePIXA_IMAGE_REQUESTS_PER_MINUTE, default600. How many requests one client may send per minute to/v1/image/and/v1/e/together (see Limits).0turns the limit off. A negative or non-integer value aborts startup.upstream_fetch_timeout: variablePIXA_UPSTREAM_FETCH_TIMEOUT, default30s. The time allowed for one fetch from an upstream host. A fetch whose response headers have not arrived by then answers 504.upstream_max_response_size: variablePIXA_UPSTREAM_MAX_RESPONSE_SIZE, default52428800(50 MiB). The largest upstream response accepted, in bytes, from 1 to1073741824(1 GiB). It also limits the image data pixa decodes. A cached source larger than it is fetched again.downstream_timeout: variablePIXA_DOWNSTREAM_TIMEOUT, default60s. The time allowed for answering one client request. Keep it longer thanupstream_fetch_timeoutplus 20 seconds (see Limits).access_control_allow_origin: variablePIXA_ACCESS_CONTROL_ALLOW_ORIGIN, default*. The origin a browser lets read the responses of/v1/image/and/v1/e/, sent as the CORSAccess-Control-Allow-Originheader. No other route sends it.*is any site. Otherwise it is onehttporhttpsorigin such ashttps://example.com. Its host is a lowercase host name (letters, digits, hyphens and dots, with a letter in its last part) or an IP address (IPv6 in brackets, in its shortest form). It may have a port from 1 to 65535 that has no leading zero and is not the scheme's default. Any other value aborts startup, another scheme such as a browser extension's included.metrics.username: variablePIXA_METRICS_USERNAME, default empty. The username for/metrics. pixa serves/metrics, and measures requests for it, only when the username and the password are both set. Setting one without the other aborts startup.metrics.password: variablePIXA_METRICS_PASSWORD, default empty. The password for/metrics. Set it together with the username.sentry_dsn: variablePIXA_SENTRY_DSN, default empty. Sentry is an error reporting service, and the DSN is the URL it gives you. A DSN turns error reporting on. It must be a URL with a scheme and a host, or empty.debug: variablePIXA_DEBUG, defaultfalse. Turns on debug logging and plain-HTTP local development (see Routes).maintenance_mode: variablePIXA_MAINTENANCE_MODE, defaultfalse. Whiletrue,/v1/image/and/v1/e/answer every request with 503, aRetry-Afterheader of 300 seconds and a JSON error body. The health check still answers 200 and reports"maintenance_mode": true. It stays 200 because the image's DockerHEALTHCHECKrequests it, and upaas marks a deploy failed when its container is unhealthy. The login and generator pages and/metricskeep working.
configs/config.example.yml names every setting with its default.
Architecture
-
Dependency injection: Uber fx, a library that builds the program's parts in dependency order and starts and stops them.
internal/appputs every part of pixad in one fx graph.internal/providersbuilds the image cache, the image proxy and the other parts that serve images and logins. Each setting's default is declared once, ininternal/config. -
Packages: these packages under
internal/work with the concepts above:handlers: the routes, which read the image request from the URL, check the signed or encrypted URL, and make the login sessionimagerequest: the image request, with the source key and the variant keyimageproxy: the image proxyimgcache: the image cache, which keeps its sources in oneFileStoreand its variants in anotherhttpfetcher: fetching sources from upstream hostsmagic: reading an image's format from its first bytesimageprocessor: making a variant from a source with libvipssignature: checking thesigof a signed URLencurl: making and reading the tokens of encrypted URLsseal: the keys derived fromsigning_key, and the encryption of those tokenssession: the login session
-
HTTP router: go-chi
-
Image processing: govips, a Go wrapper for libvips
-
Database: SQLite via modernc.org/sqlite
-
Static assets: embedded in the binary via
//go:embed -
Metrics: Prometheus, at
/metrics. Prometheus is a monitoring system that reads metrics from that route. pixa serves/metricsand measures HTTP requests only whenmetrics.usernameandmetrics.passwordare set./metricsholds:- the duration of HTTP requests
- the size of HTTP responses
- the HTTP requests in flight
- the Go runtime and process metrics
pixa_cache_hits_total: image requests served from the cachepixa_cache_misses_total: image requests not served from the cachepixa_upstream_fetches_total: fetches of a source image that read any bytespixa_upstream_fetch_bytes_total: the bytes those fetches readpixa_transforms_total: images converted
A request's
handlerlabel is the pattern of the route it matches, such as/v1/image/*, never its path. Requests that match no route share the labelunmatched: those the router answers 404 or 405, and CORS preflights. A request'smethodlabel is its method when that isGET,HEAD,POST,PUT,PATCH,DELETE,OPTIONS,CONNECTorTRACE, andotherfor any other method. -
Logging: the standard library's
slog, as JSON on standard output, or as text when standard output is a terminal.
Entrypoints
This repository adheres to the
Scripts to Rule Them All
standard. The scripts in script/ are the entrypoints for the development
workflow, and the Makefile targets are thin shims that call them. We provide:
script/bootstrap— install git, make, Go, Node, Yarn and prettier, and download the Go modules. It can be run again at any time. With--cgo, it installs the C compiler and the libvips (with its JPEG XL support) and libheif libraries instead of Node, Yarn and prettier. Compiling and testing pixa need those.script/setup— make a fresh clone ready for development: bootstrap, then install-precommit.script/projectname— output the project name (pixa).script/test— run the test suite: build thetestphase of theDockerfile, writing no image.script/lint— run golangci-lint: build thelintphase of theDockerfile, writing no image. The linter never runs on the host.script/fmt— format the Go code with gofmt and the markdown with prettier (writes).script/fmt-check— check the same formatting (read-only), on the host.script/check— run test, lint, and fmt-check.script/docker— build the Docker image tagged viascript/projectname, with the version fromgit describe. The image's build stage depends on thelintandtestphases, so this runs them too.script/docker-smoke— build the image, start it, and wait for it to be healthy.script/loadtest— measure pixad's throughput, latency and peak memory. It is a benchmark, not part ofscript/check(see Load Test).script/bench— run the Go benchmark that serves cache hits in parallel through the image handler, with Go on the host afterscript/bootstrap --cgo. It is a benchmark, not part ofscript/check(see Load Test).script/cibuild— the CI entrypoint: runscript/bootstrap(without--cgo), thenscript/check, then build the image asscript/dockerdoes.script/precommit— the pre-commit checks: thego mod tidyguard, thenscript/check.script/install-precommit— install the git pre-commit hook that runsscript/precommit.
Every docker build in these scripts passes a new value of the CACHEBUST
build argument, which every stage of the Dockerfile declares. So every RUN
step after that line, the lint and test steps included, runs on every build
instead of coming from the build cache. Above the line are the steps that
install system packages and download the Go modules. Docker keeps their layers
until the base image, the step itself, go.mod or go.sum changes. Go's build
cache is kept from build to build: the steps that compile mount it, so a package
whose inputs are unchanged is not compiled again. Both go test runs pass
-count=1, so no stored test result is replayed. The lint step also mounts
golangci-lint's cache. That cache reuses a package's findings only while its
files, its imports, go.mod, .golangci.yml and the golangci-lint version are
unchanged. The test phase runs on the Debian Go image, with Debian's libvips
and libheif. The image pixa ships is built and runs on Alpine. These scripts are
the standard copies from sneak/prompts, kept identical to them:
script/check, script/cibuild, script/docker, script/lint, script/test,
script/setup and script/install-precommit. script/fmt and
script/fmt-check are the standard copies with pixa's gofmt step kept before
prettier. prettier formats the markdown only, not the HTML templates, as it
cannot parse a Go template action inside a tag.
Load Test
script/loadtest (or make loadtest) measures how fast pixad answers and how
much memory it uses. It is a benchmark, not a check: script/check does not run
it. It needs Docker and Go.
script/loadtest # 10 seconds per scenario, 4 clients
script/loadtest 30s 32 # 30 seconds per scenario, 32 clients
It builds the image with script/docker and the load tool,
vegeta, from a pinned commit. Each scenario
starts a new pixad container and a new origin container, cmd/loadtest-origin.
The origin is an upstream host that answers every path with the same generated
1600x1200 JPEG. vegeta then sends requests from the given number of clients.
Each client sends its next request as soon as its last one is answered. Every
request asks for an image resized to 400x300 WebP:
hit: the same image every time, put in the cache first;miss: a new source image every time, so pixad fetches and converts each one;herd: each new source image once per client in a row. All clients then ask for it at the same time and share one fetch and one conversion (see Limits).
pixad refuses upstream hosts with private or local addresses, so the containers
share a Docker network in 203.0.113.0/24, a range set aside for documentation.
A second run on the same Docker host while one is going fails, as it cannot
create that network.
For each scenario the script prints vegeta's report and two lines of its own:
Requests [total, rate, throughput]: the requests sent, how many were sent per second, and how many were answered successfully per second;Latencies [min, mean, 50, 90, 95, 99, max]: the time from sending a request to the end of its answer.50,95and99are the 50th, 95th and 99th percentiles;Status CodesandError Set: anything other than200means the other numbers are not for the scenario described, such as503when pixad was busy;Bytes In:0, as vegeta is told not to keep the images it receives;pixad peak memory (VmHWM): the peak resident memory of pixad's process since its container started, in kB; forhitit includes the request that put the image in the cache;requests to the origin: the fetches pixad made. That is one forhit, one per request formiss, and one per image forherd: the requests sent divided by the number of clients.
The numbers depend on the machine and on whatever else runs on it.
script/bench (or make bench) measures cache hits alone, without the network
and with no pixad process. A Go benchmark in internal/handlers serves one
cached image through the /v1/image/ handler from as many goroutines at once as
the machine has CPUs. It reports the hits served per second as hits/s. Each
run starts with an empty cache and an empty database, a SQLite file of its own,
and puts the image in the cache before it measures. The benchmark fails if
serving the hits ran any statement on the database. Like the load test it is not
part of script/check. It runs Go on the host, as the load test does, and
compiles pixa there. So it needs the C compiler and libraries that
script/bootstrap --cgo installs, but not Docker.
TODO
See TODO.md for the full prioritized task list.
License
GPL-3.0. See LICENSE.