TT Lab
はじめる
学ぶ 学習パス コース

CBA — Backstage認定アソシエイト

yarn tsc が作るものと、Dockerfile が tar を二回展開する理由

TT Labで続きを見る

一言でいうと

Backstageのアプリは、npx @backstage/create-app@latestで作成し、yarn startでフロントエンド(3000)とバックエンド(7007)を一緒に起動します。yarn tscは、リポジトリ全体を1つのコンパイル単位として型検査し、dist-types/に結果を残し、yarn build:backendは、packages/backend/dist/にskeleton.tar.gzとbundle.tar.gzの2つのアーカイブを作ります。Dockerイメージは、この2つを順番に展開して、依存関係のインストールをキャッシュします。テーマは、packages/app/src/App.tsxのcreateAppにthemesとして登録し、プラグインのReactコンポーネントは、plugins/<id>/src/components/に置き、plugin.tsが拡張(extension)としてエクスポートします。出典は、はじめに、ビルドシステム、Dockerイメージのビルド、UIのカスタマイズ、プラグインの構造のドキュメントです。

なぜ必要なのか

Backstageは製品ではなくフレームワークなので、自分たちの組織に合わせたアプリのリポジトリが、そのまま成果物です。そのリポジトリは、Yarnワークスペースでまとめたモノレポで、フロントエンド・バックエンド・プラグインが、それぞれパッケージです。この構造のため、「ビルド」は1種類ではありません。型検査、パッケージのビルド、フロントエンドのバンドル、バックエンドのバンドル、そしてコンテナイメージが、それぞれ別のツールとアーティファクトを持ちます。試験がこのワークフローを1つのドメイン(24%)にまとめて問う理由は、どの段階が何を作り、どの段階がそれを消費するかを知らなければ、CIパイプラインもDockerfileも読めないからです。

どう動くのか

作って起動する

npx @backstage/create-app@latestは、アプリ名を尋ね、その名前のディレクトリにファイルを生成したあと、yarn installとyarn tscまで実行します。生成物の骨組みは、こうです。

app
├── app-config.yaml      # 앱 설정
├── catalog-info.yaml    # 카탈로그 엔티티 기술자
├── package.json         # 루트. 여기에 npm 의존성을 넣지 말 것
└── packages
    ├── app              # 프론트엔드 앱
    └── backend          # 백엔드

yarn startは、フロントエンドとバックエンドを、[0]・[1]の2つのプロセスとして、1つのウィンドウで起動し、「Rspack compiled successfully」と表示されれば、http://localhost:3000でアプリを見られます。システムが隔離されている場合は、ポート3000とポート7007を開ける必要があります。この独立したインストールは、インメモリSQLiteとデモデータを使う評価用で、運用用ではありません。要求仕様は、Node.js Active LTS(ドキュメントは22または24を推奨)、Yarn 4.4.1(corepack enableのあとにyarn set version 4.4.1)、ディスク20GB、メモリ6GBです。

型検査: リポジトリ全体が1つの単位

ビルドシステムのドキュメントが最も強調する特徴は、プロジェクト全体が1つのTypeScriptコンパイル単位だという点です。パッケージごとに分けると、設定が複雑になり、全体の型検査が一桁倍遅くなるからです。そのため、各パッケージの入口は、TypeScriptのソースを指します。ローカルでは、増分(incremental)検査がデフォルトで、結果がリポジトリのルートのdist-types/に溜まります。また、node_modulesの中のライブラリの型検査をスキップして速度を得ますが、CIでは、この2つの最適化をオフにしたyarn tsc:fullを使うよう勧めています。dist-types/は、単なるキャッシュではありません。package buildが作る型宣言ファイルの入口がこのフォルダーなので、型宣言があるパッケージをビルドする前に、必ず型検査を先に動かす必要があります。

3つのビルドとそのアーティファクト

コマンド ツール アーティファクト 対象
backstage-cli package build Rollup パッケージのdist/に、CJS・ESM・型宣言 frontend・backendの役割を除いたパッケージ(プラグイン・ライブラリ)
フロントエンドのバンドル Webpack(ドキュメント基準。起動ログにはRspackが見える) dist/の一般のアセット(短いキャッシュ) + dist/static/のハッシュ付きアセット(長いキャッシュ) packages/app
yarn build:backend / backend:bundle 独自の収集 packages/backend/dist/bundle.tar.gz + skeleton.tar.gz packages/backend

