---
description: Dockerfile — lint-docker / hadolint; перевірка check-docker
version: '1.13'
globs: "**/Dockerfile*"
alwaysApply: false
---

# Docker — hadolint

Правило перевіряє Dockerfile / Containerfile: GCR-дзеркало замість Docker Hub, multistage build з дозволеними runtime-образами, компіляцію bun-проєктів у бінарник, виняток для нативних `.node`-аддонів, non-root у фінальному stage, nginx-специфічні вимоги та hadolint CI-workflow. Усі перевірки нижче — один прохід по кожному Dockerfile/Containerfile у native-концерні `docker/lint` (**`crates/rules-core/src/concerns/docker_lint.rs`**, `npx @7n/rules lint docker`).

## Активація

Спрацьовує на всі файли `Dockerfile*`; `check docker` (`fix.mjs`) обробляє також `Containerfile` та `Containerfile.*`.

## GCR-дзеркало замість Docker Hub

Для образів з Docker Hub — **`oven/bun`**, **`alpine`**, **`nginx`**, **`nginxinc/nginx-unprivileged`**, **`node`** — у **`FROM`** треба вказувати дзеркало GCR, а не pull напряму з Hub:

| Docker Hub | GCR-дзеркало |
|---|---|
| `oven/bun` | `mirror.gcr.io/oven/bun` |
| `alpine` | `mirror.gcr.io/library/alpine` |
| `nginx` | `mirror.gcr.io/library/nginx` |
| `node` | `mirror.gcr.io/library/node` |
| `nginxinc/nginx-unprivileged` | `mirror.gcr.io/nginxinc/nginx-unprivileged` |

Перевіряє **`crates/rules-core/src/concerns/docker_lint_mirror.rs`** (`get_mirror_gcr_hint`).

## Multistage build і дозволені runtime-образи

Dockerfile/Containerfile **має бути multistage build**: окремий build stage (залежності/компіляція) і окремий runtime stage. У фінальному stage дозволені лише мінімальні базові образи:

- **backend (типово)**: `mirror.gcr.io/library/alpine:*`
- **ультра-легкі (glibc / одна статична збірка)**: `scratch` — тільки як `FROM scratch` (офіційний порожній базовий шар), коли весь **runtime** уже в `COPY --from=…`
- **glibc, Debian (slim)**: `mirror.gcr.io/library/debian:*` **лише** з тегом, у якому є `slim` (наприклад `bookworm-slim`, `trixie-slim`), а не `bookworm` без `slim`. Debian-slim виправданий **лише** коли потрібен саме glibc-рантайм (нативні glibc-залежності, prebuilds без musl-варіанта). **Non-root сам по собі не є підставою** переходити сюди: `mirror.gcr.io/oven/bun:alpine` уже має користувача `bun` (uid/gid 1000), тож `USER bun` дає non-root **без зміни бази**. Перехід Alpine→Debian заради лише non-root — **антипатерн** (більший образ + зайва musl→glibc міграція bun-бінарника)
- **виняток (інтерпретовані стеки)**: `mirror.gcr.io/library/php:*` або `mirror.gcr.io/library/python:*` — якщо сервіс має крутитися в офіційному runtime PHP чи Python, а не як один бінарник на Alpine; інакше лишай **alpine** у фінальному stage
- **frontend**: `mirror.gcr.io/nginxinc/nginx-unprivileged:*` або `mirror.gcr.io/openresty/openresty:*`

Це стримує зайвий build tooling (Bun, **node_modules** зі збірки) у фінальному образі; для **alpine** / **nginx** / **openresty** у **runtime** лишаються лише відповідні вимоги, для **php** / **python** (виняток) — цільовий інтерпретований **stack**; **scratch** і **debian** з тегом **`*slim*`** — коли glibc і мінімальне оточення Debian важливіші за musl в **alpine**.

Перевіряє `get_multistage_and_runtime_hint` у **`crates/rules-core/src/concerns/docker_lint.rs`**.

## Компіляція bun-проєкту в бінарник

Якщо проект має `bun install` крок, та не є фронтенд проектом (тобто не має `bun build` крок без `--compile`), **і не має нативного `.node`-аддона з динамічним завантаженням** (див. розділ нижче про нативні аддони), то потрібно щоб була компіляція коду, і далі у фінальному образі був тільки бінарник. Цей образ не містить компілятора, npm, Bun — тільки runtime libs.

