Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Kubernetes

The Helm chart in charts/dns-rs runs dns-rs as stateless replicas. A v* tag publishes it as an OCI artifact (see Releasing):

helm install dns-rs oci://ghcr.io/evanndev/charts/dns-rs --version 0.1.0 \
  --namespace dns --create-namespace

Replace 0.1.0 with the version you want. To install the development version, use a checkout of the repository instead:

helm install dns-rs charts/dns-rs --namespace dns --create-namespace

The chart creates:

ObjectNameContent
Deployment<release>-dns-rsreplicaCount replicas (default 2); each loads the configuration and the blocklists itself
ConfigMap<release>-dns-rsdns-rs.toml, from config, mounted at /etc/dns-rs
Service<release>-dns-rs-dnsDNS on service.dns.port (default 53), UDP and TCP, to container port 5353
Service<release>-dns-rs-httpProbes, metrics and API on service.http.port (default 8080)
Secret<release>-dns-rs-apiThe API token, only when api.token is set
PodDisruptionBudget<release>-dns-rsmaxUnavailable pods, only with podDisruptionBudget.enabled and more than one replica
ServiceMonitor<release>-dns-rsScrapes /metrics of the http Service, only with serviceMonitor.enabled
NetworkPolicy<release>-dns-rsIngress rules, only with networkPolicy.enabled

If the release name already contains dns-rs, the prefix is not repeated (release dns-rs gives dns-rs, dns-rs-dns and dns-rs-http). Run helm template to see the exact names.

Values

ValueDefaultMeaning
replicaCount2Number of replicas, at least 1
image.repositoryghcr.io/evanndev/dns-rsImage repository, see Container image
image.tag""Image tag; empty means the chart’s appVersion
image.pullPolicyIfNotPresentAlways, IfNotPresent or Never
imagePullSecrets[]Pull secrets
upstream.server1.1.1.1:53Upstream resolver (DNS_RS_UPSTREAM); the port defaults to 53
upstream.timeoutMs2000Upstream timeout in milliseconds (DNS_RS_UPSTREAM_TIMEOUT_MS)
cache.size10000Maximum number of cached responses (DNS_RS_CACHE_SIZE)
config[blocking] with mode = "null"The reloaded part of the configuration file, as TOML text
service.dns.typeClusterIPClusterIP, NodePort or LoadBalancer
service.dns.port53Service port for DNS (UDP and TCP)
service.dns.externalTrafficPolicy""Cluster or Local; empty leaves the Kubernetes default; the schema rejects it on a ClusterIP Service
service.dns.annotations{}Service annotations
service.http.typeClusterIPClusterIP, NodePort or LoadBalancer
service.http.port8080Service port for HTTP
service.http.annotations{}Service annotations
api.token""API token, at least 16 characters (the schema rejects shorter ones); the chart stores it in a Secret. Prefer api.existingSecret: a token in values ends up in Helm’s release history
api.existingSecret""Name of a Secret you manage; set it or api.token, not both (the chart fails to render otherwise)
api.existingSecretKeytokenKey of the token in api.existingSecret
podDisruptionBudget.enabledtrueRenders a PodDisruptionBudget, only with more than one replica
podDisruptionBudget.maxUnavailable1Pods that may be unavailable during voluntary disruptions
topologySpread.enabledtrueSpreads replicas across nodes (kubernetes.io/hostname, maxSkew: 1, ScheduleAnyway)
serviceMonitor.enabledfalseRenders a Prometheus Operator ServiceMonitor for /metrics
serviceMonitor.interval30sScrape interval
serviceMonitor.labels{}Extra labels, for example to match a Prometheus serviceMonitorSelector
networkPolicy.enabledfalseRenders a NetworkPolicy (ingress only)
networkPolicy.dns.from[]NetworkPolicyPeers allowed to query DNS (port 5353); empty means everyone
networkPolicy.http.from[]NetworkPolicyPeers allowed on port 8080 (metrics, API); empty means nobody
tests.enabledtrueRenders the helm test pod, see Testing a release
tests.imageregistry.k8s.io/e2e-test-images/jessie-dnsutils:1.7Image with dig for the test pod
tests.queryexample.comName the test pod asks for
resourcesrequests 50m CPU and 128Mi memory, limit 256Mi memoryContainer resources
podAnnotations, podLabels, nodeSelector, tolerations, affinityemptyPassed to the pod template

values.schema.json rejects unknown keys and invalid values at helm install and helm lint.

Configuration

config holds the [blocking], [local] and [[forward]] parts of dns-rs.toml (see Configuration and dns-rs.example.toml):

config: |
  [blocking]
  mode = "nxdomain"
  lists = ["https://raw.githubusercontent.com/StevenBlack/hosts/master/hosts"]
  deny = ["ads.example.com"]
  [local]
  records = [{ name = "nas.home.arpa", ip = "192.168.1.10" }]
  [[forward]]
  domain = "home.arpa"
  server = "192.168.1.1"