バックエンドのバンドルは、Webpackを使いません。バックエンドのパッケージと、そのローカルの依存関係を、モノレポと同じディレクトリ配置で集めて、bundle.tar.gzにまとめ、ルートのpackage.jsonとyarn.lockも入れます。その隣のskeleton.tar.gzは、同じ配置ですが、package.jsonファイルだけが入っています。この2つに分けた理由が、Dockerfileを読む鍵です。スケルトンだけでもyarn installができるので、ソースが変わっても、依存関係がそのままなら、インストールのレイヤーがキャッシュされます。バンドルを作る前に、バックエンドのパッケージが先にビルドされている必要があり、--build-dependenciesフラグを与えると、バンドルのコマンドが代わりにビルドします。

Dockerイメージ: host buildとmulti-stage

Dockerのドキュメントは、2つの方式を分けて、1つ目を勧めています。

Host build: ビルドの大部分を、Dockerの外(ホストやCI)で行います。順序は、yarn install --immutable → yarn tsc → yarn build:backendで、そのあと、packages/backend/Dockerfileでイメージを作ります。このDockerfileは、リポジトリのルートをビルドコンテキストとして実行しなければ、ルートのyarn.lock・package.jsonに届きません。

docker image build . -f packages/backend/Dockerfile --tag backstage
docker run -it -p 7007:7007 backstage

create-appが入れてくれるDockerfileの流れは、こうです。node:24-trixie-slimの上で、USER nodeに下りたあと、.yarn・.yarnrc.yml・backstage.jsonをコピーし、yarn.lock・package.json・skeleton.tar.gzをコピーして展開し、yarn workspaces focus --all --productionで運用の依存関係だけをインストールし、最後にbundle.tar.gzとapp-config*.yamlをコピーして展開します。起動コマンドは、node packages/backend --config app-config.yaml --config app-config.production.yamlです。一緒に生成される.dockerignoreは、packages/*/src・plugins・node_modules・*.local.yamlを除いて、コンテキストを減らします。ソースではなく、ビルドのアーティファクトを入れる方式だからです。ドキュメントは、ホストのNodeのバージョンがベースイメージと同じでなければ、ネイティブモジュールがランタイムで壊れると警告しています。

Multi-stage build: 全体のビルドを、Dockerの中で行います。通常はより遅いですが、ビルド環境でDockerの中のビルドが必要だったり、別の制約があったりするときに使います。3つの段階に分かれます。第1段階は、findでpackage.jsonだけを残して、yarn installのキャッシュ用のスケルトンレイヤーを作り、第2段階は、yarn install --immutable → yarn tsc → yarn --cwd packages/backend buildで、host buildと同じことを行ってから、2つのアーカイブを展開しておき、第3段階が、最終的なイメージを作ります。この方式の.dockerignoreは、ソースにアクセスする必要があるので、host buildのものとは違い、dist-types・node_modules・packages/*/distのようなアーティファクトだけを除きます。

どちらの方式にも前提があります。デフォルトのGuest認証プロバイダーは、コンテナ環境用ではないので、認証プロバイダーを先に立て、Postgresを用意する必要があります。フロントエンドを別に配信するには、@backstage/plugin-app-backendをバックエンドから外す必要がありますが、そうすると、バックエンドが、フロントエンドの設定を注入してくれる機能を失います。ビルドがおかしいときは、--progress=plainと--no-cacheを付けてみるようにという助言もあります。

テーマ: Material UIとBackstage UIの2つの体系

UIのカスタマイズのドキュメントは、今のBackstageに、2つのUI体系が共存していると説明しています。もともとのMaterial UI(MUI)は、JSベースのテーマで、UnifiedThemeProviderで適用し、既存のプラグインの大半が使います。新しいBackstage UI(BUI)は、CSS変数とトークンベースで、クラス名がbui-で始まります。どちらを直すべきかは、コンポーネントのクラス名を見て決めます。