Тригер перевірки:

- у Dockerfile є крок `bun install` (або `bun i`);
- фінальний FROM — `mirror.gcr.io/library/alpine:*` (тобто не nginx/openresty frontend).

Очікування:

- у build stage є `bun build --compile`;
- у фінальному stage немає викликів `bun` (залишків build tooling).

### Канон — компільований бінарник на alpine

```dockerfile
FROM mirror.gcr.io/oven/bun:alpine AS build-env

WORKDIR /app

ENV NODE_ENV=production

COPY package.json .
COPY bunfig.toml .

RUN bun install --production

COPY ./src ./src

# Компілюємо в бінарник
RUN bun build --compile --outfile app ./src/index.js

FROM mirror.gcr.io/library/alpine:latest

# (libstdc++ libgcc) для Bun runtime, (tzdata) для часового поясу
RUN apk add --no-cache libstdc++ libgcc tzdata

WORKDIR /app

COPY --from=build-env /app/app ./app

CMD ["./app"]
```

Перевіряє `get_bun_compile_hint` у **`crates/rules-core/src/concerns/docker_lint.rs`**.

## Виняток: нативний `.node`-аддон (sharp / @img/* / argon2)

Якщо в `package.json#dependencies` є нативний `.node`-аддон, який вантажиться через **динамічний `require`** — передусім **`sharp`**; той самий клас — **`@img/*`**, **`argon2`** — його **не можна** пакувати через `bun build --compile`.

`bun build --compile` не трейсить ``require(`@img/sharp-${platform}/sharp.node`)`` і не вшиває нативний біндинг → компільований бінарник падає в рантаймі: `Could not load the "sharp" module using the linuxmusl-arm64 runtime`. Доведено реальними docker-збірками (bun 1.3.14, sharp 0.34.5); відтворюється і на darwin-arm64 (тобто не musl/glibc-залежне). `apk add vips` **НЕ лікує** — він дає системний libvips, а бракує саме `sharp.node`.

Канон — ship `node_modules` і запускати через `bun <entry>` на базі `mirror.gcr.io/oven/bun:alpine` (це легітимний виняток до правила «лише alpine/scratch у фінальному stage» — тут потрібен саме bun-рантайм). База `oven/bun` уже має non-root користувача `bun` (uid/gid 1000). Entry бери з наявного `--outfile`-таргета / `package.json#main` / `scripts.start`; якщо не визначити — лиши TODO-маркер, не вгадуй.

Список нативних аддонів — розширювана константа `NATIVE_ADDON_PACKAGES` / `NATIVE_ADDON_SCOPES` у **`crates/rules-core/src/concerns/docker_lint_native_addon.rs`** (підключено в **`crates/rules-core/src/concerns/docker_lint.rs`**).

### Антипатерн (це правило ловить)

```dockerfile
RUN bun build --compile --outfile app ./src/index.js     # ← з sharp у deps
FROM mirror.gcr.io/library/alpine:latest
RUN apk add --no-cache ... vips                           # системний vips не рятує
COPY --from=build-env --chown=app:app /app/app ./app
USER app
CMD ["./app"]
```

### Канон (привести до цього)

```dockerfile
FROM mirror.gcr.io/oven/bun:alpine AS build-env
WORKDIR /app
ENV NODE_ENV=production
COPY package.json .
RUN bun install --production
COPY ./src ./src

FROM mirror.gcr.io/oven/bun:alpine
RUN apk add --no-cache tzdata
WORKDIR /app
# база oven/bun має non-root користувача bun (uid/gid 1000)
COPY --from=build-env --chown=bun:bun /app/node_modules ./node_modules
COPY --from=build-env --chown=bun:bun /app/src ./src
COPY --from=build-env --chown=bun:bun /app/package.json ./package.json
USER bun
CMD ["bun", "src/index.js"]
```

Для проєктів **без** нативних аддонів standalone-бінарник на alpine лишається каноном (див. розділ вище про компіляцію).

## Виняток: явний `n-rules:bun-no-compile`-маркер (причина поза виявними класами)

Нативний `.node`-аддон — механічно виявна причина (є в `package.json#dependencies`). Але бувають причини, які checker вивести не може: наприклад, сервіс завантажує конфіг через динамічний `import()` шляху, невідомого на момент компіляції, — `bun build --compile` такий шлях не трейсить, тож бінарник падає в рантаймі так само, як із нативним аддоном.

