Rewrites README.md from scratch against the code on next, for #327: what pixa is and how to use it, deployment, design, the settings reference, then development and testing. The Configuration list keeps one item per setting, as TestReadmeNamesEverySetting reads it; the test is unchanged. The statements where the old text disagreed with the code are listed in the PR body. Model: fable-5-1
68 KiB
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.
In this document, the upstream host is the server that holds an original image.
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 into bin/pixad. pixad is the
server program. Compiling needs the libraries script/bootstrap --cgo installs
(see Deployment).
The signing key is pixa's one required 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.
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.trusted_proxiesmust hold the proxy's address as pixa sees it. 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.
configs/Caddyfile is an example for Caddy, a web server.
Caddy does everything in this list without further settings, and gets its own
TLS certificate.
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 limit does not count the database, or files still being written. 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 that default.
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 saves the last-used times it has not yet
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 saved;
- 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 these at run time:
- libvips, the image library pixa converts images with, including its JPEG XL
support. pixa does not start without that support. On Alpine it is the
vips-jxlpackage. The Nix and Homebrew packages of libvips include it. So do the Debian and Ubuntu packages, from Debian 12 and Ubuntu 24.04 on. - libheif, which libvips needs for AVIF.
- the system's CA certificates, which HTTPS fetches need.
Compiling pixa needs the development files of libvips and libheif, pkg-config
and a C compiler. script/bootstrap --cgo installs them, and libvips and
libheif with them. The Dockerfile installs its own packages.
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. The host and every other app use that filesystem too. The default is 75% of the sum of that filesystem's free space and what the cache already holds. So by default the cache may grow to three quarters of the space the host and the other apps have left. The default is also at least 500 MiB. So when that space is 500 MiB or less, the cache may take all of it.- 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 largest rise in resident memory measured while pixa saved one 8192x8192 source, the largest it accepts, to each output format at full size. The rise was largest for a 16-bit JPEG XL of random pixels saved as WebP. At the defaults the total 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). It does not see a port changed only in a mounted config file, 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 and the CA certificates 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 listed path answers 405 to any method the list does not give for it.
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). For the right key it answers 303 to/and sets the login session cookie. For a wrong key it answers 200 with the login page and an error. 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.
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.
Every response carries an X-Request-Id header with the request's ID. A client
can quote it when reporting a problem. A reverse proxy may send an
X-Request-Id header with the request. pixa keeps that ID when it is 1 to 64
letters, digits, -, _ or .. Otherwise pixa makes a random ID, 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. 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 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.
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 is refused with 400 when its query string cannot be
decoded, or when it gives any parameter more than once. A request with a q,
fit or exp that is empty or not valid is refused with 400 too.
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 output 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 most specific of
image/jpeg,image/*and*/*that the header names has aqabove 0, 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 (seconds since 1 January 1970) 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 is refused with 400. An emptyexp=is one example.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 is refused with 401.
Node's base64url and Go's base64.RawURLEncoding encode without padding.
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, fill in the form. Its fields are listed below. Then
submit it (
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 source image's URL. Its scheme is not kept (below).
- The 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). - The format:
orig,auto,jpeg,png,webp,avif,jxlorgif.jxlis selected unless another is chosen. A form sent with an empty format, or none, also makes a JPEG XL URL. - The quality, a whole number from 1 to 100. The page offers five values:
25,50,70,85and100.85is selected unless another is chosen. - The fit, one of the five fit modes (see Image URLs).
coveris selected unless another is chosen. - How long the URL lasts, in seconds, or
0for never. The page offers seven choices: never, 1 minute, 1 hour, 1 day, 1 week, 30 days and 1 year. Never is selected unless another is chosen.
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;
- pixa would fetch the source URL in another form (below).
The page refuses a source URL that pixa would fetch in another form, and shows
the URL pixa would fetch. A URL with a user name and password is one example:
pixa does not send them. A URL ending in ? with no query is another. A path
holding %2F is another: pixa would fetch it as /. So is a path holding (:
pixa would fetch it as %28 (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, and a copied session cookie too. 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. Once the value has changed, the session is refused, after a restart too. 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 needs a signature even when the host is allowlisted. So does one whose host
ends in : with no port. cdn.example.com:8443 is a host with a 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, two other metadata formats;
- 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 first converted to sRGB, the standard colour space, 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. The Docker image has libvips 8.16, so its JPEG XL output carries this block.
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 a
connection slot and for a processing slot. The wait for a shared
upstream_connections slot ends after 10 seconds, and so does the wait for a
processing slot. The wait for an upstream_connections_per_host slot ends only
with downstream_timeout. Keep downstream_timeout longer than
upstream_fetch_timeout plus 20 seconds.
A conversion whose request's downstream_timeout has ended stops waiting for a
processing slot. If its downstream_timeout ends after it has a slot but before
it begins to encode, it gives the slot back. For a shared conversion, the first
request's deadline counts. When a shared conversion stops this way, each request
waiting for it is answered 503 server busy, try again later, even if its own
downstream_timeout has not 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 a conversion can take. They are 8192 on each side of the source and of the output, and 16,777,216 pixels for an AVIF output. Decoding an 8192x8192 source takes up to about 11 seconds. 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, encoding 8192x8192 random pixels takes up to about:
- 1 second as JPEG;
- 15 seconds as PNG;
- 16 seconds as GIF;
- 23 seconds as WebP with an alpha channel, and 16 without;
- 27 seconds as JPEG XL with an alpha channel, and 13 without.
An AVIF output of 4096x4096, 8192x2048 or 2048x8192 random pixels takes up to
about 34 seconds with an alpha channel, and 22 without. At 8192x8192 and quality
85 it would take about 96 seconds with an alpha channel, and 52 without. 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. 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. A fetch that finds every connection to
its host in use waits for one until downstream_timeout ends.
Client limits. The login form (POST /) is limited to 5 attempts per minute
per client address. 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. Both limits count an IPv6 client by its /64.
The client address. Behind a reverse proxy, pixa takes the client address
from X-Forwarded-For only when the request arrives from an address in
trusted_proxies. Otherwise all users behind the proxy count as one client. The
address the request arrives from is not always the proxy's own. A proxy on the
Docker host may connect to pixa over 127.0.0.1. pixa then sees 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, find the address as described under trusted_proxies (see
Configuration). The default trusted_proxies is the RFC 1918 ranges, the
private address ranges. With that default, a client with a private address is
itself trusted. It can then choose the address it is counted by, through its own
X-Forwarded-For. Setting trusted_proxies to only the proxy's address closes
this. Behind a content delivery network (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. By default the database is in WAL mode,
SQLite's write-ahead log (see db_url). SQLite then keeps state.sqlite3-wal
and state.sqlite3-shm beside the file while pixa runs.
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 then at the spacing below, 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, pixa waits at least ten times as long as that check took. Eviction runs every five minutes (below). The next check starts at the first of those runs after that wait. 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, for the
pixa_cache_hits_total metric. 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 config file
(--config), or both. The file is YAML, a plain-text format. A variable present
in the environment wins over the file, even when it is empty. The file wins over
the built-in default. pixa reads a variable's value as a quoted string in the
file. So PIXA_DEBUG=1 turns debug on, but debug: 1 in the file aborts
startup. A setting that holds a list takes comma-separated entries, with the
spaces around each trimmed. An empty variable is an empty list.
The file's env: section is the one exception to that order. pixa sets each
variable named there while it loads the file. So that variable wins over the
environment pixa started with, and over the file's key for the same setting.
pixa refuses to start when a value does not parse or is invalid. The error names
the setting. It also refuses to start when a key in the file is set to null. A
key written as key: with nothing after it is null. Omit the key to use the
default. It refuses to start when the file has a key that is not in the list
below, and the error names the key. It 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. The variables set by the file's
env: section are checked the same way.
pixa also accepts PIXA_CONFIG_PATH, which names the config file, like
--config.
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. An address matches a range that contains it.
blocked_networks and trusted_proxies hold CIDR ranges. For both, 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 ::/0 matches neither it nor a plain
IPv4 address. A range written in that form is read as the IPv4 range it covers:
::ffff:10.0.0.0/104 is 10.0.0.0/8. Such a range shorter than /96 covers
more than IPv4 addresses and aborts startup.
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. 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. 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.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 embedding them. Entries are written and matched as forallowlist_hosts. A request whoseRefererheader names a listed host is refused with 403. That applies to/v1/image/and/v1/e/. It happens before the signature or token is checked, and before the cache or the upstream host is used. The request is refused even when the image is cached. A request with noRefereris served, as many clients send none. So is one whoseRefererdoes not parse as a URL with a host. 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. An invalid CIDR aborts startup. The built-in ranges always apply. They are:0.0.0.0/8, this network,0.0.0.0included;10.0.0.0/8,172.16.0.0/12and192.168.0.0/16, the private ranges;100.64.0.0/10, carrier-grade NAT;127.0.0.0/8, loopback;169.254.0.0/16, link-local;192.0.0.0/24, IETF protocol assignments;198.18.0.0/15, benchmarking;224.0.0.0/4, multicast;::, the unspecified address;::1, loopback;64:ff9b::/96and64:ff9b:1::/48, NAT64;fc00::/7, the unique local (private) range;fe80::/10, link-local;ff00::/8, multicast.
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. 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. A fetch that finds every connection to its host in use waits for one untildownstream_timeoutends (see Limits).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 Go uses (GOMAXPROCS), rounded down, and at least 1. The most images decoded and encoded at once.GOMAXPROCSfollows 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, including an origin with another scheme, such as a browser extension's.metrics.username: variablePIXA_METRICS_USERNAME, default empty. The username for/metrics. pixa serves/metrics, and measures every HTTP request, 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 everyGETandHEADrequest 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:
fx(go.uber.org/fx), a library that builds the program's parts in dependency order and starts and stops them.internal/appputs every part ofpixadin onefxgraph.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:
chi(github.com/go-chi/chi/v5) -
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, for these nine:GET,HEAD,POST,PUT,PATCH,DELETE,OPTIONS,CONNECTandTRACE. Any other method is labelledother. -
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 most Makefile targets are thin shims that call them. We provide:
script/bootstrap— installgit,make, Go, Node, Yarn andprettier, and download the Go modules. It can be run again at any time. With--cgo, it installs a C compiler,pkg-config, libvips (with its JPEG XL support) and libheif instead of Node, Yarn andprettier. Compiling pixa outside Docker, asmake buildandscript/benchdo, needs those. Docker itself must already be installed.script/setup— make a fresh clone ready for development:script/bootstrap, thenscript/install-precommit.script/projectname— output the project name (pixa).script/test— run the test suite: build thetestphase of theDockerfile, writing no image.script/lint— rungolangci-lint: build thelintphase of theDockerfile, writing no image. The linter never runs on the host.script/fmt— format the Go code withgofmtand the markdown withprettier(writes).script/fmt-check— check the same formatting (read-only), on the host.script/check— runscript/test,script/lintandscript/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— measurepixad'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. Every stage of the Dockerfile declares it with an
ARG CACHEBUST line. So every RUN step after that line, the lint and test
steps included, runs on every build instead of coming from the build cache. The
steps above that line 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. The test step
runs go test, and reruns it with -v after a failure. Both 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/checkscript/cibuildscript/dockerscript/lintscript/testscript/setupscript/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. It also builds 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, sopixadfetches and converts each one;herd: every client asks for each new source image at the same moment. The clients then 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,90,95and99are the 50th, 90th, 95th and 99th percentiles;Status CodesandError Set: anything other than200means the other numbers are not for the scenario described, such as503whenpixadwas busy;Bytes In:0, as vegeta is told not to keep the images it receives;pixad peak memory (VmHWM): the peak resident memory of thepixadprocess since its container started, in kB; forhitit includes the request that put the image in the cache;requests to the origin: the fetchespixadmade. 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 several goroutines at once,
one per CPU. A goroutine is a Go thread. The benchmark 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.