- Published on
Keycloak SPI 拡張開発 — カスタム Authenticator から EventListener まで
- Authors

- Name
- Youngju Kim
- @fjvbn20031
- はじめに
- SPI アーキテクチャ — Provider と ProviderFactory
- 開発環境の構成
- カスタム Authenticator — 社内 SMS ゲートウェイ OTP
- RequiredAction — 電話番号登録の強制
- EventListener — 監査ログを Kafka へ
- カスタム REST エンドポイント — RealmResourceProvider
- User Storage SPI — レガシー DB の連携
- テーマカスタマイズとの区別
- デプロイ — providers ディレクトリとビルド
- Testcontainers 統合テスト
- バージョンアップグレード互換性管理
- おわりに
- 参考資料
はじめに
Keycloak の本当の力は「組み込み機能が多い」ことではなく「すべてを差し替えられる」ことにあります。認証ステップ、イベント処理、ユーザーストレージ、REST API まで、ほぼすべての内部動作が SPI(Service Provider Interface)として抽象化されており、標準機能では足りない要件を Java コードで埋めることができます。
2026 年時点で SPI 開発の需要はむしろ増えています。社内レガシーシステムとの統合(レガシー DB のユーザーでログイン)、国ごとの規制対応(SMS 本人認証)、監査ログのリアルタイムストリーミング(Kafka)、AI エージェントのアイデンティティ発行の自動化といった要求は、標準設定だけでは解決できないからです。本記事では以下を扱います。
- SPI アーキテクチャ: Provider と ProviderFactory の関係
- 開発環境の構成(Java 17+、Maven)
- カスタム Authenticator の完全実装 — 社内 SMS ゲートウェイベースの OTP
- RequiredAction による電話番号登録の強制
- EventListener で監査ログを Kafka にストリーミング
- RealmResourceProvider でカスタム REST エンドポイントを作る
- User Storage SPI でレガシー DB を連携
- デプロイ、Testcontainers 統合テスト、バージョンアップグレード互換性管理
SPI アーキテクチャ — Provider と ProviderFactory
Keycloak のすべての拡張ポイントは同一の三層パターンに従います。
+------------------------------------------------------------+
| Spi (拡張ポイント定義) |
| 例: AuthenticatorSpi, EventListenerSpi |
| |
| +------------------------------------------------------+ |
| | ProviderFactory (シングルトン、サーバー寿命中 1 個) | |
| | - init(Config.Scope): 設定のロード | |
| | - create(KeycloakSession): Provider インスタンス生成 | |
| | - getId(): 一意の識別子 | |
| | | |
| | +------------------------------------------------+ | |
| | | Provider (リクエスト/トランザクション単位で生成) | | |
| | | - 実際のビジネスロジック | | |
| | | - close(): リクエスト終了時のクリーンアップ | | |
| | +------------------------------------------------+ | |
| +------------------------------------------------------+ |
+------------------------------------------------------------+
核心となるルールは次のとおりです。
- ProviderFactory はサーバー起動時に一度だけ生成されるシングルトンです。重い初期化(コネクションプール、HTTP クライアント)はここで行います。
- Provider は KeycloakSession 単位(おおよそ HTTP リクエスト単位)で生成されます。状態を持たないか、セッションスコープの状態だけを持つべきです。
- 実装の登録は Java の ServiceLoader 規約に従います。META-INF/services ディレクトリにインターフェースの完全修飾名のファイルを作り、実装クラスを記述します。
開発環境の構成
Keycloak 26.x は Java 17 以上を要求し、21 でも動作します。Maven 依存関係は provided スコープにします。サーバーが既に持っているクラスを JAR に重複して含めないためです。
<project xmlns="http://maven.apache.org/POM/4.0.0">
<modelVersion>4.0.0</modelVersion>
<groupId>com.example.iam</groupId>
<artifactId>keycloak-extensions</artifactId>
<version>1.0.0</version>
<packaging>jar</packaging>
<properties>
<keycloak.version>26.6.2</keycloak.version>
<maven.compiler.release>17</maven.compiler.release>
</properties>
<dependencies>
<dependency>
<groupId>org.keycloak</groupId>
<artifactId>keycloak-server-spi</artifactId>
<version>${keycloak.version}</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.keycloak</groupId>
<artifactId>keycloak-server-spi-private</artifactId>
<version>${keycloak.version}</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.keycloak</groupId>
<artifactId>keycloak-services</artifactId>
<version>${keycloak.version}</version>
<scope>provided</scope>
</dependency>
<!-- Kafka EventListener 用 (JAR に含める必要があるため compile スコープ) -->
<dependency>
<groupId>org.apache.kafka</groupId>
<artifactId>kafka-clients</artifactId>
<version>3.9.0</version>
</dependency>
</dependencies>
</project>
spi-private にあるインターフェースはマイナーバージョンでも変わる可能性があることを覚えておいてください。後述の互換性管理の節で再び扱います。
カスタム Authenticator — 社内 SMS ゲートウェイ OTP
最も需要の多い拡張です。パスワード認証の後、社内 SMS ゲートウェイで 6 桁のコードを送信して検証する Authenticator を作ってみましょう。
1. Authenticator の実装
package com.example.iam.sms;
import org.keycloak.authentication.AuthenticationFlowContext;
import org.keycloak.authentication.AuthenticationFlowError;
import org.keycloak.authentication.Authenticator;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
import org.keycloak.models.UserModel;
import java.security.SecureRandom;
public class SmsOtpAuthenticator implements Authenticator {
private static final String OTP_NOTE = "sms-otp-code";
private static final String PHONE_ATTR = "phoneNumber";
@Override
public void authenticate(AuthenticationFlowContext context) {
UserModel user = context.getUser();
String phone = user.getFirstAttribute(PHONE_ATTR);
if (phone == null || phone.isBlank()) {
// 電話番号未登録 -> RequiredAction へ誘導
user.addRequiredAction("register-phone-number");
context.attempted();
return;
}
String code = generateCode();
// auth note は今回の認証セッションでのみ有効な一時ストレージ
context.getAuthenticationSession().setAuthNote(OTP_NOTE, code);
SmsGateway gateway = SmsGateway.fromConfig(
context.getAuthenticatorConfig());
gateway.send(phone, "認証コード: " + code + " (5 分以内に入力)");
context.challenge(
context.form()
.setAttribute("phoneHint", maskPhone(phone))
.createForm("sms-otp.ftl"));
}
@Override
public void action(AuthenticationFlowContext context) {
String expected = context.getAuthenticationSession()
.getAuthNote(OTP_NOTE);
String submitted = context.getHttpRequest()
.getDecodedFormParameters()
.getFirst("otp");
if (expected != null && expected.equals(submitted)) {
context.success();
return;
}
context.failureChallenge(
AuthenticationFlowError.INVALID_CREDENTIALS,
context.form()
.setError("invalidOtpCode")
.createForm("sms-otp.ftl"));
}
@Override
public boolean requiresUser() {
return true; // パスワード認証の後に来る必要がある
}
@Override
public boolean configuredFor(KeycloakSession session,
RealmModel realm, UserModel user) {
return user.getFirstAttribute(PHONE_ATTR) != null;
}
@Override
public void setRequiredActions(KeycloakSession session,
RealmModel realm, UserModel user) {
user.addRequiredAction("register-phone-number");
}
@Override
public void close() { }
private String generateCode() {
return String.format("%06d", new SecureRandom().nextInt(1_000_000));
}
private String maskPhone(String phone) {
int len = phone.length();
return phone.substring(0, 3) + "****" + phone.substring(len - 2);
}
}
2. AuthenticatorFactory の実装
package com.example.iam.sms;
import org.keycloak.Config;
import org.keycloak.authentication.Authenticator;
import org.keycloak.authentication.AuthenticatorFactory;
import org.keycloak.models.AuthenticationExecutionModel;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.KeycloakSessionFactory;
import org.keycloak.provider.ProviderConfigProperty;
import java.util.List;
public class SmsOtpAuthenticatorFactory implements AuthenticatorFactory {
public static final String PROVIDER_ID = "sms-otp-authenticator";
private static final SmsOtpAuthenticator SINGLETON =
new SmsOtpAuthenticator();
@Override
public String getId() { return PROVIDER_ID; }
@Override
public String getDisplayType() { return "SMS OTP (internal gateway)"; }
@Override
public Authenticator create(KeycloakSession session) {
return SINGLETON; // 状態がないためシングルトンを再利用可能
}
@Override
public AuthenticationExecutionModel.Requirement[] getRequirementChoices() {
return new AuthenticationExecutionModel.Requirement[] {
AuthenticationExecutionModel.Requirement.REQUIRED,
AuthenticationExecutionModel.Requirement.ALTERNATIVE,
AuthenticationExecutionModel.Requirement.CONDITIONAL,
AuthenticationExecutionModel.Requirement.DISABLED
};
}
@Override
public boolean isConfigurable() { return true; }
@Override
public List<ProviderConfigProperty> getConfigProperties() {
ProviderConfigProperty url = new ProviderConfigProperty(
"gateway.url", "SMS Gateway URL",
"社内 SMS ゲートウェイのエンドポイント",
ProviderConfigProperty.STRING_TYPE,
"https://sms.internal.example.com/v1/send");
return List.of(url);
}
@Override
public boolean isUserSetupAllowed() { return true; }
@Override
public String getHelpText() {
return "Sends a 6-digit OTP via the internal SMS gateway.";
}
@Override
public void init(Config.Scope config) { }
@Override
public void postInit(KeycloakSessionFactory factory) { }
@Override
public void close() { }
}
3. ServiceLoader 登録
# ファイルパス:
# src/main/resources/META-INF/services/org.keycloak.authentication.AuthenticatorFactory
com.example.iam.sms.SmsOtpAuthenticatorFactory
その後、管理コンソールで browser flow を複製し、Username Password Form の後に SMS OTP execution を追加すれば適用されます。認証フローエンジンの動作原理はアーキテクチャ編で扱ったため省略します。
RequiredAction — 電話番号登録の強制
上記の Authenticator は電話番号がなければ register-phone-number という RequiredAction を追加します。RequiredAction は「ログインには成功したが、続行する前にユーザーが必ずしなければならないこと」です。
package com.example.iam.sms;
import org.keycloak.authentication.RequiredActionContext;
import org.keycloak.authentication.RequiredActionProvider;
public class RegisterPhoneRequiredAction implements RequiredActionProvider {
public static final String PROVIDER_ID = "register-phone-number";
@Override
public void evaluateTriggers(RequiredActionContext context) {
// ログインごとの自動トリガー条件評価 (ここでは未使用)
}
@Override
public void requiredActionChallenge(RequiredActionContext context) {
context.challenge(
context.form().createForm("register-phone.ftl"));
}
@Override
public void processAction(RequiredActionContext context) {
String phone = context.getHttpRequest()
.getDecodedFormParameters()
.getFirst("phoneNumber");
if (phone == null || !phone.matches("^\\+?[0-9]{10,15}$")) {
context.challenge(
context.form().setError("invalidPhone")
.createForm("register-phone.ftl"));
return;
}
context.getUser().setSingleAttribute("phoneNumber", phone);
context.success();
}
@Override
public void close() { }
}
Factory と ServiceLoader 登録(org.keycloak.authentication.RequiredActionFactory)は Authenticator と同じパターンのため省略します。
EventListener — 監査ログを Kafka へ
セキュリティ監査要件で最もよくあるのが「すべてのログイン/管理イベントをリアルタイムで SIEM に送れ」というものです。EventListener SPI で実装します。
package com.example.iam.audit;
import org.apache.kafka.clients.producer.KafkaProducer;
import org.apache.kafka.clients.producer.ProducerRecord;
import org.keycloak.events.Event;
import org.keycloak.events.EventListenerProvider;
import org.keycloak.events.admin.AdminEvent;
import org.keycloak.util.JsonSerialization;
public class KafkaEventListener implements EventListenerProvider {
private final KafkaProducer<String, String> producer;
private final String topic;
public KafkaEventListener(KafkaProducer<String, String> producer,
String topic) {
this.producer = producer;
this.topic = topic;
}
@Override
public void onEvent(Event event) {
// LOGIN, LOGIN_ERROR, LOGOUT, TOKEN_REFRESH ...
send(event.getRealmId(), toJson(event));
}
@Override
public void onEvent(AdminEvent adminEvent, boolean includeRepresentation) {
// 管理コンソール/Admin API の変更履歴
send(adminEvent.getRealmId(), toJson(adminEvent));
}
private void send(String key, String value) {
// 非同期送信: 認証経路をブロックしないことが重要
producer.send(new ProducerRecord<>(topic, key, value), (md, ex) -> {
if (ex != null) {
// Kafka 障害がログイン障害になってはいけない: ログのみ残す
org.jboss.logging.Logger
.getLogger(KafkaEventListener.class)
.errorf(ex, "audit event publish failed: key=%s", key);
}
});
}
private String toJson(Object o) {
try {
return JsonSerialization.writeValueAsString(o);
} catch (Exception e) {
throw new RuntimeException(e);
}
}
@Override
public void close() {
// リクエスト単位の close: producer は Factory が所有するためここでは閉じない
}
}
KafkaProducer を Factory でシングルトンとして管理するのがポイントです。
package com.example.iam.audit;
import org.apache.kafka.clients.producer.KafkaProducer;
import org.keycloak.Config;
import org.keycloak.events.EventListenerProvider;
import org.keycloak.events.EventListenerProviderFactory;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.KeycloakSessionFactory;
import java.util.Properties;
public class KafkaEventListenerFactory
implements EventListenerProviderFactory {
private KafkaProducer<String, String> producer;
private String topic;
@Override
public String getId() { return "kafka-audit"; }
@Override
public void init(Config.Scope config) {
// spi-events-listener-kafka-audit-* オプションからロード
String bootstrap = config.get("bootstrapServers",
"kafka.internal:9092");
topic = config.get("topic", "keycloak-audit");
Properties props = new Properties();
props.put("bootstrap.servers", bootstrap);
props.put("key.serializer",
"org.apache.kafka.common.serialization.StringSerializer");
props.put("value.serializer",
"org.apache.kafka.common.serialization.StringSerializer");
props.put("acks", "1");
props.put("linger.ms", "20");
producer = new KafkaProducer<>(props);
}
@Override
public EventListenerProvider create(KeycloakSession session) {
return new KafkaEventListener(producer, topic);
}
@Override
public void postInit(KeycloakSessionFactory factory) { }
@Override
public void close() {
if (producer != null) producer.close();
}
}
設定値は kc.sh オプションで注入します。
bin/kc.sh start --optimized \
--spi-events-listener-kafka-audit-bootstrap-servers=kafka.internal:9092 \
--spi-events-listener-kafka-audit-topic=keycloak-audit
有効化は管理コンソールの Realm settings の Events タブにある Event listeners に kafka-audit を追加すれば完了です。
カスタム REST エンドポイント — RealmResourceProvider
Admin API にない機能(例: ユーザー一括照会の最適化、社内フォーマットのヘルスレポート)を realm 配下のパスの REST API として公開できます。
package com.example.iam.rest;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
import org.keycloak.models.KeycloakSession;
import org.keycloak.services.managers.AppAuthManager;
import org.keycloak.services.managers.AuthenticationManager;
import org.keycloak.services.resource.RealmResourceProvider;
import java.util.Map;
public class StatsResourceProvider implements RealmResourceProvider {
private final KeycloakSession session;
public StatsResourceProvider(KeycloakSession session) {
this.session = session;
}
@Override
public Object getResource() { return this; }
@GET
@Path("summary")
@Produces(MediaType.APPLICATION_JSON)
public Map<String, Object> summary() {
// トークン検証: Bearer トークンがなければ 401
AuthenticationManager.AuthResult auth =
new AppAuthManager.BearerTokenAuthenticator(session)
.authenticate();
if (auth == null) {
throw new jakarta.ws.rs.NotAuthorizedException("Bearer");
}
long userCount = session.users()
.getUsersCount(session.getContext().getRealm());
return Map.of(
"realm", session.getContext().getRealm().getName(),
"users", userCount
);
}
@Override
public void close() { }
}
Factory の getId が stats なら、このエンドポイントの最終的な URL は次のようになります。
GET /realms/production/stats/summary
Authorization: Bearer eyJhbGciOiJSUzI1NiIs...
カスタム REST は強力なぶん危険です。必ずトークン検証と role 検査を自分で実装しなければならず、抜けると認証なしの公開 API になってしまいます。
User Storage SPI — レガシー DB の連携
既存のユーザー DB(例: 古い会員テーブル)をマイグレーションなしに Keycloak のユーザーソースとして使う拡張ポイントです。中核インターフェースは UserStorageProvider で、機能ごとの capability インターフェースを組み合わせます。
package com.example.iam.legacy;
import org.keycloak.component.ComponentModel;
import org.keycloak.credential.CredentialInput;
import org.keycloak.credential.CredentialInputValidator;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
import org.keycloak.models.UserModel;
import org.keycloak.models.credential.PasswordCredentialModel;
import org.keycloak.storage.StorageId;
import org.keycloak.storage.UserStorageProvider;
import org.keycloak.storage.user.UserLookupProvider;
public class LegacyDbStorageProvider implements UserStorageProvider,
UserLookupProvider, CredentialInputValidator {
private final KeycloakSession session;
private final ComponentModel model;
private final LegacyUserDao dao; // JDBC アクセス層
public LegacyDbStorageProvider(KeycloakSession session,
ComponentModel model,
LegacyUserDao dao) {
this.session = session;
this.model = model;
this.dao = dao;
}
@Override
public UserModel getUserByUsername(RealmModel realm, String username) {
LegacyUser u = dao.findByUsername(username);
return u == null ? null
: new LegacyUserAdapter(session, realm, model, u);
}
@Override
public UserModel getUserById(RealmModel realm, String id) {
String externalId = StorageId.externalId(id);
LegacyUser u = dao.findById(externalId);
return u == null ? null
: new LegacyUserAdapter(session, realm, model, u);
}
@Override
public UserModel getUserByEmail(RealmModel realm, String email) {
LegacyUser u = dao.findByEmail(email);
return u == null ? null
: new LegacyUserAdapter(session, realm, model, u);
}
@Override
public boolean supportsCredentialType(String type) {
return PasswordCredentialModel.TYPE.equals(type);
}
@Override
public boolean isConfiguredFor(RealmModel realm, UserModel user,
String type) {
return supportsCredentialType(type);
}
@Override
public boolean isValid(RealmModel realm, UserModel user,
CredentialInput input) {
if (!supportsCredentialType(input.getType())) return false;
// レガシーハッシュ (bcrypt など) の検証を委譲
return dao.verifyPassword(
StorageId.externalId(user.getId()),
input.getChallengeResponse());
}
@Override
public void close() { }
}
実務 Tips を三つ挙げます。
- import モード(Keycloak DB へユーザーをコピー)vs 非 import モード(常に外部照会)を初期に決める必要があります。長期的には import 後にレガシーを廃棄するマイグレーション経路を推奨します。
- レガシーパスワードハッシュが検証される初回ログイン時に Keycloak 標準ハッシュで再ハッシュして保存すれば(credential 更新)、漸進的なマイグレーションになります。
- 外部 DB の障害がログイン全体の障害に波及しないよう、タイムアウトとサーキットブレーカーを DAO 層に入れるべきです。
テーマカスタマイズとの区別
ログイン画面の見た目を変えたいだけなら、SPI ではなくテーマを使うべきです。
| 要件 | 手段 |
|---|---|
| ロゴ/色/CSS の変更 | テーマ (FreeMarker テンプレート + CSS) |
| ログインフォームにフィールド追加 + 検証ロジック | Authenticator SPI + テーマテンプレート |
| 多言語メッセージの追加 | テーマの messages プロパティ |
| 認証ステップ自体の追加/変更 | Authenticator SPI |
| 会員登録項目のカスタム | User Profile 設定 (コード不要な場合が多い) |
上記 SMS OTP の例の sms-otp.ftl のように、SPI とテーマは一緒に使われることが多いです。テンプレートはテーマディレクトリの login 配下に置きます。
デプロイ — providers ディレクトリとビルド
拡張 JAR は providers ディレクトリに入れ、build を再実行します。
FROM quay.io/keycloak/keycloak:26.6 AS builder
COPY target/keycloak-extensions-1.0.0.jar /opt/keycloak/providers/
# kafka-clients のような外部依存も一緒に (shaded JAR を使えば不要)
COPY target/dependency/kafka-clients-3.9.0.jar /opt/keycloak/providers/
ENV KC_DB=postgres
ENV KC_HEALTH_ENABLED=true
RUN /opt/keycloak/bin/kc.sh build
FROM quay.io/keycloak/keycloak:26.6
COPY /opt/keycloak/ /opt/keycloak/
ENTRYPOINT ["/opt/keycloak/bin/kc.sh", "start", "--optimized"]
確認は起動ログまたは管理コンソールの Provider info で行います。
# 登録済み provider の確認
bin/kc.sh show-config
kubectl logs keycloak-0 | grep -i "sms-otp"
Testcontainers 統合テスト
SPI は単体テストだけでは不十分です。実際の Keycloak コンテナに JAR を載せて検証する統合テストが必須です。コミュニティ標準である dasniko の testcontainers-keycloak を使用します。
package com.example.iam;
import dasniko.testcontainers.keycloak.KeycloakContainer;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.BeforeAll;
import org.keycloak.admin.client.Keycloak;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import static org.assertj.core.api.Assertions.assertThat;
@Testcontainers
class SmsOtpAuthenticatorIT {
@Container
static KeycloakContainer keycloak =
new KeycloakContainer("quay.io/keycloak/keycloak:26.6")
.withProviderClassesFrom("target/classes")
.withRealmImportFile("test-realm.json");
@Test
void authenticatorFactoryIsRegistered() {
Keycloak admin = keycloak.getKeycloakAdminClient();
var info = admin.serverInfo().getInfo();
var authenticators = info.getProviders()
.get("authenticator").getProviders();
assertThat(authenticators)
.containsKey("sms-otp-authenticator");
}
@Test
void loginWithoutPhoneTriggersRequiredAction() {
// test-realm.json に sms-otp を含む flow と
// 電話番号のないユーザーを事前に定義しておき、
// OAuth コードフローを RestAssured でシミュレーション
// (紙面の都合で省略 — リポジトリの完全な例を参照)
}
}
CI で Keycloak バージョンをマトリクスで回せば(26.5、26.6 など)、アップグレード互換性の回帰を早期に捕捉できます。
バージョンアップグレード互換性管理
SPI 開発の最大のリスクは、Keycloak アップグレード時の内部 API 変更です。実践的な鉄則を整理します。
- 公開 SPI(keycloak-server-spi)に依存し、private SPI の使用は最小限にします。private はマイナーバージョンでも壊れる可能性があります。
- pom の keycloak.version をサーバーバージョンと固定し、サーバーアップグレードと拡張ビルドを一つのパイプラインで処理します。
- リリースノートの deprecation セクションをアップグレード前に必ず読みます。例えば 26 でのレガシーコンソールテーマ削除、jakarta 名前空間への移行(22)のような変化が拡張コードを壊してきました。
- Testcontainers 統合テストを次期バージョンのイメージで事前に回すカナリアジョブを設けます。
- 拡張 JAR にあまり多くの責務を詰め込みません。Kafka 送信のようなロジックはできるだけ薄く保ち、加工/補強はダウンストリームで行います。
アップグレード手順 (推奨)
1. 次期バージョンのリリースノート / deprecation を確認
2. keycloak.version を上げてコンパイル -> API の破壊を検出
3. Testcontainers IT を新イメージで実行
4. ステージングに新イメージ + 拡張 JAR をデプロイ
5. 26.6+ のパッチバージョンなら rolling、それ以外は recreate で本番適用
おわりに
SPI は Keycloak を「製品」から「プラットフォーム」へと変える仕組みです。Provider/ProviderFactory パターンを一つ身につければ、Authenticator、EventListener、REST リソース、User Storage まで同じ構造で拡張できます。ただし拡張コードは認証経路の真ん中で実行されるため、外部システム障害の隔離(タイムアウト、非同期化)とバージョン互換性管理がコードを書くことと同じくらい重要です。本記事の例をもとに、必ず統合テストとともに本番環境へ進んでください。