Для цих випадків — явний opt-in консюмера: коментар-рядок `# n-rules:bun-no-compile: <причина>` будь-де у Dockerfile/Containerfile (причина обов'язкова, непорожня). Маркер вимикає і вимогу `bun build --compile` (розділ «Компіляція bun-проєкту в бінарник»), і заборону `mirror.gcr.io/oven/bun:*` як фінального stage (розділ «Multistage build») — той самий канон, що й для нативних аддонів: ship `node_modules` + `bun <entry>` на `mirror.gcr.io/oven/bun:alpine`.

```dockerfile
# n-rules:bun-no-compile: gateway.config.js вантажиться через динамічний import(), compile не трейсить його
FROM mirror.gcr.io/oven/bun:alpine AS build-env
WORKDIR /app
COPY package.json .
RUN bun install --production
COPY ./src ./src

FROM mirror.gcr.io/oven/bun:alpine
WORKDIR /app
COPY --from=build-env --chown=bun:bun /app/node_modules ./node_modules
COPY --from=build-env --chown=bun:bun /app/src ./src
USER bun
CMD ["bun", "src/index.js"]
```

Маркер — **не** заміна нативно-аддонного винятку (той визначається автоматично з `package.json`) і **не** привід уникати компіляції там, де вона можлива — це escape hatch для нового, не закодованого в checker класу причин; використовуй лише коли `bun build --compile` доведено не працює. Перевіряють `has_bun_no_compile_marker` / `get_bun_compile_hint` / `get_multistage_and_runtime_hint` у **`crates/rules-core/src/concerns/docker_lint.rs`**.

## Non-root принцип у фінальному stage

Для всіх образів потрібно щоб використовувся non-root принцип. **Спосіб** досягнення non-root залежить від **бази**, а не від зміни ОС — змінювати дистрибутив (Alpine→Debian) заради лише non-root **не треба**. Два шляхи:

- **standalone-бінарник на `alpine:latest`** (секція компіляції) — у `alpine` немає готового non-root користувача, тож його створюємо явно: `addgroup -g 1000 app && adduser -D -u 1000 -G app app` + `COPY --chown=app:app` + `USER app` (приклад нижче);
- **ship `node_modules` на `mirror.gcr.io/oven/bun:alpine`** (виняток: нативний `.node`-аддон) — користувач `bun` (uid/gid 1000) **уже в базі**, тож достатньо `COPY --chown=bun:bun …` + `USER bun`; базу на Debian-slim міняти **не треба** — це той самий антипатерн, що й у переліку фінальних образів.

### Приклад для standalone-бінарника на alpine

```dockerfile
# Stage 1
FROM oven/bun:alpine AS build-env
WORKDIR /app
COPY package.json bunfig.toml .
RUN bun install --production
COPY ./src ./src
RUN bun build --compile --outfile app ./src/index.js

# Stage 2
FROM alpine:latest
RUN apk add --no-cache libstdc++ libgcc tzdata

# Додати non-root user
RUN addgroup -g 1000 app && adduser -D -u 1000 -G app app

WORKDIR /app

# Змінити власника файлу
COPY --from=build-env --chown=app:app /app/app ./app

# Запускати як non-root
USER app

CMD ["./app"]
```

Перевіряє `get_non_root_runtime_hint` у **`crates/rules-core/src/concerns/docker_lint.rs`**.

> **Примітка:** для `nginxinc/nginx-unprivileged` канон відрізняється — дивись розділ нижче про nginx non-root.

## Мінімальний тег для nginx-unprivileged

Якщо використовується nginx, то **використовуй** `mirror.gcr.io/nginxinc/nginx-unprivileged:alpine-slim` (і не `latest` / `alpine`).

Тег `alpine-slim` є обов'язковим для всіх `FROM` з образом `nginxinc/nginx-unprivileged` — будь-який інший тег (включаючи без тега) прапорцюється як порушення.

Перевіряє `get_nginx_alpine_slim_tag_hint` у **`crates/rules-core/src/concerns/docker_lint.rs`**.

## nginx-unprivileged — без USER, із --chown

Окрема гілка для фронтенду на базі **`nginxinc/nginx-unprivileged`** (будь-який тег, з/без `mirror.gcr.io/`-префікса). Цей образ **уже** оголошує `USER 101` і `EXPOSE 8080`, тож у фінальному stage **не потрібні** жодні явні `USER`-інструкції:

- **жодного `USER root` / `USER 0`** для білд-кроків: він перезатирає успадкований `USER 101`, і якщо потім не повернути non-root — фінальний образ лишається root, а k8s із `runAsNonRoot: true` падає з `CreateContainerConfigError`;
- **жодного switch-back** `USER 101` / `USER nginx` наприкінці stage — це лише симптом зайвого `USER root` на початку (повертати треба саме **числовим** UID, бо kubelet не підтверджує non-root за іменем `nginx`). Канон — взагалі не виходити з-під дефолтного 101;
- **`COPY`/`ADD` лише з `--chown`** (канон — `--chown=nginx:nginx`): без нього файли копіюються власником root і дефолтний non-root користувач (uid=101) не зможе читати статику.

Build-stage не чіпаємо — там root і tooling норма. Перевіряє **`crates/rules-core/src/concerns/docker_lint_nginx_user.rs`**.

### Антипатерн (це правило ловить)

```dockerfile
FROM mirror.gcr.io/nginxinc/nginx-unprivileged:alpine-slim
USER root
COPY ./k8s/nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /app/dist ./
RUN find ./ -type f -name "*.js" -exec gzip -k {} \;
USER 101          # повернення назад — симптом того, що був зайвий USER root
EXPOSE 8080
```

### Канон (привести до цього)

```dockerfile
FROM mirror.gcr.io/nginxinc/nginx-unprivileged:alpine-slim

COPY --chown=nginx:nginx ./k8s/nginx.conf /etc/nginx/conf.d/default.conf

WORKDIR /usr/share/nginx/html

COPY --from=build --chown=nginx:nginx /app/dist ./

RUN find ./ -type f -name "*.js" -exec gzip -k {} \;
```

(без жодного `USER`, gzip під дефолтним користувачем 101; `EXPOSE 8080` теж зайвий — база вже його оголошує)

## hadolint

CLI **`hadolint`** приймає лише **явні шляхи** (`[DOCKERFILE...]` у **`hadolint --help`**); обхід репозиторію робить `find_dockerfile_paths` у **`crates/rules-core/src/concerns/docker_lint.rs`** (правило `npx @7n/rules lint docker`) — файли з іменем **`Dockerfile`**, **`*.Dockerfile`**, **`Containerfile`**, **`*.Containerfile`** (суфікс без урахування регістру), з тими самими пропусками каталогів, що й решта обходу репозиторію.

Виклик **`hadolint`** як **нативного бінарника** через спільний реєстр тулів (`crate::tool_resolve` — PATH → керований кеш; **без** `docker run`) — спільна логіка **`crates/rules-core/src/concerns/docker_lint_hadolint.rs`**. Для CI-workflow, що встановлює hadolint і викликає `npx @7n/rules lint docker`, дивись `npm/rules/docker/lint_docker_yml/lint_docker_yml.mdc` (там канонічний шаблон і rego-перевірка `.github/workflows/lint-docker.yml`).

### Конфігурація hadolint

Кореневий **`.hadolint.yaml`**: вимкнення правил, trusted registries — [документація](https://github.com/hadolint/hadolint#configure). Щоб не додавати **`# hadolint ignore=DL3007`** у кожному **`FROM`** з **`:latest`**, у корені проєкту-споживача можна задати глобально:

```yaml title=".hadolint.yaml"
ignored:
  - DL3007
  - DL3008
  - DL3018
```

Де DL3007 — «Не використовуй тег latest у FROM»
Де DL3018 — «Піни версії пакетів у apk add»

### Запуск

**`npx @7n/rules lint docker`** — виклик hadolint як у **`docker_lint_hadolint.rs`** (нативний бінарник через спільний реєстр тулів; **без** `docker run`), разом з іншими docker-перевірками (mirror/multistage/compile/non-root/nginx) в одному проході по кожному Dockerfile/Containerfile.

Якщо немає жодного Dockerfile/Containerfile у репозиторії — перевірка пропускається (exit 0).

Винятки: **`# hadolint ignore=DL3008`** (або інший код) у Dockerfile, або **`ignored`** у **`.hadolint.yaml`** (наприклад **DL3007** для **`:latest`** — див. вище).
