Защита Go-образа: noroot, read-only и без секретов в слоях

DockerCaddyбезопасностьGo

Базовый образ из 006 уже работал, но запускался от root и отдавал заголовки Server: Caddy. Для продакшена этого мало - защита занимает 5 строк в Dockerfile и пару :ro в compose.yml.

Multi-stage: templ → builder → run

FROM ghcr.io/a-h/templ:latest AS templ
COPY --chown=65532:65532 . /app
WORKDIR /app
RUN ["templ", "generate"]

FROM golang:1.26-alpine AS builder
WORKDIR /app
RUN apk add --no-cache build-base sqlite-dev
COPY go.mod go.sum ./
RUN --mount=type=cache,target=/go/pkg/mod go mod download
COPY --from=templ /app /app
RUN --mount=type=cache,target=/root/.cache/go-build \
    --mount=type=cache,target=/go/pkg/mod \
    CGO_ENABLED=1 GOOS=linux go build -ldflags="-s -w" -o bin/tashirka cmd/tashirka/main.go

FROM alpine:3.23 AS run
RUN apk add --no-cache ca-certificates curl gzip
WORKDIR /app
COPY --from=builder /app/bin/tashirka /app/bin/tashirka
COPY --from=builder /app/static /app/static
COPY --from=builder /app/migrations /app/migrations
RUN find /app/static -type f -exec gzip -kf {} \; && apk del gzip
RUN adduser --disabled-password --gecos "" noroot && chown -R noroot:noroot /app
USER noroot:noroot
EXPOSE 8000
CMD ["/app/bin/tashirka"]

Что важно:

  • templ стадия генерирует _templ.go - в рантайме templ не нужен.
  • CGO_ENABLED=1 обязателен для mattn/go-sqlite3 (C-код SQLite), поэтому build-base sqlite-dev.
  • -ldflags="-s -w" вырезает debug, бинарь ~15MB.
  • Кэши BuildKit --mount=type=cache ускоряют пересборки.
  • Статика gzip'уется на сборке, потом gzip удаляется - в run образе его нет.

Пользователь noroot - главная защита

RUN adduser --disabled-password --gecos "" noroot && chown -R noroot:noroot /app
USER noroot:noroot
EXPOSE 8000

Без USER контейнер работает от root (uid 0). Если пробьют RCE - получат root внутри контейнера, а с escape - и на хосте. С noroot:

  • Нет доступа к /etc, /var/run/docker.sock, привилегированным портам <1024 (поэтому 8000, а не 80).
  • EXPOSE 8000 + reverse_proxy app:8000 в Caddyfile - наружу торчит только Caddy на 80/443.
  • chown -R noroot:noroot /app - приложение пишет только в /app/db (том), не в корень.

Проверка:

docker exec tashirka-app-1 whoami    # noroot
docker exec tashirka-app-1 id        # uid=1000(noroot) gid=1000
docker exec --user root tashirka-app-1 whoami  # только если явно указать root

Секреты не попадают в слои

Неправильно:

COPY .env /app/.env          # попадёт в слой, видно через docker history
ENV SMTP_PASSWORD=secret     # тоже в слое

Правильно - compose.yml:

services:
  app:
    build: { dockerfile: ./Dockerfile }
    env_file: .env           # файл с хоста, не из образа
    volumes:
      - "./static:/app/static"
      - ".env:/app/.env"      # монтируется в рантайме
      - "./content:/app/content"
      - "./db:/app/db"

И .dockerignore страхует от COPY . /app с секретами:

.gitignore
README.md
compose.yml
Dockerfile
.air.toml
tmp
*_templ.go

Даже если кто-то сделает COPY ., .env и db/ не попадут.

Read-only тома и заголовки

# compose.yml
volumes:
  - "./static:/var/www/public:ro"   # Caddy читает, но не пишет
  - ./Caddyfile:/etc/caddy/Caddyfile:ro

# Caddyfile
localhost, tashirka.ru {
    header -Server
    header -Via
    header -Alt-Svc
    header /static/* Cache-Control "public, max-age=604800, immutable"
    route {
        handle_path /static/* {
            root * /var/www/public
            file_server { precompressed gzip }
        }
        handle { reverse_proxy app:8000 }
    }
}
  • :ro - контейнер не может перезаписать статику или конфиг, даже с noroot.
  • -Server/-Via/-Alt-Svc - убирает Server: Caddy из ответа (меньше инфо для сканеров).
  • precompressed gzip - отдаёт .gz из RUN gzip -kf, CPU не тратится на лету.
  • Cache-Control: immutable для статики, max-age=600 для страниц - Caddy кеширует без Go.

Healthcheck и логи

healthcheck:
  test: ["CMD-SHELL", "curl -s http://localhost:8000/health | grep -q 'ok' || exit 1"]
  interval: 30s
  timeout: 10s
  retries: 5
  start_period: 15s
x-logging-template: &default-logging
  logging: { driver: "json-file", options: { max-size: "10m", max-file: "3" } }
  • /health дергает db.Ping() - проверяет реальную БД, а не просто процесс (internal/core/health/handler.go).
  • Без лимита логов json-file съест диск за неделю - 10m * 3 = 30MB макс.

Graceful shutdown

ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
go func() { e.Start(":8000") }()
<-ctx.Done()  // SIGTERM от docker compose down
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
return e.Shutdown(shutdownCtx)

docker compose down шлёт SIGTERM, Go корректно закрывает соединения 10s, потом SIGKILL. Без этого - оборванные INSERT в SQLite.

Проверка

make up
docker ps                                   # app (healthy), caddy Up
docker exec tashirka-app-1 whoami           # noroot
curl -s -I localhost | grep -i server       # пусто (заголовок убран)
curl -s localhost/health | grep ok          # {"status":"ok"}

Важно про безопасность

Порты 80/443 слушает только Caddy. app:8000 недоступен снаружи - только через reverse_proxy внутри docker-сети. restart: always поднимает всё после ребута, тома caddy_data хранят TLS без перевыпуска Let's Encrypt.