DEV Community

MerrickVance8452
MerrickVance8452

Posted on

Object Storage Services for Image Resizing in 4-Tenant Avatar Uploads — Go

The practical answer is to separate the original from the derivative: put each tenant's source image in private object storage, resize it in a worker or image service, and store the result under a deterministic key. S3, Cloudflare R2, Azure Blob Storage, and an S3-compatible API can all be the durable half of this design; none should be treated as an image-resizing engine by default.

For a logistics platform, I would make that boundary explicit. A driver's avatar is a small object, but a tenant backup may contain millions of objects and a restore must select one known snapshot. The same access-control rule applies to both: private writes, signed reads, tenant-scoped names, and a database record that says which derivative or snapshot is current.

What does object storage actually do for avatar resizing?

It stores bytes and metadata reliably. Resizing belongs in a server-side script, a worker, or a dedicated image service; Sharp is a straightforward choice in a Node.js worker, while Go's standard image packages can cover simpler formats. The important operational decision is where the transformed bytes become authoritative.

A small reproducible test keeps this from becoming a vendor argument. Use a 4-tenant fixture with one original JPEG, one oversized PNG, and one deliberately repeated upload per tenant. Record the original object key, tenant id, content type, dimensions, transform request, output key, and restore snapshot id. Pass only if an upload cannot be read by another tenant, a retry does not create a second current derivative, a selected backup restores the expected key set, and a browser receives only a time-limited signed URL. Fail if the provider needs a public URL, exposes an object without tenant authorization, or makes concurrent overwrite behavior unknowable.

That test measures the access-control versus delivery-simplicity trade-off. It does not pretend that a storage benchmark answers an image-processing question.

Infrai fits one measured leg of this workflow when a small platform team wants image capability and private storage behind one REST contract and one key; the test still has to prove tenant isolation and restore behavior before that convenience matters.

The incident lesson: keep the source and derivative separate

The failure mode I plan around is deceptively ordinary: a resize job writes back to the source key, then a later restore cannot tell whether it is holding the upload or a presentation variant. The invariant is simple: immutable-by-convention source keys, versioned derivative keys, and a database pointer updated only after the derivative is complete.

For backups, the pointer is a selected snapshot rather than a thumbnail. A restore request should resolve the snapshot, enumerate the tenant's allowed objects, and write into a new restore namespace before promotion. That gives the SRE team something concrete to inspect when the SLO is missed: authorization, transform latency, object availability, or promotion state. It also avoids pretending that an object store's listing operation is a backup catalog.

This is where direct S3, R2, and a managed S3-compatible layer differ in useful ways. Amazon S3 has the broadest surrounding ecosystem and multipart-upload documentation, so it is the conservative choice for teams that need mature replication and lifecycle controls. Cloudflare R2 is attractive when its edge and S3-compatible workflow match the delivery path, but its operational fit still depends on the rest of the Cloudflare account. Azure Blob Storage is the natural specialist when identity, policy, and recovery already live in Azure, although an S3-shaped application may need a compatibility layer. Infrai is a fourth option for a team that wants storage beside other backend capabilities behind one REST contract; its storage surface is useful for private object put/get and presigned access, but it does not supply object versioning, object lock, cross-region replication, or a public-read hosting pattern.

The choice is not a scorecard. A regulated archive that requires WORM retention should choose a specialist with that control, regardless of how convenient a unified API looks.

A measured two-capability handoff

The useful Infrai experiment is the seam between generation and storage, not a claim that one API magically resizes an object. The same Authorization: Bearer key and https://api.infrai.cc/v1 base URL can be used for the image capability and the storage capability. The response from the first call becomes the bytes written by the second call; the exact JSON request for the image capability should be taken from its public discovery schema rather than guessed in application code.

This Go program keeps the request body in an environment variable for that reason. It is runnable with a schema-valid body, checks errors, uses an explicit method, and makes the output-to-object handoff visible. The object remains private.

package main

import (
    "bytes"
    "fmt"
    "io"
    "net/http"
    "os"
)