登録する場所は1つです。packages/app/src/App.tsxのcreateAppに、themesの配列を与えます。各項目は、id、設定画面に見せるtitle、lightまたはdarkのvariant(bodyにdata-theme-mode属性として入ります)、icon、そしてMUI用のProviderです。この配列は、デフォルトのテーマを置き換えるので、lightとdarkの両方を入れる必要があり、デフォルト値が必要なら、@backstage/themeのthemes.light・themes.darkを使えばよいです。

import { createBaseThemeOptions, createUnifiedTheme, palettes } from '@backstage/theme';

export const lightTheme = createUnifiedTheme({
  ...createBaseThemeOptions({ palette: palettes.light }),
  fontFamily: 'Comic Sans MS',
  defaultPageTheme: 'home',
});

MUIのテーマは、createUnifiedThemeに、createBaseThemeOptions({ palette })を展開して入れて作ります。palettes.lightを展開したあと、primary.main・navigation.backgroundのような値を上書きし、pageThemeにgenPageTheme({ colors, shape: shapes.wave })で、ページヘッダーの色と形を決め、typographyにdefaultTypographyを展開して、h1だけを変えるように、部分的に再定義します。ユーザー定義のフォントは、componentsのMuiCssBaselineのstyleOverridesに、@font-faceを入れます。BUI側は、packages/app/src/styles.cssをApp.tsxでimportし、:rootと[data-theme-mode='light']・[data-theme-mode='dark']の下に、--bui-bg-app・--bui-fg-primaryのような変数を上書きします。

Reactコンポーネントは、プラグインのどこに入るのか

yarn newでfrontend-pluginを選ぶと、プラグインのパッケージができ、アプリに自動で接続されます。app/package.jsonの依存関係と、app/src/App.tsxのimportが一緒に追加されるので、アプリが起動していれば、http://localhost:3000/my-pluginですぐに見られます。プラグインは、package.jsonとsrc/を持つ別のパッケージなので、npmでデプロイでき、アプリ全体を起動せずに、dev/ディレクトリの設定で、単独で起動することもできます。

plugins/my-plugin/
  dev/index.ts                      # 플러그인만 따로 띄우는 설정
  src/
    components/ExampleComponent/    # 페이지 컴포넌트 (React)
    components/ExampleFetchComponent/  # 외부 API 를 부르고 MUI 표로 그림
    plugin.ts                       # createPlugin + createRoutableExtension
    routes.ts                       # rootRouteRef
    index.ts                        # 폴더 단위 export

plugin.tsが、配線の核心です。createPlugin({ id, routes: { root: rootRouteRef } })でプラグインを作り、createRoutableExtension({ name, component: () => import('./components/ExampleComponent').then(m => m.ExampleComponent), mountPoint: rootRouteRef })を、plugin.provide()で包んでエクスポートします。アプリは、この拡張をimportして、ルートに付けます。つまり、Reactコンポーネントの変更は、src/components/の中で行い、新しいページをアプリに公開するには、plugin.tsで拡張としてエクスポートする必要があります。このドキュメントは、レガシーのフロントエンドシステム基準で、新しいフロントエンドシステムでは、plugin.tsの配線が大きく違うという案内が付いています。

現場での姿

CIでイメージのビルドが、「packages/backend/dist/skeleton.tar.gz not found」で失敗したチームがありました。Dockerfileが、packages/backend/をコンテキストとして実行されていて、その前に、yarn build:backendも抜けていました。host buildは、ホストで作ったアーティファクトを、イメージに入れる方式なので、ビルドの順序とコンテキストのルートが、そのまま契約です。

別のチームは、ローカルではyarn tscが通るのに、CIだけが失敗しました。ローカルは、増分検査とライブラリの型のスキップがオンで、CIはtsc:fullでした。ドキュメントがCIにtsc:fullを勧める理由が、まさにこの違いです。

次のクイズで確認すること

クイズでは、yarn startが起動するものとポート、yarn tscのアーティファクトと、dist-types/が必要な理由、skeleton.tar.gzとbundle.tar.gzの違い、host buildとmulti-stage buildの違い、createAppのthemesの項目、そしてplugin.tsの役割を問います。