Object storage¶
A deployed service can ask the platform for a bucket. The platform makes the service one bucket in the installation's object store and one storage credential that reaches that bucket and no other, and gives the service's program the variables that say where the bucket is and how to sign for it. The service keeps and reads its objects with whatever S3 client its language has: the platform offers no storage API of its own, and the SDKs wrap nothing.
{
"name": "reports",
"service": {
"image": "registry.example.com/acme/reports:1.0.0",
"provisionObjectStorage": true
}
}
A service that does not ask is given nothing: no bucket, no credential and no variable.
What is provisioned¶
The installation's object store is Garage, an S3-compatible store,
running in the namespace garage-system. Per service that asks:
- A bucket, named
<project>.<service>. A project id and a service name cannot contain a dot, so no two services share a name, and a bucket's name is at most 63 characters: a descriptor whose project and service name would make a longer one is refused when it is applied, naming the limit. - An access key, allowed to read, write and own that bucket and nothing else. It is refused by every other bucket, in the same project or another.
- A storage credential Secret,
<service>-storage, in the project's namespace, holding the key.
The service's program is given five variables:
| Variable | Value |
|---|---|
ANKKA_S3_ENDPOINT |
the store's S3 address inside the cluster, http://garage.garage-system.svc.cluster.local:3900 |
ANKKA_S3_REGION |
the region to sign for, garage |
ANKKA_S3_BUCKET |
the bucket's name |
ANKKA_S3_ACCESS_KEY |
the access key's id, from the storage credential Secret |
ANKKA_S3_SECRET_KEY |
its secret, from the storage credential Secret |
Which program receives them depends on the service's hosting. An embedded service's one container has
them. A process-hosted or web-hosted service's own program has them and the platform's program beside it —
the sidecar or the proxy — has none, because nothing of the platform's opens a bucket. A module asks its
config import for them and is told each one.
A service with a bucket does not start until its credential exists: an instance is created only once the Secret it reads is there.
Configuring an S3 client¶
Any S3 client works, configured three ways:
- Path-style addressing. The store has one address and the bucket is in the path,
<endpoint>/<bucket>/<object>. A client that puts the bucket in the hostname reaches nothing. - The region the platform gives,
ANKKA_S3_REGION. A signature is made for a region, and the store refuses one made for any other; most clients default tous-east-1, and are refused withAuthorizationHeaderMalformed. - Checksums only when an operation needs one. Recent releases of the AWS SDKs send a trailing checksum with every upload by default, which the store refuses as an invalid payload signature. Set the client to calculate request checksums, and validate response checksums, only when required.
With the AWS SDK for Java:
private def s3(key: IssuedKey, region: String = "garage"): S3Client =
S3Client
.builder()
.endpointOverride(s3Endpoint)
.region(Region.of(region))
.forcePathStyle(true)
// The SDK's default since 2.30 sends a trailing checksum in an aws-chunked body, which the
// store refuses as an invalid payload signature. A checksum only where an operation needs one
// is what every S3-compatible store accepts.
.requestChecksumCalculation(RequestChecksumCalculation.WHEN_REQUIRED)
.responseChecksumValidation(ResponseChecksumValidation.WHEN_REQUIRED)
.credentialsProvider(
StaticCredentialsProvider.create(
AwsBasicCredentials.create(key.accessKeyId, key.secretAccessKey)
)
)
.build()
In a service, s3Endpoint is ANKKA_S3_ENDPOINT, the region is ANKKA_S3_REGION, and the key is
ANKKA_S3_ACCESS_KEY and ANKKA_S3_SECRET_KEY. From a shell, with curl 8 or later, curl --aws-sigv4
"aws:amz:$ANKKA_S3_REGION:s3" --user "$ANKKA_S3_ACCESS_KEY:$ANKKA_S3_SECRET_KEY"
"$ANKKA_S3_ENDPOINT/$ANKKA_S3_BUCKET/<object>" reads an object. An older curl signs without sending the request's
payload hash, which the store requires: add it as the x-amz-content-sha256 header.
Buckets and objects are never deleted by the platform¶
Deleting a service leaves its bucket, its objects and its storage credential exactly as they were. A
descriptor applied again under the same name, in the same project, is given the same bucket and the same
credential, and services get reports recovered existing bucket. The platform has no operation that
deletes a bucket or an object.
The service itself owns its bucket, so it can delete objects, and can delete the bucket once it is empty. A bucket deleted that way is made again, empty, on the platform's next pass, and the service's credential reaches it.
A credential is made once, and replaced only when a member asks¶
The credential is issued once, when the bucket is first made. Applying the descriptor again, restarting the service or upgrading the platform leaves it as it is. The platform's operator writes the Secret and cannot read it back; the control plane never holds it. The operator does hold the object store's administrator token, as it administers each project's database, and whoever holds that token can reach every bucket.
A member can have the credential issued again — for a credential that has leaked, or a rotation policy:
ankka services storage reissue reports -p shop
A new credential is written where the service's instances read it, and the instances are replaced by a
rolling update once it is there. The old credential goes on working for the rotation grace, an hour as
shipped, so an instance not yet replaced keeps working; after it, the store refuses it. The history
records storage-credential-reissued.
A credential rotated by hand in the store is not seen by the platform: the Secret keeps the old one, and a service using it is refused. If the store loses a key, the operator issues a new one into the same Secret the next time it starts, and a running service reads it when it is restarted.
A bucket reachable from a browser¶
A bucket is reached only from inside the installation until its descriptor asks otherwise:
{
"name": "reports",
"service": {
"image": "registry.example.com/acme/reports:1.0.0",
"provisionObjectStorage": true,
"exposeObjectStorage": true,
"objectStorageOrigins": ["https://app.example.com"]
}
}
The bucket is then reachable at the store's one hostname, storage.<base domain>, with the bucket in the
path: https://storage.<base domain>/<project>.<service>. The service's program is given a sixth
variable, ANKKA_S3_PUBLIC_ENDPOINT, the store's address on the internet, and services get shows the
bucket's address.
A service gives a browser a signed URL: an address for one object, made with the service's credential, that lets whoever holds it read or upload that object until it expires, without the credential.
- Sign against
ANKKA_S3_PUBLIC_ENDPOINT, neverANKKA_S3_ENDPOINT. A signature covers the address it was made for, so a URL signed for the address inside the cluster is refused from a browser. Read and write the service's own objects throughANKKA_S3_ENDPOINT. - Nothing is read without a signature. A request with none is refused by the store.
- An upload from a page needs its origin named. A browser sending a file from a page to the store's
hostname asks first, and is refused unless the bucket admits the page's origin. The descriptor names the
origins in
objectStorageOrigins— eachhttps://hostorhttps://host:port, or*— and the platform sets the bucket's CORS rule from them; the service sets none. Name the page's origin, which is in general not the service's own hostname: a page served by another service, or by a site of its own, uploads from there. No origins is no rule, and a bucket that is not reachable has none whatever the list says. - Turning it off revokes every URL. Applying the descriptor without
exposeObjectStorageremoves the bucket's route, and a URL signed before stops working, whatever its expiry. Only the bucket that asked is reachable: every other bucket's path answers nothing at the store's hostname.
Only a bucket the platform made can be made reachable: exposeObjectStorage without
provisionObjectStorage is refused.
Bring your own object store¶
A descriptor that declares any ANKKA_S3_ variable in its env has an object store of its own, and the
platform makes it no bucket and no credential. services get reports supplied. The variables reach the
service's program as declared, by the same rule as any other variable:
{
"name": "exports",
"service": {
"image": "registry.example.com/acme/exports:2.0.0",
"env": [
{ "name": "ANKKA_S3_ENDPOINT", "value": "https://s3.eu-west-2.amazonaws.com" },
{ "name": "ANKKA_S3_REGION", "value": "eu-west-2" },
{ "name": "ANKKA_S3_BUCKET", "value": "acme-exports" },
{ "name": "ANKKA_S3_ACCESS_KEY", "secretKeyRef": { "name": "exports-store", "key": "id" } },
{ "name": "ANKKA_S3_SECRET_KEY", "secretKeyRef": { "name": "exports-store", "key": "secret" } }
]
}
}
The check is by name, so a value taken from a project secret counts. A descriptor that both asks for a
bucket and declares an ANKKA_S3_ variable is refused, naming the variable.
No descriptor may take a variable from a Secret named <service>-storage or <service>-cloud-storage, its
own service's included, and no project secret may be given such a name: a storage credential reaches its
own service and no other.
On Google Cloud Storage¶
An installation may keep its buckets in Google Cloud Storage instead of Garage, made in the installation's cloud account by its cloud provider; Installing on GKE sets one up. The descriptor is the same on either store, and so are the variables and the client: a service written against Garage runs unchanged.
{
"name": "reports",
"service": {
"image": "registry.example.com/acme/reports:1.0.0",
"provisionObjectStorage": true
}
}
Which store a bucket is in. The installation names the store new buckets are made in
(ANKKA_OBJECT_STORE_BACKEND, garage or gcs). A bucket stays in the store it was made in: an
installation that turns to Google Cloud Storage keeps every bucket it already has in Garage until a member
moves it, and ankka services get says which store a service's bucket is in, its name and its location.
The variables. ANKKA_S3_ENDPOINT, ANKKA_S3_REGION and ANKKA_S3_BUCKET are what the cloud provider
answered for the bucket: Google Cloud Storage's XML API, the region auto, and the bucket's name. The
storage credential is in <service>-cloud-storage, written once by the provider; it is an HMAC key of the
service's own cloud identity, granted on its bucket and on nothing else. Configure the client as for
Garage, with one addition: Google's listing of an object's versions names each one by <Generation>
where S3 says <VersionId>, so without the interceptor below an S3 client reads no version id at all
and a read or delete of "that version" lands on the current one. Google accepts the number as
versionId on a read or a delete; only the listing's element needs renaming.
/** Google lists an object's versions as `<Generation>`; S3 clients read `<VersionId>`. */
private object GenerationAsVersionId extends ExecutionInterceptor:
override def modifyHttpResponseContent(
context: Context.ModifyHttpResponse,
attributes: ExecutionAttributes
): Optional[InputStream] =
val listsVersions = context.httpRequest().rawQueryParameters().containsKey("versions")
if !listsVersions || !context.responseBody().isPresent then context.responseBody()
else
val xml = new String(context.responseBody().get.readAllBytes(), UTF_8)
.replace("<Generation>", "<VersionId>")
.replace("</Generation>", "</VersionId>")
Optional.of(new ByteArrayInputStream(xml.getBytes(UTF_8)))
private lazy val s3: S3Client =
S3Client
.builder()
.endpointOverride(URI.create(endpoint)) // ANKKA_S3_ENDPOINT
.region(Region.of(region)) // ANKKA_S3_REGION
.forcePathStyle(true)
.requestChecksumCalculation(RequestChecksumCalculation.WHEN_REQUIRED)
.responseChecksumValidation(ResponseChecksumValidation.WHEN_REQUIRED)
.overrideConfiguration(
ClientOverrideConfiguration.builder().addExecutionInterceptor(GenerationAsVersionId).build()
)
.credentialsProvider(credentials) // ANKKA_S3_ACCESS_KEY, ANKKA_S3_SECRET_KEY
.build()
The name. A bucket's name in Google Cloud Storage is shared with every other customer of Google's,
so it is the installation's prefix, the project, the service and a digest of the two:
acme-shop-reports-ae7cc739. The cloud provider names it and the status reports it; read it from
ANKKA_S3_BUCKET, never derive it. A name another Google customer holds is a bucket the provider refuses.
Until the provider has answered. The service's instances start only once the provider has answered:
until then the service is UpdateInProgress, saying what it waits for. A bucket the provider refuses
starts no instance at all, since a service that asked for a bucket cannot be relied on to run correctly
without one: the service is Failed, with the provider's reason in its status. Instances already running
when a refusal comes are left as they are.
Reachable from the internet, and a browser's origin¶
A bucket in Google Cloud Storage is reachable from the internet by anyone holding a valid signature,
whether or not its descriptor asks for exposeObjectStorage: Google's address answers every signed
request. Exposure decides only two things: the service is given ANKKA_S3_PUBLIC_ENDPOINT, Google's
address, and ankka services get shows the bucket's address on the internet; and the bucket's CORS rule
admits the descriptor's objectStorageOrigins. A request without a signature is refused by every bucket.
The cloud provider sets the CORS rule from the descriptor, as the operator does on Garage, and the service
sets none: a rule a service sets through S3 is refused by Google Cloud Storage.
Versions are kept, and every version can be deleted¶
Every bucket in Google Cloud Storage keeps versions. An object overwritten or deleted stays readable as a
noncurrent version, and a deleted object can be recovered for the installation's soft-delete window
(ANKKA_OBJECT_STORE_SOFT_DELETE_DAYS, 7 days as shipped), which ankka services get shows. No bucket has
a retention policy: the platform refuses no deletion on account of an object's age, and keeping a
document for as long as a rule requires is the service's own to do. Deleting every version of an object
removes it once the soft-delete window has passed:
test("deleting every version of an object leaves none listed") {
val key = fresh("passport.pdf")
put(key, "first")
put(key, "second")
for (id, _) <- versions(key) do
s3.deleteObject(
DeleteObjectRequest.builder().bucket(bucket.get).key(key).versionId(id).build()
): Unit
assertEquals(versions(key), Vector.empty)
}
Versions are stored and paid for. objectStorageVersionAgeDays deletes a version that is no longer current
after that many days; Garage, which keeps one version, accepts it and does nothing.
A bucket is encrypted, with the installation's wrapping key when it names one (ANKKA_CLOUD_KMS_KEY). The
window and the key are the installation's when the bucket is made, and stay as they were when the
installation changes them, until a member applies them again:
ankka services storage reapply-settings reports -p shop
Where a bucket is made¶
A bucket is made in its project's location when the project names one, and in the installation's
(ANKKA_CLOUD_LOCATION) otherwise. A bucket's location is fixed when it is made.
ankka projects location set europe-west6 -p shop
ankka projects location clear -p shop
A service with no storage credential¶
A service written with Google's own client can reach its bucket as its cloud identity, with no key at all: its Kubernetes ServiceAccount is bound to that identity, so the client's default credentials reach its bucket and no other. Its descriptor declines the credential:
{
"name": "reports",
"service": {
"image": "registry.example.com/acme/reports:1.0.0",
"provisionObjectStorage": true,
"objectStorageCredential": false
}
}
Such a service is given ANKKA_S3_ENDPOINT, ANKKA_S3_REGION and ANKKA_S3_BUCKET and no key, and none is
issued for it. It cannot sign URLs with an HMAC key. An installation whose store is Garage refuses the
descriptor, since a bucket there is reached only with a storage credential.
Moving a bucket from Garage¶
A member moves one service's bucket from Garage to Google Cloud Storage:
ankka services storage move reports -p shop --write-pause-bound 30m
A mover the operator runs inside the installation, holding the service's two storage credentials and no
other, copies every object into a bucket made for the service in Google Cloud Storage while the service
goes on writing. Then, for a write pause no longer than its bound (10 minutes as shipped, from one minute
to a day), the service's key in Garage reads and does not write, what changed since the copy began is
copied, and every object is checked on both sides. Once they match, the service is given the variables of
its new bucket and its instances are replaced. ankka services get says where the move is: the pause,
since when and how long it may last.
A move that fails, or whose write pause reaches its bound, gives the service its writes back in Garage, and the status says why; it may be asked for again, and a second run copies only what is missing. The bucket in Garage is left as it was: the platform deletes nothing. An object larger than 5 GiB does not move.
The store in an installation¶
The object store is the garage component of the installation's overlay. On its own it is one node with
one volume, so a bucket's durability is that volume's, which is what a local platform runs.
An installation in a cluster lists garage-replicated after it: three nodes, one per machine, each
object kept on all three, so losing a machine and its volume loses nothing. A small Deployment,
garage-layout, connects the nodes and gives each its role, and goes on doing so every fifteen
seconds: the node that replaces a lost one, which comes up with a new identity, is given the lost
node's role and Garage copies the objects back to it. Until it has a role it refuses what it is sent,
so for those seconds about one request in three is refused.
garage-copy copies every bucket, the services' and the platform's backups alike, to a secondary store
outside the cluster every hour, with rclone sync, so an object deleted from Garage is deleted from the
copy at the next run. The secondary's address and credential are the Secret garage-secondary in
garage-system, with ENDPOINT, ACCESS_KEY_ID, SECRET_ACCESS_KEY and REGION, which the
installation creates, beside garage-copy-key, the key the copy reads Garage with (ACCESS_KEY_ID,
GK and 24 hex digits, and SECRET_ACCESS_KEY, 64), which the copy imports into Garage itself. Each
copy writes what it did to the ConfigMap garage-copy-status, and
ankka status reports when the last one completed, when one last failed and why, and how many objects
it deleted. Without a completed copy, the installation's backups share its cluster's failure domain,
and the status says so. An installation that requires a copy, with ANKKA_BACKUP_COPY_REQUIRED=on, does
not call a project backed up until its latest base backup has been copied.
The platform's own buckets, platform.backups.<project> and platform.backups-controlplane, hold the
databases' backups; no service can be given a credential for them. Inside the cluster the store speaks plain HTTP, behind a network
policy that admits only the installation's workloads, the gateway and the operator; a request from a
browser is encrypted as far as the gateway. A request is signed, so a secret key never crosses the
network, but an object's contents do.
An installation without the component has no object store. A service that asks for a bucket there is
reported object storage provisioning failed, with the reason, and none of its instances starts.