Защита 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.