Read and write etcd¶
An etcd v3 prefix becomes a read-write config layer through
config-etcd, a sibling module, so a consumer who
needs etcd takes it and its etcd client, and one who does not pays nothing. You build the client
and hand it in with a prefix that scopes the read and is stripped from the keys.
import (
"gitlab.com/phpboyscout/go/config"
configetcd "gitlab.com/phpboyscout/go/config-etcd"
clientv3 "go.etcd.io/etcd/client/v3"
)
client, err := clientv3.New(clientv3.Config{Endpoints: []string{"localhost:2379"}})
if err != nil {
return err
}
defer client.Close()
store, err := config.NewStore(ctx,
config.WithFiles(config.OS(), "/etc/app.yaml"), // YAML defaults
config.WithBackend(configetcd.FromClient(client, "app/")), // etcd outranks them
)
if err != nil {
return err
}
port := store.View().GetInt("server.port") // 8080, from app/server/port
An etcd layer takes part in precedence, per-key merge, provenance and hot-reload exactly as a file does.
Connect to etcd¶
Building the client yourself is the default, because it is where every endpoint, credential, TLS and namespace decision lives. Two further rungs exist:
| Use | When |
|---|---|
FromClient(client, prefix) |
You built the *clientv3.Client, or share it with other code. It stays yours to close. |
FromConfig(cfg, prefix) |
You assembled a clientv3.Config and want the adapter to build the client from it. |
Default(prefix) |
Nothing to configure: the environment etcdctl reads. |
// You assembled the config; the adapter builds the client.
b, err := configetcd.FromConfig(clientv3.Config{Endpoints: []string{"localhost:2379"}}, "app/")
// Nothing at all: the environment etcdctl reads.
b, err := configetcd.Default("app/")
store, err := config.NewStore(ctx, config.WithBackend(b), config.WithCloser(b))
defer store.Close()
FromConfig and Default return *OwnedBackend, which must be closed. The adapter built the
etcd client, so it owns the client's connections and goroutines. Hand it to the store with
WithCloser, as above, and closing the store releases it. FromConfig has done this since
config-etcd v0.3.0; before that, nothing could close it. A backend from FromClient is not owned,
and the client you built stays yours to close.
Neither rung contacts etcd, so an unreachable cluster is a Load error. FromConfig returns
ErrNoEndpoints when the config names none, because it does not fall back to localhost. The
reasoning, including why the etcdctl convention was adopted when clientv3 has none, is in
who owns the connection.
Default reads what etcdctl reads¶
clientv3 has no DefaultConfig of its own, but etcd's command-line tool
documents one: every global flag can be set as ETCDCTL_ plus its name. So a
machine that can run etcdctl get against a cluster already has what Default
needs, and a tool built on config reaches the same cluster from the same shell.
Default reads seven variables, once, at construction, and no others:
| Variable | Sets |
|---|---|
ETCDCTL_ENDPOINTS |
the endpoints, comma-separated |
ETCDCTL_CACERT, ETCDCTL_CERT, ETCDCTL_KEY |
TLS, which is on when any of the three is set |
ETCDCTL_USER, ETCDCTL_PASSWORD |
the credentials; ETCDCTL_USER may be user:password when ETCDCTL_PASSWORD is unset, and a password with no user is an error |
ETCDCTL_DIAL_TIMEOUT |
the dial timeout, as a duration such as 3s |
It departs from etcdctl in three places, each on purpose:
- No endpoint is an error, not
localhost:2379.ErrNoEndpointsnames the variable. A configuration store that guesses its source serves wrong values instead of failing. - A user with no password is an error, not a prompt. A library has no terminal to ask on.
ETCDCTL_INSECURE_SKIP_TLS_VERIFYis never read. If a test cluster needs it, build the config yourself and useFromConfig.
A variable it cannot use, such as a dial timeout of 3 with no unit or a
certificate without its key, fails at Default with ErrMalformedEnv, naming the
variable. With ETCDCTL_DIAL_TIMEOUT unset there is no dial timeout at all:
nothing dials until Load, and Load's context bounds it.
Map etcd keys to config keys¶
Keys under the prefix, split on /, become the nested tree:
app/server/host = "localhost" store.View().GetString("server.host") // "localhost"
app/server/port = "8080" → store.View().GetInt("server.port") // 8080
A dot inside an etcd key also separates segments, because a config path splits on dots: app/log.level
reads as log.level. A write to an existing key goes back to that key, dot and all, and a new key
is written with slashes. Two keys that read as one path, such as app/log.level beside
app/log/level, fail the load with config.ErrBackendUnsafe rather than one hiding the other.
Values are strings, or decoded documents¶
etcd stores bytes, so by default every value is a scalar string and the View's typed
accessors coerce it. GetInt("server.port") parses "8080", the same as an environment
variable.
Where a key holds a whole JSON or YAML document, pass a value codec. It can be any
config.Codec, the interface the
sibling format adapters already implement:
import configjson "gitlab.com/phpboyscout/go/config-json"
configetcd.FromClient(client, "app/", configetcd.WithValueCodec(configjson.Codec{}))
A value that decodes to a mapping becomes a subtree at its key's path; anything the codec rejects stays a scalar string. A prefix mixing flat keys and document blobs therefore reads correctly, and enabling a codec never turns a scalar key into a load error.
Leased keys can vanish under you¶
etcd keys can carry a lease and disappear when it expires. This adapter reads them like any other key: the value contributes while it exists, and once the lease lapses the key is simply absent at the next load.
A leased prefix can lose keys with nobody writing anything
That is not a bug, and it is not special-cased. It is what a layer losing a key already means, and merge, provenance and shadowing all handle it the same way they handle a file being deleted underneath a running store.
It is worth knowing before it surprises you. If you did not mean the keys to be ephemeral, use an unleased prefix.
Write back to etcd¶
Unlike the parameter stores, etcd has genuine compare-and-swap, so writes are supported from the first release rather than deferred.
The guard is the revision each key held when the store last loaded, not when the write was
staged. That is the protection worth understanding. If another client changed the key between
your program reading the configuration and deciding to write, the write is refused with
ErrConflict rather than silently overwriting them.
if errors.Is(err, config.ErrConflict) {
// Somebody else moved a key. Reload and re-plan; do not retry blindly.
}
A batch is one transaction¶
Every key in one Apply goes into a single etcd transaction, so it applies completely or
not at all. If any key's guard fails, none of the writes land.
That is stronger than most of this family can offer (AtomicMultiKey is true), and it means
the Store's partial-write rollback path is never needed here.
There is no batch-size limit in the adapter¶
etcd bounds transactions in two ways, and both are server settings your client cannot
know: operation count (--max-txn-ops, 128 by default) and request size
(--max-request-bytes).
This adapter imposes no cap of its own, because a guessed one would be too low on a tuned cluster and too high on a default one. An oversized batch fails with etcd's own error, naming which limit it hit:
Match it with errors.Is against
rpctypes.ErrTooManyOps or
ErrRequestTooLarge. It is not a conflict, and treating it as one would send a caller
into a retry loop that can never succeed.
Watch for changes¶
etcd's watch is a genuine change feed, so a change reaches your observers by push
(NativeWatch: true). The store still passes a foreign change through its settle window and a
value comparison before telling your observers.
An explicit poll interval does nothing here
config.WithPollInterval is accepted and ignored by this backend. There is nothing to
pace: no poll, and no billed-request argument for slowing one down. If your store's
only backend is etcd, setting an interval changes nothing at all.
It still applies to any other backend in the same store that does poll.
The watch starts from the revision just after the one captured at load, not from the head, so a change landing between reading the prefix and the watch attaching is replayed rather than missed. Without that the store could hold stale values with no pending notification and no way to discover it.
The cost is that a revision old enough to have been compacted away cannot be replayed from. When that happens the adapter treats it as "reload and re-watch from the current revision", which is the honest recovery, because the alternative would leave the layer silently unwatched.
Test without etcd¶
The adapter reaches etcd through the three-method configetcd.KV interface (Get, Txn, Watch),
so a unit test hands New a fake and needs no cluster:
type fakeKV []configetcd.Pair
func (f fakeKV) Get(context.Context, string) ([]configetcd.Pair, int64, error) {
return f, 1, nil
}
func (fakeKV) Txn(context.Context, []configetcd.Cmp, []configetcd.Op) (bool, error) {
return true, nil
}
func (fakeKV) Watch(context.Context, string, int64, func()) (func(), error) {
return func() {}, nil // nothing ever changes
}
backend := configetcd.New(fakeKV{
{Key: "app/server/port", Value: []byte("8080"), ModRevision: 1},
}, "app/")
Wrap(client) is the real implementation, and what FromClient uses. The module's integration
suite, under test/integration, runs against a real etcd under testcontainers and is skipped unless
INT_TEST_INTEGRATION=1 is set; it needs a Docker daemon.
Errors you'll see¶
| Error | Cause | Fix |
|---|---|---|
config.ErrConflict |
A key under the prefix changed between load and write. | Reload and re-plan; do not retry blindly. |
config.ErrBackendUnsafe |
The written value is a map, a slice or a lossy number, which a flat key cannot hold. | Write scalars, one key at a time, or a whole document as a string. |
config.ErrBackendUnsafe on load |
Two keys read as one config path, such as app/log.level beside app/log/level. |
Delete the one you do not want. The error names both. |
rpctypes.ErrTooManyOps, rpctypes.ErrRequestTooLarge |
The batch exceeded the server's --max-txn-ops or --max-request-bytes. |
Split the batch. It is not a conflict. |
configetcd.ErrNoEndpoints |
FromConfig got no endpoints, or Default found no ETCDCTL_ENDPOINTS. |
Name the endpoints. |
configetcd.ErrMalformedEnv |
Default found an ETCDCTL_ variable it cannot use. |
Fix the variable the error names. |
fs.ErrNotExist |
Nothing exists under the prefix. | Check the prefix. The store tolerates it as an empty layer unless it is the first source and you set RequireFirstSource. |
Limits¶
- etcd v3 only.
- Not a secrets store. The layer is never marked sensitive; see below.
It is not a secrets store¶
The layer is not marked sensitive, and there is deliberately no option to mark it so.
Kubernetes keeps Secret objects in etcd, so people demonstrably store secrets there. But an etcd prefix has no audit trail of who read a value, no leasing or rotation, and no separation between the credential that reads configuration and the one that reads secrets. Those are exactly what Vault, AWS Secrets Manager and the other secrets backends provide. An option that made etcd look like a secrets store would make the weaker choice the easier one.
If you have secrets in etcd and cannot move them, you are not stuck:
- wrap the backend in
config.Filteredto keep those keys out of the layer, or - contribute those keys through a backend that does declare itself sensitive.
When you don't need this¶
If nothing needs to change between deploys, a file is simpler and cannot be unreachable at start-up.
Reach for etcd when values change at runtime, when you need a write across several keys to be atomic, or when you already run etcd and would rather not add a second system.
What it costs¶
| Adds | the etcd client, on top of the config graph |
| Requires | the config version named in this module's go.mod (go get brings it), and etcd v3 |
The etcd client weighs the same as Consul's and less than half of the Kubernetes client-go.
That measurement decided this adapter was worth building while a ConfigMap adapter was not,
since a mounted ConfigMap is already a directory of files. An allowlist test in
the module pins the footprint, so a transitive upgrade cannot widen it quietly.
Related¶
- How dynamic backends work: what every remote backend shares, and where they legitimately differ
- Read and write Consul: the closest sibling, and the reference this follows
- A directory of files: the Kubernetes ConfigMap case, which needs no cluster client
- Filter a backend: bounding which keys a backend contributes