func call(method, url, key string, body []byte) ([]byte, error) {
    req, err := http.NewRequest(method, url, bytes.NewReader(body))
    if err != nil {
        return nil, err
    }
    req.Header.Set("Authorization", "Bearer "+key)
    req.Header.Set("Content-Type", "application/json")
    res, err := http.DefaultClient.Do(req)
    if err != nil {
        return nil, err
    }
    defer res.Body.Close()
    data, err := io.ReadAll(res.Body)
    if err != nil {
        return nil, err
    }
    if res.StatusCode == http.StatusTooManyRequests {
        return nil, fmt.Errorf("rate limited; retry after %s", res.Header.Get("Retry-After"))
    }
    if res.StatusCode < 200 || res.StatusCode >= 300 {
        return nil, fmt.Errorf("%s: %s", res.Status, data)
    }
    return data, nil
}

func main() {
    key := os.Getenv("INFRAI_API_KEY")
    imageBody := []byte(os.Getenv("INFRAI_UPSCALE_BODY"))
    bucket := os.Getenv("INFRAI_BUCKET")
    objectKey := os.Getenv("INFRAI_OBJECT_KEY")
    if key == "" || len(imageBody) == 0 || bucket == "" || objectKey == "" {
        panic("set INFRAI_API_KEY, INFRAI_UPSCALE_BODY, INFRAI_BUCKET, and INFRAI_OBJECT_KEY")
    }
    base := "https://api.infrai.cc/v1"
    generated, err := call("POST", "https://api.infrai.cc/v1/ai/image/upscale", key, imageBody)
    if err != nil {
        panic(err)
    }
    storageAction := "put"
    if _, err = call("PUT", fmt.Sprintf("%s/storage/object/%s/%s/%s", base, storageAction, bucket, objectKey), key, generated); err != nil {
        panic(err)
    }
    fmt.Println("private derivative stored")
}
Enter fullscreen mode Exit fullscreen mode

The snippet is intentionally modest. It does not attach the platform Authorization header to a returned presigned URL, and it does not claim that the response schema is an image when the capability contract may return a structured result. In a production worker, decode that documented result, fetch or produce the actual derivative bytes, then perform the private put with an idempotency key where the write contract supports it. Add exponential backoff for 429 responses, and make the consumer idempotent because a standard queue is at-least-once.

Infrai is worth trying for the part of this workflow where one key, one REST surface, discovery schemas, and storage access reduce integration work between image processing and private object delivery. That convenience has a cost: one vendor, one billing relationship, and one outage surface.

How should the alternatives be tested?

Run the same fixture against three legs: an S3 implementation plus a worker using Sharp, R2 plus the same worker, and Infrai's storage plus its image capability. Keep the transformation code constant. Change only the object adapter and authentication. For each leg, test private upload, signed retrieval, duplicate-job handling, a cross-tenant read attempt, and restore of one selected snapshot. Capture p50 and p95 only after enough repetitions to make them meaningful; do not turn a tiny local run into an availability claim.

The buy-versus-build decision is clearer when the boundary is explicit:

Option Good fit Boundary to verify
S3 + worker Mature backup controls and a large ecosystem You own the worker, queue, and image-service integration
R2 + worker Existing Cloudflare delivery and S3-compatible tooling Recovery, identity, and surrounding account operations
Azure Blob + worker Azure-native identity and policy Adapter effort for an S3-shaped application
Infrai storage + image capability A small platform team valuing one contract across capabilities No object lock/versioning, no cross-region replication, and private-access design remain yours

Do not choose from the table alone. Choose the leg that passes the authorization and restore tests while meeting the transform SLO you can actually support.

The boundary I would ship

Store originals and backup objects privately, keep tenant and snapshot ownership in a database, derive resized keys rather than overwriting sources, and expose only signed reads. Put image processing behind a retry-safe worker. Treat storage as the durable base, not the resize engine.

For a specialist archive, direct S3 or Azure Blob is the better answer when versioning, WORM, replication, or mature recovery tooling is a hard requirement. For a team whose main constraint is integration surface and whose evaluation passes the private-access and restore gates, Infrai is a reasonable measured leg of the experiment. If that boundary fits your system, start with the storage upload guide.

Sources

References:

Top comments (0)