Docker Hardened Images로 Backstage 애플리케이션 보호하기

Docker Hardened Images로 Backstage 애플리케이션 보호하기 (Secure a Backstage application with Docker Hardened Images)

Docker Hardened Images(DHI)로 Backstage 개발자 포털을 보호하고, better-sqlite3로 네이티브 모듈 컴파일을 처리하며, 의존성 설치 중 Socket Firewall 보호를 추가하고, DHI 커스터마이제이션으로 distroless 런타임 이미지를 만드는 방법을 배우는 가이드예요.

출처: 문서

본문

이 가이드는 Docker Hardened Images(DHI)를 사용해 Backstage 애플리케이션을 보호하는 방법을 보여줘요. Backstage는 수천 개 조직이 소프트웨어 카탈로그, 템플릿, 개발자 도구를 관리하는 데 사용하는 CNCF 오픈소스 개발자 포털이에요.

이 가이드를 끝내면 표준 node:24-trixie-slim 기본 이미지보다 CVE가 훨씬 적고, 기본적으로 비-root 사용자로 실행되며, Backstage가 필요한 네이티브 모듈 컴파일도 지원하는 distroless Backstage 컨테이너 이미지를 갖게 될 거예요.

사전 요구사항 (Prerequisites)

  • BuildKit이 활성화된 Docker Desktop 또는 Docker Engine
  • docker login과 docker login dhi.io로 인증된 Docker Hub 계정
  • @backstage/create-app으로 만든 Backstage 프로젝트

Backstage에 커스터마이제이션이 필요한 이유 (Why Backstage needs customization)

DHI 마이그레이션 예시들은 기본 이미지만 바꾸면 모든 게 동작하는 애플리케이션을 다뤄요. Backstage는 다릅니다. Backstage는 better-sqlite3와 설치 시점에 네이티브 Node.js 모듈을 컴파일하는 다른 패키지들을 사용해요. 그래서 빌드 스테이지에 g++, make, python3, sqlite-dev가 필요해요 — 이중 어느 것도 기본 dhi.io/node 이미지에는 없거든요. 런타임 이미지에는 컴파일된 네이티브 모듈이 링크하는 공유 라이브러리(sqlite-libs)만 있으면 됩니다.

이건 흔한 패턴이에요. 네이티브 애드온(bcrypt, sharp, sqlite3, node-canvas 같은)에 의존하는 모든 Node.js 애플리케이션이 같은 과제에 직면해요. 이 가이드의 접근 방식은 모두에 적용됩니다.

1단계: 원본 Dockerfile 살펴보기 (Step 1: Examine the original Dockerfile)

공식 Backstage 문서는 node:24-trixie-slim(Debian)을 사용하는 멀티-스테이지 Dockerfile을 권장해요. 일반적인 구성은 이렇게 생겼어요:

# Stage 1 - Create yarn install skeleton layer
FROM node:24-trixie-slim AS packages
WORKDIR /app
COPY backstage.json package.json yarn.lock ./
COPY .yarn ./.yarn
COPY .yarnrc.yml ./
COPY packages packages
COPY plugins plugins
RUN find packages \! -name "package.json" -mindepth 2 -maxdepth 2 \
    -exec rm -rf {} \+

