퀵스타트
API 키를 발급하고, 리소스를 등록하고, 컨텍스트에 가장 건강한 리소스를 확보하고, 결과를 보고하기까지 — 한 바퀴 전체.
1. 스택 띄우기
서비스는 docker compose 파일 하나입니다. 앱(REST 8083, gRPC 9093), PostgreSQL, 대시보드, 그리고 둘을 :8080 한 오리진으로 묶어 주는 Caddy 리버스 프록시로 구성됩니다. 첫 부팅에 Flyway 가 스키마를 마이그레이션하므로 별도 준비 단계는 없습니다.
git clone https://github.com/PreAgile/reputation-pool-cloud
cd reputation-pool-cloud
cp .env.example .env # REPUTATION_POOL_API_KEY has no default — compose refuses to start without it
docker compose up --build -dhttp://localhost:8080 을 열면 대시보드가 있습니다. REST 컨트롤플레인은 한 단계가 더 필요합니다 — .env 의 REPUTATION_POOL_ADMIN_* 세 줄의 주석을 해제하세요. 이 값을 비워 두는 것은 의도된 기본 차단(fail closed) 입니다. 관리자 콘솔이 비활성으로 남고 모든 /api/** 호출이 거절되며, gRPC 데이터플레인은 그대로 동작합니다.
REPUTATION_POOL_ADMIN_USERNAME=admin
REPUTATION_POOL_ADMIN_PASSWORD=change-me-local-dev
# HS256 needs a 256-bit key: anything shorter than 32 bytes fails fast at startup.
REPUTATION_POOL_ADMIN_JWT_SECRET=0123456789abcdef0123456789abcdef
# Then re-create the container. Not `docker compose restart` — that reuses the container
# with the environment it was created with, so the new variables would appear to do nothing.
docker compose up -d이 페이지의 나머지가 쓰는 값들입니다.
export RP_ORIGIN=http://localhost:8080 # control plane + dashboard, one origin (Caddy)
export RP_GRPC=localhost:9093 # data plane — loopback only, by design
export RP_TENANT=default # the tenant compose seeds on startup
export RP_API_KEY=local-dev-key # REPUTATION_POOL_API_KEY from your .env2. API 키 받기
이미 하나 있습니다. 앱은 시작할 때 REPUTATION_POOL_API_KEY 를 default 테넌트의 활성 키로 심어 두므로, $RP_API_KEY 로 gRPC 호출이 바로 인증됩니다. 이 변수를 바꾸고 재시작하면 같은 단계에서 기존 키가 폐기됩니다 — 이 부트스트랩 키 하나에 대해서는 환경변수가 단일 출처입니다.
첫 실행을 넘어가면 워커마다 키를 따로 두고 싶어지고, 그 키는 컨트롤플레인에서 나옵니다. 먼저 로그인해 토큰을 받으세요. 토큰은 expiresInSeconds 뒤에 만료됩니다(기본 한 시간).
curl -sS -X POST "$RP_ORIGIN/api/auth/login" \
-H 'Content-Type: application/json' \
-d '{"username":"admin","password":"change-me-local-dev"}'
# {"token":"eyJhbGciOiJIUzI1NiJ9…","tokenType":"Bearer","expiresInSeconds":3600}
export RP_JWT=eyJhbGciOiJIUzI1NiJ9…이제 키를 발급합니다. 응답의 rawToken 은 키 원문을 볼 수 있는 유일한 순간입니다 — 암호화가 아니라 해시로 저장되므로 나중에 다시 읽어낼 수 없습니다. 곧바로 비밀 저장소에 넣으세요.
curl -sS -X POST "$RP_ORIGIN/api/tenants/$RP_TENANT/api-keys" \
-H "Authorization: Bearer $RP_JWT" \
-H 'Content-Type: application/json' \
-d '{"label":"worker-01"}'
# 201 Created
# {
# "id": "5f1c…-…",
# "rawToken": "rp_9Q3xK7bT…", <-- shown once, never again
# "label": "worker-01",
# "prefix": "rp_9Q3xK7bT", <-- what listings show
# "createdAt": "2026-07-29T09:12:44Z"
# }
export RP_API_KEY=rp_9Q3xK7bT…대시보드의 API 키 화면도 버튼 하나로 같은 일을 합니다. 어느 쪽이든 경로의 tenantId 는 토큰이 묶여 있는 테넌트여야 합니다 — 다른 테넌트를 향한 키 발급 요청은 그 테넌트가 존재하든 아니든 403 으로 거절됩니다.
3. 리소스 등록하기
리소스는 kind(PROXY, ACCOUNT, SESSION)와 여러분이 정하는 불투명한 value 의 조합입니다 — 프록시 엔드포인트, 계정 id, 세션 핸들 같은 것. 등록은 멱등이므로 워커가 부팅할 때마다 다시 등록하는 것이 정상적인 패턴입니다.
데이터플레인은 gRPC 라서 아래 요청은 curl 이 아니라 grpcurl 을 씁니다. 서버 리플렉션이 켜져 있어 grpcurl 이 서비스를 스스로 찾아냅니다 — 받아 올 .proto 파일이 없습니다. 인터셉터가 리플렉션도 함께 지키므로 목록을 보는 데도 키가 필요합니다.
grpcurl -plaintext -H "x-api-key: $RP_API_KEY" "$RP_GRPC" list
# io.github.preagile.reputationpool.grpc.v1.ReputationAdvisor <-- plus health and reflection여기서 -plaintext 는 편법이 아니라 맞는 선택입니다. 포트가 loopback 에 있고 앞에 TLS 가 없습니다. 실제로 TLS 종단을 붙였을 때만 이 플래그를 빼세요.
grpcurl -plaintext -H "x-api-key: $RP_API_KEY" \
-d '{"resource":{"kind":"PROXY","value":"proxy-1.example.net:8080"}}' \
"$RP_GRPC" io.github.preagile.reputationpool.grpc.v1.ReputationAdvisor/Register
# {} <-- RegisterResponse is empty; the call succeeding is the result4. 컨텍스트에 맞는 리소스 확보하기
컨텍스트는 지금 하려는 일에 이름을 붙인 문자열입니다 — checkout-us, search-eu 처럼, 리소스를 독립적으로 태울 수 있는 목적지나 워크로드마다 하나씩. 이 값을 Acquire 에 넘기면 풀이 그 컨텍스트에서 가장 건강한 리소스를 골라, 여러분에게만 배타적으로 리스해 줍니다.
grpcurl -plaintext -H "x-api-key: $RP_API_KEY" \
-d '{"context":{"value":"checkout-us"}}' \
"$RP_GRPC" io.github.preagile.reputationpool.grpc.v1.ReputationAdvisor/Acquire
# {
# "granted": true,
# "lease": {
# "resource": {"kind":"PROXY","value":"proxy-1.example.net:8080"},
# "context": {"value":"checkout-us"},
# "token": "1", <-- int64 is a string in proto3 JSON
# "leasedAt": "2026-07-29T09:13:02Z",
# "expiresAt": "2026-07-29T09:13:32Z" <-- 30s lease TTL by default
# }
# }5. 리소스를 쓰고, 결과를 보고하기
lease.resource 로 할 일을 하고, 무슨 일이 있었는지 풀에 알려 주세요. 평판을 움직이는 것은 이 호출입니다. 성공은 점수를 조금 올리고, 실패는 실패 유형에 따라 정해진 만큼 점수를 내리며, 임계치를 넘으면 그 리소스를 쿨다운 동안 빼 둡니다.
grpcurl -plaintext -H "x-api-key: $RP_API_KEY" \
-d '{
"resource": {"kind":"PROXY","value":"proxy-1.example.net:8080"},
"context": {"value":"checkout-us"},
"outcome": {"success":{"latency":"0.412s"}}
}' \
"$RP_GRPC" io.github.preagile.reputationpool.grpc.v1.ReputationAdvisor/Report# type: CONNECTION_RESET | TLS_HANDSHAKE | TIMEOUT | BLOCKED | SLOW
grpcurl -plaintext -H "x-api-key: $RP_API_KEY" \
-d '{
"resource": {"kind":"PROXY","value":"proxy-1.example.net:8080"},
"context": {"value":"checkout-us"},
"outcome": {"failure":{"type":"BLOCKED","latency":"1.2s"}}
}' \
"$RP_GRPC" io.github.preagile.reputationpool.grpc.v1.ReputationAdvisor/Report실패 유형은 정직하게 고르세요 — 장식이 아닙니다. BLOCKED 는 점수 30 점과 한 시간 쿨다운을, SLOW 는 2 점과 30 초를 뜻합니다. 전부 BLOCKED 로 보고하면 풀이 비어 버립니다. 정확한 수치는 핵심 개념에 있습니다.
마지막으로 Release 로 리소스를 돌려주면 다른 워커가 가져갈 수 있습니다. 반납은 정확성을 위해 필수는 아닙니다 — 리스는 TTL 이 지나면 스스로 만료됩니다 — 하지만 제때 반납하는 것이 풀을 TTL 만료를 기다리는 상태가 아니라 일하는 상태로 유지합니다. 작업이 TTL 보다 길어지면 Renew 를 부르세요.
grpcurl -plaintext -H "x-api-key: $RP_API_KEY" \
-d '{"lease":{"resource":{"kind":"PROXY","value":"proxy-1.example.net:8080"},
"context":{"value":"checkout-us"},"token":"1",
"leasedAt":"2026-07-29T09:13:02Z","expiresAt":"2026-07-29T09:13:32Z"}}' \
"$RP_GRPC" io.github.preagile.reputationpool.grpc.v1.ReputationAdvisor/Release
# {"released": true}같은 루프, Java 로
생성된 스텁은 퍼블리시된 io.github.preagile:reputation-pool-grpc 아티팩트에서 오므로 여러분 쪽에 codegen 단계가 없습니다. 와이어 타입이 한 겉 클래스(AdvisorProto) 아래에 중첩된 것은 의도적입니다 — 단순 이름이 엔진의 도메인 타입과 충돌하기 때문입니다.
import io.github.preagile.reputationpool.grpc.v1.AdvisorProto.AcquireRequest;
import io.github.preagile.reputationpool.grpc.v1.AdvisorProto.AcquireResponse;
import io.github.preagile.reputationpool.grpc.v1.AdvisorProto.Context;
import io.github.preagile.reputationpool.grpc.v1.AdvisorProto.FailureType;
import io.github.preagile.reputationpool.grpc.v1.AdvisorProto.LeaseHandle;
import io.github.preagile.reputationpool.grpc.v1.AdvisorProto.Outcome;
import io.github.preagile.reputationpool.grpc.v1.AdvisorProto.RegisterRequest;
import io.github.preagile.reputationpool.grpc.v1.AdvisorProto.ReleaseRequest;
import io.github.preagile.reputationpool.grpc.v1.AdvisorProto.ReportRequest;
import io.github.preagile.reputationpool.grpc.v1.AdvisorProto.ResourceId;
import io.github.preagile.reputationpool.grpc.v1.AdvisorProto.ResourceKind;
import io.github.preagile.reputationpool.grpc.v1.ReputationAdvisorGrpc;
import io.grpc.ChannelCredentials;
import io.grpc.Grpc;
import io.grpc.InsecureChannelCredentials;
import io.grpc.ManagedChannel;
import io.grpc.Metadata;
import io.grpc.stub.MetadataUtils;
final class Worker {
private final ReputationAdvisorGrpc.ReputationAdvisorBlockingStub advisor;
/**
* Loopback data plane: InsecureChannelCredentials. Behind a TLS terminator this becomes
* TlsChannelCredentials.create() and nothing else in this class changes.
*/
Worker(String target, String apiKey) {
var headers = new Metadata();
headers.put(Metadata.Key.of("x-api-key", Metadata.ASCII_STRING_MARSHALLER), apiKey);
ChannelCredentials credentials = InsecureChannelCredentials.create();
ManagedChannel channel = Grpc.newChannelBuilder(target, credentials).build();
this.advisor = ReputationAdvisorGrpc.newBlockingStub(channel)
.withInterceptors(MetadataUtils.newAttachHeadersInterceptor(headers));
}
void runOnce() {
var proxy = ResourceId.newBuilder()
.setKind(ResourceKind.PROXY)
.setValue("proxy-1.example.net:8080")
.build();
var context = Context.newBuilder().setValue("checkout-us").build();
advisor.register(RegisterRequest.newBuilder().setResource(proxy).build()); // idempotent
AcquireResponse acquired = advisor.acquire(AcquireRequest.newBuilder().setContext(context).build());
if (!acquired.getGranted()) {
return; // nothing eligible right now — back off and retry
}
LeaseHandle lease = acquired.getLease();
long startedAt = System.nanoTime();
try {
useProxy(lease.getResource().getValue()); // your work
report(lease, Outcome.newBuilder()
.setSuccess(Outcome.Success.newBuilder().setLatency(elapsed(startedAt)))
.build());
} catch (java.io.IOException failed) {
report(lease, Outcome.newBuilder()
.setFailure(Outcome.Failure.newBuilder()
.setType(FailureType.CONNECTION_RESET)
.setLatency(elapsed(startedAt)))
.build());
} finally {
advisor.release(ReleaseRequest.newBuilder().setLease(lease).build());
}
}
private void report(LeaseHandle lease, Outcome outcome) {
advisor.report(ReportRequest.newBuilder()
.setResource(lease.getResource())
.setContext(lease.getContext())
.setOutcome(outcome)
.build());
}
/** google.protobuf.Duration from a nanosecond stopwatch reading. */
private static com.google.protobuf.Duration elapsed(long startedAtNanos) {
long nanos = System.nanoTime() - startedAtNanos;
return com.google.protobuf.Duration.newBuilder()
.setSeconds(nanos / 1_000_000_000L)
.setNanos((int) (nanos % 1_000_000_000L))
.build();
}
}같은 루프, TypeScript 로
퍼블리시된 JS 클라이언트는 아직 없으므로 @grpc/proto-loader 로 런타임에 advisor.proto 를 읽어 씁니다. keepCase: false 로 두면 위 JSON 예제와 같은 lowerCamelCase 필드 이름을 쓸 수 있습니다. proto 파일은 퍼블리시된 reputation-pool-grpc 아티팩트 안에 들어 있고 원본은 엔진 레포에 있습니다.
import { credentials, loadPackageDefinition, Metadata, type ServiceClientConstructor } from "@grpc/grpc-js";
import { loadSync } from "@grpc/proto-loader";
// proto-loader hands back a plain object nested by proto package segment, typed only as GrpcObject.
// Declare the one path you use rather than casting the whole tree away — the cast is what makes a
// snippet like this stop compiling the moment you touch it.
interface AdvisorPackage {
io: {
github: {
preagile: {
reputationpool: { grpc: { v1: { ReputationAdvisor: ServiceClientConstructor } } };
};
};
};
}
/** Your work. Replace this with the request you actually make through the leased resource. */
declare function useProxy(endpoint: string): Promise<void>;
const definition = loadSync("advisor.proto", { keepCase: false, defaults: true, longs: String });
const advisorPackage = loadPackageDefinition(definition) as unknown as AdvisorPackage;
const { ReputationAdvisor } = advisorPackage.io.github.preagile.reputationpool.grpc.v1;
// Loopback data plane: plaintext. Point this at a TLS-terminating endpoint and it becomes
// credentials.createSsl() — nothing else in this file changes.
const advisor = new ReputationAdvisor(process.env.RP_GRPC ?? "localhost:9093", credentials.createInsecure());
const auth = new Metadata();
auth.set("x-api-key", process.env.RP_API_KEY ?? "");
const call = <T>(method: string, request: unknown): Promise<T> =>
new Promise((resolve, reject) => {
advisor[method](request, auth, (error: Error | null, response: T) =>
error === null ? resolve(response) : reject(error),
);
});
// google.protobuf.Duration accepts the "12.345s" JSON form.
const seconds = (ms: number): string => (ms / 1000).toFixed(3) + "s";
interface LeaseHandle {
token: string;
leasedAt: string;
expiresAt: string;
}
export async function runOnce(): Promise<void> {
const resource = { kind: "PROXY", value: "proxy-1.example.net:8080" };
const context = { value: "checkout-us" };
await call("Register", { resource }); // idempotent
const acquired = await call<{ granted: boolean; lease?: LeaseHandle }>("Acquire", { context });
if (!acquired.granted || acquired.lease === undefined) {
return; // nothing eligible right now — back off and retry
}
const lease = acquired.lease;
const startedAt = Date.now();
try {
await useProxy(resource.value);
await call("Report", {
resource,
context,
outcome: { success: { latency: seconds(Date.now() - startedAt) } },
});
} catch {
await call("Report", {
resource,
context,
outcome: { failure: { type: "TIMEOUT", latency: seconds(Date.now() - startedAt) } },
});
} finally {
await call("Release", { lease: { resource, context, ...lease } });
}
}
6. 반영됐는지 확인하기
컨트롤플레인으로 풀을 되읽어 보세요. 등록한 리소스가 보이고, 보고한 컨텍스트마다 셀이 하나씩 있고, 이벤트 피드에 리스와 쿨다운이 찍혀 있어야 합니다.
curl -sS "$RP_ORIGIN/api/pools/resources" -H "Authorization: Bearer $RP_JWT"
curl -sS "$RP_ORIGIN/api/events?limit=10" -H "Authorization: Bearer $RP_JWT"resources가 비어 있나요?Register호출이 지금 읽고 있는 테넌트에 닿지 않았습니다 — API 키와 JWT 가 같은 테넌트에 속하는지 확인하세요.- 행에
contexts: 0이 보이나요? 등록은 했지만 보고를 한 번도 하지 않은 것입니다. 셀은Acquire가 아니라Report가 만듭니다. - 모든 REST 호출이
401인가요? 토큰이 만료됐거나(다시 로그인하세요) 관리자 환경변수 세 개가 설정되지 않아 콘솔이 통째로 비활성입니다. 전체 에러 표는 REST API 레퍼런스에 있습니다. - grpcurl 이
UNAVAILABLE을 주나요?9093에서 아무것도 듣고 있지 않습니다.docker compose ps를 확인하세요. 앱은 그 포트를127.0.0.1에만 공개하므로 다른 머신에서는 의도적으로 닿지 않습니다.
호스티드 데이터플레인에서 달라지는 것
루프에서는 아무것도 달라지지 않습니다. RPC, 메시지 모양, API 키 헤더, 평판 모델은 모두 엔진의 것이고 이 서비스는 엔진을 fork 하지 않고 퍼블리시된 의존성으로 소비합니다 — 두 곳에서 같은 코드가 돕니다. 워커를 여러분의 스택에서 호스티드로 옮기는 일은 두 군데를 고치는 것입니다.
- 대상 주소 —
localhost:9093이 호스티드 엔드포인트로 바뀝니다. - 채널 자격증명 —
-plaintext/InsecureChannelCredentials/credentials.createInsecure()가 각각의 TLS 짝으로 바뀝니다.
없는 것은 그 엔드포인트 자체입니다. 호스티드 데이터플레인의 호스트명·포트·TLS 계약이 아직 공개된 바 없고, 이 페이지는 그것을 지어내지 않습니다. 그게 도입을 막는 부분이라면 [email protected] 로 알려 주세요 — 알려진 공백이고, 누가 필요한지가 언제 만들지를 정합니다. 평판 상태도 아직 배포 사이를 옮겨가지 않습니다. 풀은 실제 트래픽으로 다시 예열됩니다.
이 페이지의 모든 내용은 PreAgile/reputation-pool-cloud 의 배포 파일 — compose.yaml, compose.prod.yaml, Caddyfile.prod — 에서 나왔습니다. 포트 바인딩에 대한 설명을 그냥 믿는 대신 직접 확인하고 싶다면 그곳을 보세요.