The project is inspired by Serverless Sharp but doesn't seem to be maintained anymore. The core features have been kept, with some bugs being fixed while other features being temporarily cut. Some might be reintroduced in near future releases. With this solution, you can get your image service without relying on other paid solutions from Cloud providers such as CloudFlare, Akamai, Imgix to name a few.
The advantages of having your custom solution are flexibility, lower costs, and customization.
More about on my website: Serverless Image-Service
- Table of Content
- About
- Setup
- Local Development
- How to Deploy
- Differences from Venveo's service
- Consuming The Service Client-Side
- Limits
- How to Contribute
The service is meant to be consumed from both a client App to fetch images from the GET route, and a CMS to manage static assets by uploading and removing them from an AWS S3 Bucket through the POST and DELETE routes. It is capable of storing incoming binary data and serving outcoming images with additional processing for size and quality on-demand. Image processing is handled by the Sharp library.
Warning, this is enabled by default, if you deploy your S3 Bucket will have Host for Static Site enabled and access will be public!
Currently, I'm investigating how to bypass the Lambda triggers if the request has no query parameters aka. it doesn't require any processing over the image requested. Something might be achievable by tweaking the API Gateway config but I don't have a certain answer yet.
Anyway, the service is going to perfectly work with a private S3 Bucket, you can disable this by commenting the following lines in serverless.yml
before deploying:
WebsiteConfiguration:
IndexDocument: " "
PublicAccessBlockConfiguration:
BlockPublicAcls: false
BlockPublicPolicy: false
IgnorePublicAcls: false
RestrictPublicBuckets: false
Alternatively, you can comment just the first 2 lines to disable Static Hosting and change the remaining values to be true
The only CDN solution offered by this service is AWS CloudFront
, which serves as Caching place for avoiding useless stress on Lambda
in case of high traffic aka. many requests at once, Lambda
's free tier might be generous with 1M requests/month free, but why waste them?
If you have access to an external CDN that can also Cache content from the Origin then it would be a good idea to register your CloudFront
distribution as Proxy to a DNS and Cache there as well successful responses.
Depending on costs on both sides and overall traffic, by using this strategy you could easily use the entire solution for free! More about this in my article
Method | Route | Description | Content-Type In | Content-Type Out | CORS | Cache | Lambda |
---|---|---|---|---|---|---|---|
GET |
/ |
List Images | not-required |
application/json |
none |
none |
image-service-<stage>-list |
GET |
/{any+} |
Get Image | not-required |
image/* or application/json |
none |
2592000 |
image-service-<stage>-get |
POST |
/{any+} |
Upload Images | multipart/form-data or not-required |
application/json |
CUSTOM_DOMAIN |
none |
image-service-<stage>-post |
DELETE |
/{any+} |
Remove Image | not-required or application/json |
application/json |
CUSTOM_DOMAIN |
none |
image-service-<stage>-delete |
Gets a list of all the images in the S3 Bucket (currently limited to 1000 keys). It has been designed for debugging purposes only, but can be extended to list subpaths as well as being so integrated into CMS workflows.
It responds with:
- π’ Success:
[ { "Key": "path/image-second.png", "LastModified": "2022-04-15T13:35:23.000Z", "ETag": "firstimageetagrandomhsh123456789", "ChecksumAlgorithm": [], "Size": 182728, "StorageClass": "STANDARD" }, { "Key": "image.jpg", "LastModified": "2022-04-15T13:35:22.000Z", "ETag": "secondimageetagrandomhsh12345678", "ChecksumAlgorithm": [], "Size": 403063, "StorageClass": "STANDARD" }, { "Key": "random/path/image-third.png", "LastModified": "2022-04-15T13:35:23.000Z", "ETag": "thirdtimageetagrandomhsh98765432", "ChecksumAlgorithm": [], "Size": 1599028, "StorageClass": "STANDARD" }, { ... } ]
- π΄ Error:
{ "name": "S3Exception", "status": 404, "code": "NoSuchBucket", "message": "The specified bucket does not exist" }
This endpoint has 2 purposes, based on receiving query parameters or not:
Without query params
: It returns the original file from the key provided (path + filename)With query params
: Attempts to fetch the original file and processes it based on what options are supported before returning it.
For some codec and config reasons, some formats that are applied q=70
or higher, output a bigger size image than the original.
GET
https://domain.com/random/path/image.jpg?w=300&h=150&q=65&fm=webp
It responds with:
-
π’ Success:
-
π΄ Error:
{ "name": "S3Exception", "status": 404, "code": "NoSuchKey", "message": "The specified key does not exist." }
{ "status": 500, "code": "internal-error", "message": "Error: Expected positive integer for height but received -350 of type number" }
Currently, the following query parameters are supported:
Resizing Operations | Docs
w=
|<Integer>
: π | A positive number of px that represents the new width which the image is requested to scale ath=
|<Integer>
: π | A positive number of px that represents the new height which the image is requested to scale atf=
|<String>
: π | The fit for when both width and height are used, can becover
,contain
,fill
,inside
oroutside
. Defaults tocover
p=
|<String>
: π | The position for when fit is eithercover
orcontain
. Can betop
,right bottom
,left top
... or cardinalnorth
,southeast
,west
,... Defaults tocenter
, full list of available values in Docs.bg=
|<Object>
: π | The ackground colour when using afit
ofcontain
k=
|<String>
: π | The kernel to use for image reduction. It can benearest
,cubic
,mitchell
,lanczos2
,lanczos3
(default)ex=
|<Object>
: π | Extends the edges of the image with the provided background colour.cb=
|<Object>
: π | Extract/crop a region of the image before resizingca=
|<Object>
: π | Extract/crop a region of the image after resizingtr=
|<Integer>
: π | Trim "boring" pixels from all edges that contain values similar to the top-left pixel
Image Operations | Docs
r=
|<Integer>
: π | An integer number that represents the rotation degree at which the image will be rotated. Negative numbers allowed for counter-clockwise rotations.flip=
|<Boolean>
: π | If true will mirror the image on the Y axisflop=
|<Boolean>
: π | If true will mirror the image on the X axisaf=
|<Array>
: π | If a validArray
is passed will perform an affine transform on the image based on offset values inside theArray
afbg=
|<String>
: π | The background in Hex for the affine transform, defaults to full black#000000
afi=
|<String>
: π | The Interpolator for the affine transform, can be one ofnearest
,bilinear
,bicubic
,lbb
,nohalo
,vsqbs
. It defaults tobicubic
sh=
|<Object>
: π | Sharpen the image, requires a valid JSON Object as value, more details about individual keys in the Docsmd=
|<Integer>
: π | Apply a Median filter over the image. Value is aninteger
, represents the square mask NxNbl=
|<Float>
: π | Blur the image by the value, which represents the sigma of the Gaussian mask. Values accepted in the range 0.3 and 1000,float
orinteger
types.fl=
|<String>
: π | Flatten, merge alpha transparency channel, if any, with a background, then remove the alpha channel. Value is a Hex colorgm=
|<Array>
: π | Gamma correction. Value is anarray of floats
, first element isgamma in
second isgamma out
ng=
|<Boolean>
: π | Produces the Negative of the image. Value is aboolean
nr=
|<Boolean>
: π | Normalize output image contrast by stretching its luminance to cover the full dynamic range, Value is aboolean
cl=
|<Object>
: π | Enhance the clarity of the image by bringing out darker details through Clahe. Value is an Object withwidth
height
and optionalmaxSlope
params. More in the Docscv=
|<Object>
: π | Convolve with a specific kernel, more about value in Docsth=
|<Integer>
: π | Any pixel value greater than or equal to the Threshold value will be set to 255, otherwise it will be set to 0bo=
|<Object>
: π | Perform a bitwise Boolean operation with operand image. you can passand
,or
andeor
asoperator
, and a link to an image to fetch tooperand
. Doesn't support local files yetli=
|<Array>
: π | Apply the Linear formula a * input + b to the image (levels adjustment)rc=
|<Array>
: π | Recomb the image with the specified matrix.mo=
|<Object>
: π | Modulate transform the image using brightness, saturation, hue rotation, and lightness. See Object structure in Docs
Color Manipulation | Docs
t=
|<Object>
: π | Tint the image using the provided chroma while preserving the image luminance. Value is an Object withr
g
b
props. Alpha is ignored.g=
|<Boolean>
: π | Convert to 8-bit greyscale if the value istrue
or1
pc=
|<String>
: π | The input image will be converted to the provided colourspace at the start of the pipeline. Possible values:multiband
,b-w
,histogram
,xyz
,lab
,cmyk
,labq
,rgb
,cmc
,lch
,labs
,srgb
,yxy
,fourier
,rgb16
,grey16
,matrix
,scrgb
,hsv
,last
,tc=
|<String>
: π | Set the output colourspace. Possible values same as above
Channel Manipulation | Docs
ra=
|<Boolean>
: π | if value istrue
then Remove Alpha channel fromimage.jpg
if anyea=
|<Float>
: π | Ensure Alpha channel onimage.jpg
by the number in the valueec=
|<String>
: π | Extract a Single Channel fromimage.jpg
. Possible values arered
,green
,blue
andalpha
jc=
|<Array>
: π | Join more channels intoimage.jpg
. Value is an array of links to images with different channels to be fetched and then merged. Warning, is error prone, use carefully.bb=
|<String>
: π | Perform a Bitwise Boolean operation on all input image channels (bands) to produce a single channel output image. Possible values areand
,or
andeor
Compositing Images | Docs
wm=
|<String>
π | The name of the Watermark to be applied over the image. Static assets must be stored inside thesrc/assets
directorygr=
|<String>
π | The position where to apply the Watermark on the original image. Defaults tosouthwest
, other positions are described as cardinal points,northeast
,west
,center
...
Output Options | Docs
q=
|<Integer>
: A positive number between 1 and 100 that represents the new quality which the image is requested to be compressed atfm=
|<String>
: π | The name of the format you want to convert the original image, if not supported returns the original format with other eventual optimizations applied. Still experimental, stating to Sharp Docs you can pass the following values:jpeg
,png
,webp
,gif
,jp2
(not yet supported),tiff
,avif
,heif
,raw
,ll=
|<Boolean>
: It allows to enable Lossless Compression when available, you can pass booleanstrue
orfalse
or integers0
or1
. It defaults tofalse
if not passed or other stranger values are detected.
Since these parameters can be chained into one request, their actions need to coexist in the final image. Some rules apply when for example you get both w
and h
in the same request, or when you have just one of them but also q
Order doesn't matter between Query Parameters
Following are some examples of Query Parameter
usage:
Think of
image.jpg
as πΊ
Uploads one or many images to a specific path inside an S3 Bucket. Once provided /path/to/upload
the function will attempt to upload all the files provided under it, if any of the selected filenames are already contained inside the same path, it will throw a conflict error.
Note that this endpoint is supposed to receive a Content-Type: multipart/form-data
payload format to work but this depends on the library or tool you use to make the request.
If using fetch
or Thunder for example you won't have to add the Content-Type
header at all, since they handle the situation in the background, the header itself also contains a boundary that is used to mark the beginning and the end of the payload, as well as distinguish the various files, or parts, which it's composed of.
You'll still have to pass a binary of multipart/form-data
as body on your request though.
The structure of the payload on the client-side looks like this:
{
data: ImageBuffer.jpg
data: ImageSecondBuffer.png
data: ImageThirdBuffer.webp
data: ...
}
The key for the multipart/form-data
has to always be data
because other metadata such as name and extension are already contained within the ImageBuffer that is in this case the binary representation of the image we want to upload, and they will be parsed back by Lambda once received in a correct form.
There are many ways to construct a valid payload compatible but it differs from the client App and its libraries.
An example with React/Next.js is provided in the related paragraph
The raw data, once reaches Lambda, due to API Gateway policy, it forcefully encodes the request body into base64
, forcing Lambda to decode it back into binary
if we want to parse it further from it's multipart/form-data
format.
I still haven't found a hack for this, avoiding useless data transformation would be ideal since it would be less prone to bad parsing.
Once the data gets parsed, it's directly written on the S3 Bucket from the Buffer within the RAM, without being written on Lambda's ephemeral storage first.
It responds with:
- π’ Success:
{ "status": 200, "code": "success", "message": "Images uploaded successfully!", "ETags": [ { "link": "https://domain.com/random/path/image.jpg", "ETag": "firstimageetagrandomhsh123456789" }, { "link": "https://domain.com/random/path/image-second.png", "ETag": "secondimageetagrandomhsh12345678" }, { "link": "https://domain.com/random/path/image-third.webp", "ETag": "thirdtimageetagrandomhsh98765432" }, { ... } ] }
- π΄ Error:
{ "status": 400, "code": "internal-error", "message": "Malformed or missing incoming data" }
{ "status": 409, "code": "already-exists", "message": "Images [ image.jpg, image-second.png, ... ] already exist within the requested path /random/path" }
Removes images that correspond to the key (path + filename) provided with the request. The payload is an application/json
that has the images
key a list of string filenames to be removed within the path you invoked the function over. It can delete as many files as you pass to it. they just have to all be already stored within S3, if any of keys aren't available will throw an error, if all files have been deleted will return a successful message.
Payload:
{
"images": ["first.jpg","second.jpg", "thisDoesntExist.jpg"]
}
It responds with:
- π’ Success:
{ status: 200, code: "success", message: "Images removed successfully!", }
- π΄ Error:
{ "status": 404, "code": "not-found", "message": "No image passed for removal" } { "status": 404, "code": "not-found", "message": "Images [ first.jpg, second.jpg ] don't exist under the requested path /random/path" }
Clone and install NPM dependencies:
git clone https://github.com/serban-mihai/serverless-image-service.git
cd serverless-image-service && npm i
There are a couple of things to be done before deploying:
- Setup your
AWS_CREDENTIALS
within your local environment, being it you machine, a Docker container or a CI/CD Pipeline secrets. More about permissions needed in Serverless Docs - Create an
AWS Certificate
in ACM on theus-east-1
region that belongs to yourdomain.com
and register theCNAME
inside your external CDN or in Route53. Also, remember to apply the necessary adjustments to your CDN for SSL/TLS traffic to avoid funky responses from API Gateway - Adjust
example-s3-bucket-policy.json
by changing the<CUSTOM_DOMAIN>
with yourdomain.com
. You will have multiple files for different environments if you use different domains or subdomains - Copy
example.settings.yml
tosettings.yml
and adjust missing values such as theregion
,CUSTOM_DOMAIN
, andACM_CERTIFICATE_ARN
which is the ID of the Cert you created at step one. Note that theSOURCE_BUCKET
andCUSTOM_DOMAIN
will have to be equal within the same stage - Make sure not to already have an S3 Bucket on AWS with the same name of
CUSTOM_DOMAIN
Optional
Place insidesrc/assets/
any Watermark of your choice to apply it further over images.Optional
If you don't want to include GET (List), POST and DELETE routes deployed you can just comment them inserverless.yml
. That will just deploy the GET that will serve assets to clients, leaving up to you to upload manually assets within theS3 Bucket
or integrate this operation with another service.
If you opt for not deploying POST and DELETE you can disable CORS as well on S3 resource sharing by commenting the following lines in serverless.yml
:
CorsConfiguration:
CorsRules:
- AllowedMethods:
- "POST"
- "DELETE"
AllowedOrigins:
- "*"
AllowedHeaders:
- "*"
ExposedHeaders:
- "x-amz-server-side-encryption"
- "x-amz-request-id"
- "x-amz-id-2"
MaxAge: 3000
The reason we are creating the Certificate in us-east-1
is that for some reason AWS won't accept to create resources in other regions such as eu-central-1
if the Certificate also belongs in eu-central-1
After the above points are checked everything should be ready to go for deployment.
- After the
CloudFormation Stack
deploys, add aCNAME
of the createdCloudFront Distribution
within your external CDN DNS or Route53 and Proxy traffic through it. Thedistribution
looks like:randomhash0123.cloudfront.net
and has to be assosiated with the name of yourS3 Bucket
.
Record Example: Type:
CNAME
| Name:my.domain.com
| Value:randomhash0123.cloudfront.net
To debug endpoints I recommend the Thunder Client extension for VSCode, it's feature-rich and has everything you need to send requests and debug endpoints. If you don't find yourself comfortable you can also use Postman instead, or curl
if you're a true hardcore!
You can find both Thunder and Postman Collections and Environment in their directories inside the repo, they have predefined requests that cover all the functionality of the service, import them and change the environment accordingly with your domain, path, and filename
Before running the localhost environment consider importing into either Thunder or Postman their corresponding Collections and Environments.
You can keep the *-local.json
and change just filename and path as you debug.
For local development serverless-offline
plugin is used, to use it you first need to Deploy it. After the deployment succeeds, you can run sls offline --stage <YOUR_STAGE>
or from NPM npm run offline:<YOUR_STAGE>
and use Thunder or Postman against the local
Environment.
To deploy the app you can either use sls deploy --stage <YOUR_STAGE>
if you have Serverless installed Globally, or use the NPN script desired you can find in package.json
, ex npm run deploy:dev
will deploy on dev environment.
At first, deployment is going to take a bit longer, future redeploys will end faster. If an error is returned while deploying, before swearing, you can check your stack in AWS CloudFormation
, under events there is a remote chance to find something useful.
If this doesn't help feel free to open an issue π
If you plan to debug your remote environments (dev, staging, prod) you can use the Thunder and Postman Collections with the *-prod.json
environment.
Just make sure to adjust the values for the env variables before.
Along with the edits to almost all the code structure, there are still a couple of things unchanged such as the security chunk.
- Switched from Object-Oriented to Functional programming paradigm
- Updated dependencies and Serverless version to V3
- Removed deprecated code on both Node and Serverless sides
- Included
upload
,delete
andlist
of static assets - S3 Bucket and Policy are created at deploy time based on the custom domain you want the service to run on
- Query params parser corrector, all defined query params (w, h, q) are recognized and applied to the returned image
- Fixed images paths without query params not being displayed
- Fixed image scale-up when
w=
and/orh=
values are higher than the original image's width and/or height - Packaged just the essential files within Lambdas
- Added Thunder Client and Postman Collections for easy debugging
- Removed Tests
What needs to be addressed soon:
- Add support for remaining Resizing Operations
- Add support for Image Operations
- Add support for Color Manipulation
- Add support for Channel Manipulation
- Personal favourite, add watermark with custom position, can be achieved with Compositing
- Add Images under each option in the Docs
- Extend
DELETE
endpoint to remove multiple assets at once, similar toPOST
but reversed. - Implement TypeScript!
- Enforce watermark with query param for gravity but without query param for name with a Serverless flag, to avoid public expose of raw images without branding.
- Allow
Base64
encoding for long and explicit param values (Arrays and Objects) - (Undecided) Allow for deploys over custom base paths
/images/random/path/image.jpg
. Not recommended - Create presets for popular transforms that can be applied all at once with a special query param and have priority over other query parameters
- Find a way to bypass Lambda when no query params are detected by API Gateway and get the asset from S3 Static Site (requires public access)
- Test the security
s=""
query parameter or change it with another solution - Review security and
binaryMediaTypes
from API Gateway to disallow certain file types to be uploaded/served - Test uploading other files besides images, restrict or let pass other MIME Types with a flag on Serverless
- Solve bugs within the image processing, such as the size being larger than the original with
q=70
or higher - Test and ensure CloudFront's Cache working properly to avoid Lambda throttling
- Establish an efficient CLI Rollback of CloudFormation Stack from Serverless, it breaks because buckets related are not empty before removed
- Introduce Unit Tests back
On the client-side, the App needs to communicate with the service through any library that can send HTTP requests, while most endpoints are pretty straight forward there is one, in particular, that needs more work to make it work properly, the Upload Image POST
As described above needs to receive a POST request with a binary multipart/form-data
body. Every framework/library has different ways to pack such an object, I'll show how I do it using React/Next.js and the fetch
library.
import { useRef } from "react"
const App = () => {
const form = useRef(null);
const postImage = async (e) => {
e.preventDefault();
const data = new FormData(form.current);
const options = {
method: "POST",
body: data,
};
const res = await fetch("https://domain.com/random/path", options);
const loaded = await res.json();
console.log(loaded);
};
return(
<div>
<form
ref={form}
encType="multipart/form-data"
onSubmit={postImage}
>
<input
multiple
type="file"
placeholder="Upload"
name="data"
onChange={changeHandler}
/>
<input
type="submit"
value="Post"
/>
</form>
</div>
)
}
The FormData
is the interface you should be targetting when packing an object that contains the images you want to send over to be uploaded.
Most of the time you won't need to include any Content-Type
header into the request, libraries know how to attach it automatically because the full header for such a request in its full form would look something like this multipart/form-data; boundary=---------------------------157259096020916242283640002646
. That boundary
is the separator between each object in the request, each image in our case. It generates when a FormData
is created and the service is parsing this once the request gets to Lambda
, and since you don't have to worry about it before sending the request, that's a win from both sides!
Many limits are still unknown due to the early life of the project, this thing was just born π
- There is a limit of 10Mb max for payloads on the
POST
route, meaning you can't upload 50 images at once unless they're thumbnails - I'm still breaking things, will update as soon as something happens...
If you want to contribute to the project just clone it, move to a branch with a simple naming convention with-this-format
and push your branch, then open me a PR with some information about your changes and I'll take a look.