# Stage 2 - Install dependencies and build packages
FROM node:24-trixie-slim AS build
ENV PYTHON=/usr/bin/python3
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
    --mount=type=cache,target=/var/lib/apt,sharing=locked \
    apt-get update && \
    apt-get install -y --no-install-recommends python3 g++ build-essential && \
    rm -rf /var/lib/apt/lists/*
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
    --mount=type=cache,target=/var/lib/apt,sharing=locked \
    apt-get update && \
    apt-get install -y --no-install-recommends libsqlite3-dev && \
    rm -rf /var/lib/apt/lists/*
USER node
WORKDIR /app
COPY --from=packages --chown=node:node /app .
RUN --mount=type=cache,target=/home/node/.cache/yarn,sharing=locked,uid=1000,gid=1000 \
    yarn install --immutable
COPY --chown=node:node . .
RUN yarn tsc
RUN yarn --cwd packages/backend build
RUN mkdir packages/backend/dist/skeleton packages/backend/dist/bundle \
    && tar xzf packages/backend/dist/skeleton.tar.gz \
    -C packages/backend/dist/skeleton \
    && tar xzf packages/backend/dist/bundle.tar.gz \
    -C packages/backend/dist/bundle

# Stage 3 - Build the actual backend image
FROM node:24-trixie-slim
ENV PYTHON=/usr/bin/python3
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
    --mount=type=cache,target=/var/lib/apt,sharing=locked \
    apt-get update && \
    apt-get install -y --no-install-recommends python3 g++ build-essential && \
    rm -rf /var/lib/apt/lists/*
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
    --mount=type=cache,target=/var/lib/apt,sharing=locked \
    apt-get update && \
    apt-get install -y --no-install-recommends libsqlite3-dev && \
    rm -rf /var/lib/apt/lists/*
USER node
WORKDIR /app
COPY --from=build --chown=node:node /app/.yarn ./.yarn
COPY --from=build --chown=node:node /app/.yarnrc.yml ./
COPY --from=build --chown=node:node /app/backstage.json ./
COPY --from=build --chown=node:node /app/yarn.lock \
    /app/package.json \
    /app/packages/backend/dist/skeleton/ ./
RUN --mount=type=cache,target=/home/node/.cache/yarn,sharing=locked,uid=1000,gid=1000 \
    yarn workspaces focus --all --production
COPY --from=build --chown=node:node /app/packages/backend/dist/bundle/ ./
CMD ["node", "packages/backend", "--config", "app-config.yaml"]

이 이미지를 실행하고 컨테이너 안에서 무엇이 가능한지 확인해 보세요:

docker build -t backstage:init .
docker run -d \
  -e APP_CONFIG_backend_database_client='better-sqlite3' \
  -e APP_CONFIG_backend_database_connection=':memory:' \
  -e APP_CONFIG_auth_providers_guest_dangerouslyAllowOutsideDevelopment='true' \
  -p 7007:7007 \
  -u 1000 \
  --cap-drop=ALL \
  --read-only \
  --tmpfs /tmp \
  backstage:init

이것은 동작하지만, 런타임 컨테이너에 셸, 패키지 관리자, yarn이 있어요. 이 중 어느 것도 Backstage를 실행하는 데 필요하지 않아요. docker exec로 안에서 무엇이 접근 가능한지 확인해 보세요:

docker exec -it <container-id> sh
$ cat /etc/shells
# /etc/shells: valid login shells
/bin/sh
/usr/bin/sh
/bin/bash
/usr/bin/bash
/bin/rbash
/usr/bin/rbash
/usr/bin/dash
$ yarn --version
4.12.0
$ dpkg --version
dpkg version 1.22.11 (arm64).
$ whoami
node
$ id
uid=1000(node) gid=1000(node) groups=1000(node)

node:24-trixie-slim 이미지에는 세 개의 셸(dash, bash, rbash), 패키지 관리자(dpkg), yarn이 들어 있어요. 이 도구들 각각은 공격 표면을 늘려요. 이 컨테이너에 접근권한을 얻은 공격자는 이를 이용해 인프라 전반으로 수평 이동(lateral movement)을 할 수 있어요.

2단계: 빌드 스테이지를 DHI로 전환하기 (Step 2: Switch the build stages to DHI)

세 스테이지를 모두 DHI 상당 장치로 교체하세요. DHI Node.js 이미지는 Alpine과 Debian 변형 모두에서 사용할 수 있어요. 이 가이드는 더 작은 이미지를 만들기 때문에 Alpine 변형(dhi.io/node:24-alpine3.23)을 사용해요. 호환성 때문에 Debian을 유지해야 한다면 dhi.io/node:24-bookworm을 사용하고 apt-get 대신 apk 대신 apt-get을 유지하세요.

# Stage 1: prepare packages
FROM --platform=$BUILDPLATFORM dhi.io/node:24-alpine3.23-dev AS packages
WORKDIR /app
COPY backstage.json package.json yarn.lock ./
COPY .yarn ./.yarn
COPY .yarnrc.yml ./
COPY packages packages
COPY plugins plugins
RUN find packages \! -name "package.json" -mindepth 2 -maxdepth 2 \
    -exec rm -rf {} \+

# Stage 2: build the application
FROM --platform=$BUILDPLATFORM dhi.io/node:24-alpine3.23-dev AS build
ENV PYTHON=/usr/bin/python3
RUN apk add --no-cache g++ make python3 sqlite-dev && \
    rm -rf /var/lib/apk/lists/*
WORKDIR /app
COPY --from=packages --chown=node:node /app .
RUN --mount=type=cache,target=/home/node/.cache/yarn,sharing=locked,uid=1000,gid=1000 \
    yarn install --immutable
COPY --chown=node:node . .
RUN yarn tsc
RUN yarn --cwd packages/backend build
RUN mkdir packages/backend/dist/skeleton packages/backend/dist/bundle \
    && tar xzf packages/backend/dist/skeleton.tar.gz \
    -C packages/backend/dist/skeleton \
    && tar xzf packages/backend/dist/bundle.tar.gz \
    -C packages/backend/dist/bundle

# Final Stage: create the runtime image
FROM dhi.io/node:24-alpine3.23-dev
ENV PYTHON=/usr/bin/python3
RUN apk add --no-cache g++ make python3 sqlite-dev && \
    rm -rf /var/lib/apk/lists/*
WORKDIR /app
COPY --from=build --chown=node:node /app/.yarn ./.yarn
COPY --from=build --chown=node:node /app/.yarnrc.yml ./
COPY --from=build --chown=node:node /app/backstage.json ./
COPY --from=build --chown=node:node /app/yarn.lock \
    /app/package.json \
    /app/packages/backend/dist/skeleton/ ./
RUN --mount=type=cache,target=/home/node/.cache/yarn,sharing=locked,uid=1000,gid=1000 \
    yarn workspaces focus --all --production \
    && rm -rf "$(yarn cache clean)"
COPY --from=build --chown=node:node /app/packages/backend/dist/bundle/ ./
CMD ["node", "packages/backend", "--config", "app-config.yaml"]

이 버전을 빌드하고 태그하세요:

docker build -t backstage:dhi-dev .

참고: -dev 변형에는 셸과 패키지 관리자가 포함되어 있어서 apk add가 동작해요. Backstage는 yarn workspaces focus --all --production이 프로덕션 설치 중 네이티브 모듈을 재컴파일하기 때문에 런타임 이미지에 python3와 네이티브 빌드 도구가 필요해요. 이것은 Backstage의 빌드 과정 특유의 요구사항이에요 — 대부분의 Node.js 애플리케이션은 추가 패키지 없이 표준(비-dev) DHI 런타임 변형을 사용할 수 있어요.

DHI 이미지에는 원래 node:24-trixie-slim 이미지에는 없는 증명(attestation)이 붙어 있어요. 무엇이 붙어 있는지 확인하세요:

docker scout attest list dhi.io/node:24-alpine3.23

DHI 이미지에는 CycloneDX SBOM, SLSA provenance, OpenVEX, Scout health reports, secret scans, virus/malware reports, SLSA verification summary를 포함한 15개의 증명이 실려 있어요.

3단계: Socket Firewall 보호 추가하기 (Step 3: Add Socket Firewall protection)

DHI는 Node.js 이미지용 -sfw(Socket Firewall) 변형을 제공해요. Socket Firewall은 빌드 중 npm과 yarn 명령을 가로채서 설치 스크립트를 실행하기 전에 악성 패키지를 탐지·차단해요.

Socket Firewall을 활성화하려면 세 스테이지 모두에서 -dev 태그를 -sfw-dev로 바꾸세요. SFW 버전의 Dockerfile:

# Stage 1: prepare packages
FROM --platform=$BUILDPLATFORM dhi.io/node:24-alpine3.23-sfw-dev AS packages
WORKDIR /app
COPY backstage.json package.json yarn.lock ./
COPY .yarn ./.yarn
COPY .yarnrc.yml ./
COPY packages packages
COPY plugins plugins
RUN find packages \! -name "package.json" -mindepth 2 -maxdepth 2 \
    -exec rm -rf {} \+

# Stage 2: build the packages
FROM --platform=$BUILDPLATFORM dhi.io/node:24-alpine3.23-sfw-dev AS build-packages
ENV PYTHON=/usr/bin/python3
RUN apk add --no-cache g++ make python3 sqlite-dev && \
    rm -rf /var/lib/apk/lists/*
WORKDIR /app
COPY --from=packages --chown=node:node /app .
RUN --mount=type=cache,target=/home/node/.cache/yarn,sharing=locked,uid=1000,gid=1000 \
    yarn install --immutable
COPY --chown=node:node . .
RUN yarn tsc
RUN yarn --cwd packages/backend build
RUN mkdir packages/backend/dist/skeleton packages/backend/dist/bundle \
    && tar xzf packages/backend/dist/skeleton.tar.gz \
    -C packages/backend/dist/skeleton \
    && tar xzf packages/backend/dist/bundle.tar.gz \
    -C packages/backend/dist/bundle

# Final Stage: create the runtime image
FROM dhi.io/node:24-alpine3.23-sfw-dev
ENV PYTHON=/usr/bin/python3
RUN apk add --no-cache g++ make python3 sqlite-dev && \
    rm -rf /var/lib/apk/lists/*
WORKDIR /app
COPY --from=build-packages --chown=node:node /app/.yarn ./.yarn
COPY --from=build-packages --chown=node:node /app/.yarnrc.yml ./
COPY --from=build-packages --chown=node:node /app/backstage.json ./
COPY --from=build-packages --chown=node:node /app/yarn.lock \
    /app/package.json \
    /app/packages/backend/dist/skeleton/ ./
RUN --mount=type=cache,target=/home/node/.cache/yarn,sharing=locked,uid=1000,gid=1000 \
    yarn workspaces focus --all --production \
    && rm -rf "$(yarn cache clean)"
COPY --from=build-packages --chown=node:node /app/packages/backend/dist/bundle/ ./
CMD ["node", "packages/backend", "--config", "app-config.yaml"]

이 버전을 빌드하세요:

docker build -t backstage:dhi-sfw-dev .

빌드하면 Dockerfile이나 실행 중인 컨테이너에서 실행되는 모든 yarn·npm 명령에 대해 빌드 출력에 Socket Firewall 메시지("Protected by Socket Firewall")가 보일 거예요.

팁: -sfw-dev 변형은 좀 더 큽니다(1.72 GB 대비 1.9 GB). Socket Firewall이 모니터링 도구를 추가하기 때문이에요. yarn install 동안의 보안 이점이 크기 증가를 압도해요.

4단계: DHI 커스터마이제이션으로 셸과 패키지 관리자 제거하기 (Step 4: Remove the shell and the package manager with DHI customizations)

앞선 단계들은 여전히 런타임 이미지로 -dev 또는 -sfw-dev 변형을 사용하는데, 여기에는 셸과 패키지 관리자가 포함돼요. DHI 커스터마이제이션을 사용하면 셸도 패키지 관리자도 없는 기본(비-dev) 이미지에서 시작해서, 애플리케이션이 필요로 하는 런타임 라이브러리와 언어 런타임만 추가할 수 있어요.

중요: 커스터마이제이션을 만들 때 런타임에 애플리케이션이 필요로 하는 것만 추가하세요:

  • 시스템 패키지 – DHI 카탈로그에서 공유 라이브러리(sqlite-libs 같은)와 언어 런타임(python-3.14 같은)을 추가하세요.
  • 빌드 도구는 추가하지 마세요(g++, make, Alpine의 python3 같은). 빌드 도구는 -dev 빌드 스테이지에만 두세요. 런타임 커스터마이제이션에는 절대 추가하지 마세요.

DHI 강화 패키지 피드에서 설치한 언어 런타임은 패치되어 이미지 SBOM에 추적되므로 시스템 패키지로 허용돼요. Alpine이나 Debian 패키지 피드의 빌드 도구는 강화되지 않았으며 런타임 이미지에 절대 나타나서는 안 돼요.

Backstage의 경우 런타임 이미지에는 다음이 필요해요:

  • sqlite-libs – 컴파일된 better-sqlite3 네이티브 모듈이 링크하는 공유 라이브러리(시스템 패키지로 추가).
  • Python – Backstage 플러그인 또는 구성이 런타임에 Python을 요구하는 경우. DHI 카탈로그의 python-3.14 시스템 패키지로 추가. apk로 설치한 python3와 달리 이 패키지는 Docker가 패치하고 이미지 SBOM에 추적돼요.

Docker는 SLSA Level 3 준수로 지속적으로 빌드하며, CVE 패칭에 대한 보장된 SLA 내에서 이 커스터마이즈된 이미지를 패치해요.

커스터마이제이션을 만들려면 다음 방법 중 하나를 사용하세요.

Docker Hub UI

Node.js DHI 리포지토리를 조직의 네임스페이스로 미러링한 후:

  1. Docker Hub에서 미러링된 Node.js 리포지토리를 엽니다.
  2. Customize를 선택하고 node:24-alpine3.23 태그를 선택합니다.
  3. Packages에서 sqlite-libs와 python-3.14를 추가합니다.
  4. 커스터마이제이션을 만듭니다.

자세한 내용은 Customize an image를 참고하세요.

dhictl CLI

dhictl은 Docker Hardened Images를 관리하기 위한 Docker의 커맨드라인 도구예요. DHI 카탈로그를 탐색하고, 이미지를 미러링하고, 터미널에서 직접 커스터마이제이션을 만들 수 있어요. dhictl을 CI/CD 파이프라인과 infrastructure-as-code 워크플로에 통합할 수 있어요. dhictl을 독립 바이너리 또는 Docker CLI 플러그인(docker dhi)으로 설치할 수 있어요. 설치 지침은 Use the DHI CLI를 참고하세요.

커스터마이제이션 YAML을 손으로 쓰는 대신 dhictl로 시작점을 생성하세요:

dhictl customization prepare --org YOUR_ORG node 24-alpine3.23 \
  --destination YOUR_ORG/dhi-node \
  --name "backstage" \
  --tag-suffix "_backstage" \
  --output node-backstage.yaml

생성된 파일을 편집해 런타임 라이브러리를 추가하세요:

name: backstage
source: dhi/node
tag_definition_id: node/alpine-3.23/24
destination: YOUR_ORG/dhi-node
tag_suffix: _backstage
platforms:
  - linux/amd64
  - linux/arm64
contents:
  packages:
    - sqlite-libs
    - python-3.14
  accounts:
    root: true
    runs-as: node
  users:
    - name: node
      uid: 1000
  groups:
    - name: node
      gid: 1000

그런 다음 커스터마이제이션을 만드세요:

dhictl customization create --org YOUR_ORG node-backstage.yaml

create 출력의 커스터마이제이션 ID로 빌드 진행 상황을 모니터링하세요. ID를 찾으려면 다음을 실행하세요:

dhictl customization list --org YOUR_ORG

그런 다음 빌드를 모니터링하세요:

dhictl customization build list <customization-id> --org YOUR_ORG

Docker는 보안 인프라에서 커스터마이즈된 이미지를 빌드하고 YOUR_ORG/dhi-node:24-alpine3.23_backstage로 게시해요.

참고: Backstage 구성이 런타임에 Python을 요구하지 않는다면 packages 목록에서 python-3.14를 생략할 수 있어요. sqlite-libs 패키지만으로도 better-sqlite3로 Backstage를 실행하기에 충분해요.

Dockerfile 업데이트하기 (Update the Dockerfile)

Dockerfile의 마지막 스테이지만 커스터마이즈된 이미지를 사용하도록 업데이트하세요:

# Final Stage: create the runtime image
FROM YOUR_ORG/dhi-node:24-alpine3.23_backstage
WORKDIR /app
COPY --from=build --chown=node:node /app/node_modules ./node_modules
COPY --from=build --chown=node:node /app/packages/backend/dist/bundle/ ./
CMD ["node", "packages/backend", "--config", "app-config.yaml"]

이 버전을 빌드하세요:

docker build -t backstage:dhi .

커스터마이제이션은 런타임 라이브러리와 OCI 아티팩트만 포함하고 — 빌드 도구, 패키지 관리자, 셸이 없으므로 — 결과 이미지는 distroless입니다:

docker run --rm YOUR_ORG/dhi-node:24-alpine3.23_backstage sh -c "echo hello"
docker: Error response from daemon: ... exec: "sh": executable file not found in $PATH

Enterprise 커스터마이제이션으로:

  • 런타임 이미지는 distroless입니다 — 셸도 패키지 관리자도 없어요.
  • 기본 Node.js 이미지나 그 패키지 중 하나가 보안 패치를 받으면 Docker가 커스터마이즈된 이미지를 자동으로 다시 빌드해요.
  • SLSA Build Level 3 provenance를 포함한 전체 신뢰 체인이 유지돼요.
  • Node.js와 Python 런타임이 모두 이미지 SBOM에 추적돼요.

컨테이너에 더 이상 셸 접근이 없는지 확인하세요:

docker exec -it <container-id> sh
OCI runtime exec failed: exec failed: unable to start container process: ...

실행 중인 distroless 컨테이너를 문제 해결해야 한다면 Docker Debug를 사용하세요.

참고: 조직에서 FIPS/STIG 준수 이미지를 요구한다면 DHI Enterprise에서도 그 옵션을 사용할 수 있어요.

5단계: 결과 확인하기 (Step 5: Verify the results)

Docker Scout로 DHI 기반 이미지를 원본과 비교하세요:

docker scout compare backstage:dhi \
  --to backstage:init \
  --platform linux/amd64 \
  --ignore-unchanged

여러 접근 방식에 걸친 일반적인 비교 결과는 다음과 비슷해요:

메트릭 Original DHI -dev DHI -sfw-dev Enterprise
Disk usage 1.61 GB 1.72 GB 1.9 GB 1.49 GB
Content size 268 MB 288 MB 328 MB 247 MB
Shell in runtime Yes Yes Yes No
Package manager Yes Yes Yes No
Non-root default No No No Yes
Socket Firewall No No Yes (build) Yes (build) / No (runtime)
SLSA provenance No Base only Base only Full (Level 3)

참고: -sfw-dev 변형은 Socket Firewall이 이미지에 모니터링 도구를 추가하기 때문에 더 커요. 추가 크기는 빌드 스테이지에 있으며, yarn install 동안의 보안 이점이 크기 증가를 압도해요.

더 철저한 평가를 위해 여러 도구로 스캔하세요:

trivy image backstage:dhi
grype backstage:dhi
docker scout quickview backstage:dhi

서로 다른 스캐너가 서로 다른 문제를 탐지해요. 세 가지를 모두 실행하면 보안 자세를 가장 완전하게 파악할 수 있어요.

다음 단계 (What's next)

  • Customize an image — Enterprise 커스터마이제이션 UI에 대한 완전한 레퍼런스.
  • Create and build a DHI — DHI 정의 파일을 작성하고 이미지를 로컬에서 빌드하는 방법 배우기.
  • Use the DHI CLI — 커맨드라인에서 DHI 이미지, 미러, 커스터마이제이션 관리하기.
  • Migrate to DHI — 추가 패키지 없이 표준 DHI 이미지로 동작하는 애플리케이션용.
  • Compare images — 원본 이미지와 강화된 이미지 사이의 보안 개선 평가하기.
  • Docker Debug — 셸이 없는 distroless 컨테이너 문제 해결하기.