yarn tsc が作るものと、Dockerfile が tar を二回展開する理由
一言でいうと
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の役割を問います。