docker: run as non-root, add health check, document upaas (closes #147)
check / check (push) Successful in 47s
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:
+24
-6
@@ -41,15 +41,33 @@ FROM alpine@sha256:c3f8e73fdb79deaebaa2037150150191b9dcbfba68b4a46d70103204c53f4
|
||||
|
||||
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
|
||||
|
||||
# Create data directory
|
||||
RUN mkdir -p /var/lib/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
|
||||
# owned by uid 10001 (see "Running under upaas" in README.md), or startup
|
||||
# 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
|
||||
|
||||
# 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
|
||||
|
||||
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"]
|
||||
|
||||
@@ -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
|
||||
|
||||
1. **Startup**: Load state from disk. If no state file exists, start
|
||||
with empty state (first check will establish baseline without
|
||||
triggering change notifications).
|
||||
1. **Startup**: Check that the data directory can be written, and exit
|
||||
with an error naming it if not. Load state from disk. If no state
|
||||
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
|
||||
on startup.
|
||||
3. **Periodic checks** (DNS always runs first):
|
||||
|
||||
@@ -23,6 +23,9 @@ Rationale, Design, TODO, License, Author) if any are still missing.
|
||||
|
||||
# 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`,
|
||||
`internal/healthcheck`, and `internal/logger` (closes #110).
|
||||
- 2026-09-21: `go mod tidy` dropped the redundant `golang.org/x/sync`
|
||||
|
||||
@@ -148,6 +148,11 @@ func New(
|
||||
|
||||
lifecycle.Append(fx.Hook{
|
||||
OnStart: func(_ context.Context) error {
|
||||
err := state.checkDataDirWritable()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return state.Load()
|
||||
},
|
||||
OnStop: func(_ context.Context) error {
|
||||
@@ -345,3 +350,27 @@ func (s *State) GetCertificateState(
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@@ -4,10 +4,16 @@ import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"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"
|
||||
)
|
||||
|
||||
@@ -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
|
||||
// current multi-hostname format.
|
||||
func TestPortStateUnmarshalJSON_NewFormat(t *testing.T) {
|
||||
|
||||
Reference in New Issue
Block a user