docker: run as non-root, add health check, document upaas (closes #147)
check / check (push) Successful in 47s

The runtime image runs as uid 10001, which owns /var/lib/dnswatcher. The
working directory is /, so config loading finds no .env or dnswatcher
config file there; the binary lives in /usr/local/bin. A Docker
HEALTHCHECK probes /.well-known/healthcheck every 10 seconds with busybox
wget, so the container is healthy well before upaas reads its health at
60 seconds.

Startup now fails with an error naming the data directory when it cannot
be written, instead of running with every save failing. The check creates
the directory if needed and writes and removes the temp file Save uses.

README gains "Running under upaas": the prod branch, host directory
setup, network and port, environment and health check.

Model: opus-5-5
This commit is contained in:
2026-09-28 17:06:01 +00:00
parent 8aaa103956
commit cc55720167
5 changed files with 187 additions and 9 deletions
+24 -6
View File
@@ -41,15 +41,33 @@ FROM alpine@sha256:c3f8e73fdb79deaebaa2037150150191b9dcbfba68b4a46d70103204c53f4
RUN apk add --no-cache ca-certificates tzdata RUN apk add --no-cache ca-certificates tzdata
WORKDIR /app COPY --from=builder /src/bin/dnswatcher /usr/local/bin/dnswatcher
COPY --from=builder /src/bin/dnswatcher /app/dnswatcher # Run as an unprivileged user that owns the data directory. A fresh named
# volume inherits this ownership; a bind-mounted host directory must be
# Create data directory # owned by uid 10001 (see "Running under upaas" in README.md), or startup
RUN mkdir -p /var/lib/dnswatcher # fails.
RUN addgroup -S -g 10001 dnswatcher \
&& adduser -S -G dnswatcher -u 10001 dnswatcher \
&& mkdir -p /var/lib/dnswatcher \
&& chown dnswatcher:dnswatcher /var/lib/dnswatcher
ENV DNSWATCHER_DATA_DIR=/var/lib/dnswatcher ENV DNSWATCHER_DATA_DIR=/var/lib/dnswatcher
# Config loading also reads a `.env` file and a file named `dnswatcher`
# (any config extension, or none) from the working directory. `/` holds
# neither, so every setting comes from the environment. Do not make the
# data directory, or the binary's directory, the working directory.
WORKDIR /
USER dnswatcher
EXPOSE 8080 EXPOSE 8080
ENTRYPOINT ["/app/dnswatcher"] # busybox wget (already in alpine) probes the health endpoint. The first
# probe runs one interval after start. upaas reads the container's health
# 60 seconds after a deploy and fails the deploy unless it is healthy.
HEALTHCHECK --interval=10s --timeout=5s --start-period=10s --retries=3 \
CMD wget -q -O /dev/null "http://127.0.0.1:${PORT:-8080}/.well-known/healthcheck" || exit 1
ENTRYPOINT ["/usr/local/bin/dnswatcher"]
+49 -3
View File
@@ -508,11 +508,57 @@ docker run -d \
--- ---
## Running under upaas
[upaas](https://git.eeqj.de/sneak/upaas) builds the image from this
repository's `Dockerfile` and runs it. The app needs:
- **Branch:** `prod`. `prod` is cut from `main`, and merging a `main` to
`prod` pull request is a deploy.
- **Volume:** one host directory mounted at `/var/lib/dnswatcher`, where
the state file lives. upaas bind-mounts the host path it is given and
does not create it. The container runs as uid 10001 and does not start
unless it can write there. Create the directory before the first
deploy:
```sh
mkdir -p /path/to/data
chown 10001:10001 /path/to/data
chmod 700 /path/to/data
```
- **Network and port:** the dashboard is unauthenticated and shows every
watched name and recent alert, and upaas publishes every mapped port on
all interfaces of the host
([upaas issue 113](https://git.eeqj.de/sneak/upaas/issues/113)). Add a
port mapping to container port `8080` only if the dashboard should be
public. Otherwise add none: set the app's Docker network in upaas to
your reverse proxy's Docker network, and the proxy reaches the app at
`upaas-` followed by the app name, port `8080`.
- **Required environment:** `DNSWATCHER_TARGETS`, a comma-separated list
of the domains and hostnames to watch. dnswatcher refuses to start
without it.
- **Recommended environment:** at least one notification endpoint
(`DNSWATCHER_SLACK_WEBHOOK`, `DNSWATCHER_MATTERMOST_WEBHOOK`,
`DNSWATCHER_NTFY_TOPIC`); without one, changes show only on the
dashboard. `DNSWATCHER_METRICS_USERNAME` and
`DNSWATCHER_METRICS_PASSWORD` serve `/metrics` behind basic auth.
- **Leave unset:** `DNSWATCHER_DATA_DIR`, which the image sets to
`/var/lib/dnswatcher`, and `PORT`, which defaults to `8080`. Every
setting comes from the environment; the image holds no config file.
- **Health check:** the image's own, which requests
`/.well-known/healthcheck` every 10 seconds. upaas reads the
container's health 60 seconds after a deploy and marks the deploy
failed unless it is `healthy`.
---
## Monitoring Lifecycle ## Monitoring Lifecycle
1. **Startup**: Load state from disk. If no state file exists, start 1. **Startup**: Check that the data directory can be written, and exit
with empty state (first check will establish baseline without with an error naming it if not. Load state from disk. If no state
triggering change notifications). file exists, start with empty state (first check will establish
baseline without triggering change notifications).
2. **Initial check**: Immediately perform all DNS, port, and TLS checks 2. **Initial check**: Immediately perform all DNS, port, and TLS checks
on startup. on startup.
3. **Periodic checks** (DNS always runs first): 3. **Periodic checks** (DNS always runs first):
+3
View File
@@ -23,6 +23,9 @@ Rationale, Design, TODO, License, Author) if any are still missing.
# Completed Steps # Completed Steps
- 2026-09-28: upaas deploy readiness — runtime image runs as unprivileged
`dnswatcher`, Docker `HEALTHCHECK`, startup fails when the data directory is
not writable, README "Running under upaas" (closes #147).
- 2026-09-21: added behavioural tests for `internal/globals`, - 2026-09-21: added behavioural tests for `internal/globals`,
`internal/healthcheck`, and `internal/logger` (closes #110). `internal/healthcheck`, and `internal/logger` (closes #110).
- 2026-09-21: `go mod tidy` dropped the redundant `golang.org/x/sync` - 2026-09-21: `go mod tidy` dropped the redundant `golang.org/x/sync`
+29
View File
@@ -148,6 +148,11 @@ func New(
lifecycle.Append(fx.Hook{ lifecycle.Append(fx.Hook{
OnStart: func(_ context.Context) error { OnStart: func(_ context.Context) error {
err := state.checkDataDirWritable()
if err != nil {
return err
}
return state.Load() return state.Load()
}, },
OnStop: func(_ context.Context) error { OnStop: func(_ context.Context) error {
@@ -345,3 +350,27 @@ func (s *State) GetCertificateState(
return cs, ok return cs, ok
} }
// checkDataDirWritable creates the data directory if needed, then writes
// and removes the temp file that Save uses. It runs at startup so that an
// unwritable directory stops the process, instead of the process running
// with every save failing and only logged.
func (s *State) checkDataDirWritable() error {
dir := s.config.DataDir
tmpPath := s.config.StatePath() + ".tmp"
err := os.MkdirAll(dir, dirPermissions)
if err == nil {
err = os.WriteFile(tmpPath, nil, filePermissions)
}
if err == nil {
err = os.Remove(tmpPath)
}
if err != nil {
return fmt.Errorf("data directory %s is not writable: %w", dir, err)
}
return nil
}
+82
View File
@@ -4,10 +4,16 @@ import (
"encoding/json" "encoding/json"
"os" "os"
"path/filepath" "path/filepath"
"strings"
"sync" "sync"
"testing" "testing"
"time" "time"
"go.uber.org/fx/fxtest"
"sneak.berlin/go/dnswatcher/internal/config"
"sneak.berlin/go/dnswatcher/internal/globals"
"sneak.berlin/go/dnswatcher/internal/logger"
"sneak.berlin/go/dnswatcher/internal/state" "sneak.berlin/go/dnswatcher/internal/state"
) )
@@ -493,6 +499,82 @@ func TestSaveWritePermissionError(t *testing.T) {
} }
} }
// startState builds a State through the real constructor and runs its
// startup hook against dataDir, returning the startup error.
func startState(t *testing.T, dataDir string) error {
t.Helper()
g, err := globals.New(nil)
if err != nil {
t.Fatalf("globals.New: %v", err)
}
log, err := logger.New(nil, logger.Params{Globals: g})
if err != nil {
t.Fatalf("logger.New: %v", err)
}
lifecycle := fxtest.NewLifecycle(t)
_, err = state.New(lifecycle, state.Params{
Logger: log,
Config: &config.Config{DataDir: dataDir},
})
if err != nil {
t.Fatalf("state.New: %v", err)
}
return lifecycle.Start(t.Context())
}
// TestStartupFailsWhenDataDirNotWritable verifies that startup stops
// with an error naming the data directory when it cannot be written.
// The directory's parent is a regular file, which also fails as root.
func TestStartupFailsWhenDataDirNotWritable(t *testing.T) {
t.Parallel()
parent := filepath.Join(t.TempDir(), "file")
err := os.WriteFile(parent, nil, 0o600)
if err != nil {
t.Fatalf("writing file: %v", err)
}
dataDir := filepath.Join(parent, "data")
err = startState(t, dataDir)
if err == nil {
t.Fatal("startup should fail when the data directory is not writable")
}
want := "data directory " + dataDir + " is not writable"
if !strings.Contains(err.Error(), want) {
t.Errorf("startup error %q does not contain %q", err, want)
}
}
// TestStartupCreatesDataDir verifies that startup creates a missing
// data directory and leaves nothing behind in it.
func TestStartupCreatesDataDir(t *testing.T) {
t.Parallel()
dataDir := filepath.Join(t.TempDir(), "data")
err := startState(t, dataDir)
if err != nil {
t.Fatalf("startup error: %v", err)
}
entries, err := os.ReadDir(dataDir)
if err != nil {
t.Fatalf("reading data directory: %v", err)
}
if len(entries) != 0 {
t.Errorf("startup left %d entries in the data directory", len(entries))
}
}
// TestPortStateUnmarshalJSON_NewFormat verifies deserialization of the // TestPortStateUnmarshalJSON_NewFormat verifies deserialization of the
// current multi-hostname format. // current multi-hostname format.
func TestPortStateUnmarshalJSON_NewFormat(t *testing.T) { func TestPortStateUnmarshalJSON_NewFormat(t *testing.T) {