It is TOML text, not a YAML map: Helm’s toToml writes integers as floats (cache.size = 20000.0), which dns-rs rejects. See ADR 0014.

Startup settings do not go in config. The chart fails to render if config holds a [server], [http], [upstream] or [cache] section (or a dotted key or assignment such as cache.size = 1 or cache = { size = 1 }). Set upstream.server, upstream.timeoutMs and cache.size in the values instead: the chart passes them to the pods as environment variables (DNS_RS_UPSTREAM, DNS_RS_UPSTREAM_TIMEOUT_MS, DNS_RS_CACHE_SIZE). The chart also sets DNS_RS_LISTEN=0.0.0.0:5353 and DNS_RS_HTTP_LISTEN=0.0.0.0:8080 to match the container ports; see Container image.

Changing the configuration

A change to config does not restart pods. dns-rs reads the mounted file every 10 seconds and applies [blocking], [local] and [[forward]] changes on the running pods; see Reload without restart. The kubelet needs some time to refresh a mounted ConfigMap, so a change can take longer than 10 seconds to reach a pod.

A change to upstream.* or cache.size edits the pod template, so Kubernetes rolls every replica and they never run different values.

Clients and the DNS Service

Use service.dns.type: LoadBalancer with externalTrafficPolicy: Local to keep client IP addresses, which the query log shows (ADR 0002). Do not expose the DNS Service to the Internet: an open resolver is abused for amplification attacks. See the warning in Container image.

API token

Without api.token or api.existingSecret, /api/* answers 403 (see Authentication). /healthz, /readyz and /metrics work without it.

The chart mounts the Secret as a file at /run/secrets/dns-rs/api-token and sets DNS_RS_API_TOKEN_FILE to that path. The token is not in the pod spec. dns-rs reads the file again every 10 seconds, so a rotated Secret reaches every replica after the kubelet refreshes the mounted file (about a minute), without restarting pods; this applies to chart-managed and existing Secrets alike. See ADR 0015. Removing the token does not disable the API until pods restart.

Availability and network

  • With more than one replica, the PodDisruptionBudget keeps all but maxUnavailable pods up during node drains. With one replica it is not rendered, so it never blocks a drain.
  • Replicas are spread across nodes when possible; ScheduleAnyway means the scheduler still places them together if it has to.
  • The ServiceMonitor selects the http Service through the label app.kubernetes.io/component: http, which both Services now carry. It needs the Prometheus Operator CRDs. With networkPolicy.enabled, the chart fails to render unless networkPolicy.http.from lets Prometheus reach port 8080.
  • The NetworkPolicy restricts ingress only; egress stays open because dns-rs queries its upstream and downloads blocklists. DNS is open to everyone unless networkPolicy.dns.from lists peers; port 8080 is closed unless networkPolicy.http.from lists peers. Kubelet probes come from the node and are usually allowed by the CNI regardless.

Pod security

The pods run as the numeric user and group 65532 with runAsNonRoot, a read-only root filesystem, no privilege escalation, all capabilities dropped and the RuntimeDefault seccomp profile. The user is numeric because the kubelet cannot check runAsNonRoot against a user name such as nonroot. The liveness probe uses /healthz and the readiness probe /readyz, both on the http port. dns-rs starts its HTTP server before it loads the blocklists (up to 30 seconds per list), so liveness answers while lists load and readiness stays 503 until the first policy is installed.

Testing a release

helm test <release> --logs runs a pod that queries the DNS Service with dig, first over UDP, then over TCP. It passes when dns-rs answers, whatever the answer: dig exits 0 on any response (including NXDOMAIN and SERVFAIL) and 9 when there is none, so the result does not depend on the upstream. Set tests.enabled=false to not render the pod.

With networkPolicy.dns.from set, allow pods labelled app.kubernetes.io/component=test, or the test cannot reach the Service.

Checks in CI

The chart job runs helm lint --strict (warnings fail) on the default values and on each file in charts/dns-rs/ci/, the helm-unittest suites in charts/dns-rs/tests and kubeconform -strict against Kubernetes 1.34 on the rendered manifests, with the default values and with every feature on (charts/dns-rs/ci/all-features-values.yaml).

The e2e job installs the chart in a kind cluster with the image built by CI (.github/scripts/e2e-kind.sh). The upstream is the cluster’s own DNS, so no query leaves the cluster. It checks helm test, resolution through the Service, blocking of a denied name, and hot reload: after a helm upgrade that changes config, every replica, queried on its pod IP, applies the new rule with the same pods and no restart. The script refuses any context that is not kind-*. The job is not a required check yet.