Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 25 additions & 1 deletion backend/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ dependencies {

tasks.named('test') {
useJUnitPlatform {
excludeTags 'benchmark', 'minio-integration', 'claim-concurrency', 'local-e2e', 'storage-worker-e2e', 'real-pdf-version-e2e', 'vector-search-performance', 'vector-storage-performance', 'worker-indexing-throughput', 'worker-horizontal-scaling', 'worker-queue-backpressure', 'document-indexing-e2e-load', 'chunk-quality-performance'
excludeTags 'benchmark', 'minio-integration', 'claim-concurrency', 'local-e2e', 'storage-worker-e2e', 'real-pdf-version-e2e', 'vector-search-performance', 'vector-storage-performance', 'worker-indexing-throughput', 'worker-horizontal-scaling', 'worker-queue-backpressure', 'document-indexing-e2e-load', 'chunk-quality-performance', 'opensql-ha-connection'
}
}

Expand Down Expand Up @@ -488,3 +488,27 @@ tasks.register('openSqlVerification') {
openSqlClaimPerformanceTest
)
}

tasks.register('openSqlHaConnectionTest', Test) {
group = 'verification'
description = '3노드 OpenSQL의 리더 마이그레이션 경로와 이중 OpenProxy 앱 경로를 검증합니다.'
testClassesDirs = sourceSets.test.output.classesDirs
classpath = sourceSets.test.runtimeClasspath
useJUnitPlatform {
includeTags 'opensql-ha-connection'
}
maxParallelForks = 1
outputs.upToDateWhen { false }
doFirst {
// 1. 외부 경로를 빠뜨린 테스트가 로컬 기본 DB에서 성공한 것처럼 보이지 않게 막는다.
[
'OPENSQL_APP_JDBC_URL', 'OPENSQL_APP_USER', 'OPENSQL_APP_PASSWORD',
'OPENSQL_MIGRATION_JDBC_URL', 'OPENSQL_MIGRATION_USER', 'OPENSQL_MIGRATION_PASSWORD',
'OPENSQL_PROXY_A_JDBC_URL', 'OPENSQL_PROXY_B_JDBC_URL'
].each { name ->
if (!System.getenv(name)?.trim()) {
throw new GradleException("필수 3노드 OpenSQL 환경 변수가 비어 있습니다: ${name}")
}
}
}
}
29 changes: 29 additions & 0 deletions backend/src/main/resources/application-opensql-ha.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
spring:
config:
activate:
on-profile: opensql-ha

datasource:
# 두 OpenProxy 진입점은 OPENSQL_APP_JDBC_URL에만 지정한다.
url: ${OPENSQL_APP_JDBC_URL}
username: ${OPENSQL_APP_USER}
password: ${OPENSQL_APP_PASSWORD}
driver-class-name: org.postgresql.Driver
hikari:
pool-name: docgrid-opensql-ha-pool
maximum-pool-size: ${OPENSQL_APP_POOL_SIZE:5}
connection-timeout: ${OPENSQL_APP_CONNECTION_TIMEOUT_MS:5000}
validation-timeout: ${OPENSQL_APP_VALIDATION_TIMEOUT_MS:3000}

jpa:
hibernate:
ddl-auto: validate
show-sql: false

flyway:
# DDL 권한은 애플리케이션 계정에 주지 않고 리더를 찾는 별도 경로에서만 사용한다.
enabled: true
url: ${OPENSQL_MIGRATION_JDBC_URL}
user: ${OPENSQL_MIGRATION_USER}
password: ${OPENSQL_MIGRATION_PASSWORD}
locations: classpath:db/migration
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
package com.opensource.docgrid.opensql;

import static org.assertj.core.api.Assertions.assertThat;

import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Statement;

import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.Timeout;

/**
* 외부 OpenSQL 3노드에서 DDL 계정과 런타임 계정의 접속 경로·권한 경계를 확인한다.
*
* <p>애플리케이션 쿼리는 OpenProxy를, 마이그레이션 쿼리는 현재 리더를 사용해야 한다. 이 테스트는
* 자격 증명을 출력하거나 스키마를 변경하지 않으며 실제 프록시 장애는 별도의 운영 절차에서 주입한다.
*/
@Tag("integration")
@Tag("opensql-ha-connection")
@DisplayName("OpenSQL 3노드 애플리케이션·마이그레이션 접속 경계")
class OpenSqlHaConnectionTest {

@Test
@Timeout(30)
@DisplayName("별도 마이그레이션 계정은 리더의 DocGrid DB에 접속한다")
void migrationAccountConnectsToLeader() throws SQLException {
// 1. 프록시를 우회한 경로가 현재 쓰기 가능한 리더인지 확인한다.
try (Connection connection = connect(
"OPENSQL_MIGRATION_JDBC_URL", "OPENSQL_MIGRATION_USER", "OPENSQL_MIGRATION_PASSWORD")) {
assertThat(connection.getMetaData().getUserName()).isEqualTo("docgrid_migrator");
assertClusterTarget(connection);
assertThat(queryBoolean(connection, "SELECT has_schema_privilege(current_user, 'public', 'CREATE')"))
.isTrue();
}
}

@Test
@Timeout(30)
@DisplayName("사용 가능한 OpenProxy 진입점은 최소 권한 DocGrid 계정으로 연결된다")
void bothProxiesConnectToDocGrid() throws SQLException {
// 1. 정상 시에는 두 프록시를 각각 확인하고, A 장애 시에는 살아 있는 B를 직접 확인한다.
String[] urls = Boolean.parseBoolean(System.getenv("OPENSQL_PROXY_A_DOWN"))
? new String[] {"OPENSQL_PROXY_B_JDBC_URL"}
: new String[] {"OPENSQL_PROXY_A_JDBC_URL", "OPENSQL_PROXY_B_JDBC_URL"};
for (String urlName : urls) {
try (Connection connection = connect(urlName, "OPENSQL_APP_USER", "OPENSQL_APP_PASSWORD")) {
assertAppTarget(connection);
}
}
}

@Test
@Timeout(30)
@DisplayName("다중 호스트 URL은 프록시 A 장애 시에도 DocGrid에 연결된다")
void multiHostUrlConnects() throws SQLException {
// 1. 장애 주입 실행에서는 프록시 A가 실제로 차단된 상태로 같은 URL을 다시 시험한다.
try (Connection connection = connect(
"OPENSQL_APP_JDBC_URL", "OPENSQL_APP_USER", "OPENSQL_APP_PASSWORD")) {
assertAppTarget(connection);
}
}

private Connection connect(String urlName, String userName, String passwordName) throws SQLException {
return DriverManager.getConnection(
System.getenv(urlName), System.getenv(userName), System.getenv(passwordName));
}

private void assertAppTarget(Connection connection) throws SQLException {
assertThat(connection.getMetaData().getUserName()).isEqualTo("docgrid_app");
assertClusterTarget(connection);
// 런타임 계정은 Flyway가 소유한 스키마를 변경할 수 없어야 한다.
assertThat(queryBoolean(connection, "SELECT has_schema_privilege(current_user, 'public', 'CREATE')"))
.isFalse();
}

private void assertClusterTarget(Connection connection) throws SQLException {
assertThat(connection.getCatalog()).isEqualTo("docgrid");
assertThat(queryBoolean(connection, "SELECT pg_is_in_recovery()"))
.isFalse();
}

private boolean queryBoolean(Connection connection, String sql) throws SQLException {
try (Statement statement = connection.createStatement(); ResultSet result = statement.executeQuery(sql)) {
assertThat(result.next()).isTrue();
return result.getBoolean(1);
}
}
}
46 changes: 46 additions & 0 deletions docs/test-results/opensql-three-node-app-connection-20260923.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
# OpenSQL 3노드 DocGrid 접속 준비·검증 — 2026-09-23

## 범위와 현재 결과

기존 Google Cloud의 동일 영역에 있는 Rocky Linux 9.7 `x86_64` 3노드를 대상으로 했다. 공개 문서에는 프로젝트 ID, 사설 IP, 운영 서버 경로를 기재하지 않는다. 기존 라이선스, 호스트명, VM 사양, 공급사의 `opensql` 프록시 풀은 변경하지 않았다.

| 항목 | 결과 |
|---|---|
| node1/2/3 VM | 3대 모두 `RUNNING`; 사설 IP는 비공개 |
| OpenProxy의 기존 `opensql` 풀 | node2·3 모두 세션 풀링, primary 경로, `postgres` DB에 연결 중 |
| 전용 DB·계정 | node1 리더에 `docgrid` DB와 `docgrid_migrator`, `docgrid_app` 생성 |
| 계정 권한 | DB 소유자 `docgrid_migrator`; `docgrid_app`은 DB 연결 가능, `public` 스키마 CREATE 권한 없음. 신규 객체용 기본 권한 규칙 2개 확인 |
| 새 DB 복제 | 세 노드에서 `docgrid` 조회 성공. `pg_is_in_recovery()`는 node1 `false`, node2·3 `true` |
| 벡터 확장 | 세 노드의 `docgrid` DB에서 `vector` 0.8.1 확인 |
| DocGrid 전용 OpenProxy 풀 | **미적용**. 앱 암호의 보호된 전달에 별도 승인이 필요 |
| DocGrid 애플리케이션·Flyway·E2E | **미실행**. 프록시 풀과 비밀값 전달이 완료되지 않음 |
| 프록시 장애 시 JDBC 전환 | **미실행** |
| 로컬 검증 | Java 테스트 컴파일, 셸 문법, YAML 파싱, `git diff --check` 통과. 환경 변수 없는 HA 테스트는 의도대로 즉시 중단 |

node1에는 root 전용 권한 `0600`인 자격 증명 파일이 생성됐다. 문서·저장소·로그에는 암호를 기록하지 않았다. node1에 임시로 만들었던 앱 암호 사본은 삭제했다. DB와 두 계정은 실제로 생성됐으므로 재실행 시 초기화 스크립트가 이름 충돌을 감지하고 중단한다.

## 구현한 경계

- `application-opensql-ha.yml`은 애플리케이션 연결에 `OPENSQL_APP_JDBC_URL`·`docgrid_app`을, Flyway 연결에 `OPENSQL_MIGRATION_JDBC_URL`·`docgrid_migrator`를 사용한다. 어느 암호도 설정 파일에 하드코딩하지 않는다.
- 앱 연결은 node2·3의 OpenProxy 6432 포트를 다중 호스트 URL로 지정한다. 마이그레이션 연결은 OpenProxy를 통하지 않고 PostgreSQL 5432 포트의 현재 리더를 찾도록 지정한다.
- 새 OpenProxy 풀은 기존 `opensql` 풀을 수정하지 않고 `docgrid` 풀을 추가하도록 준비했다. 초기 설정은 세션 풀링과 primary 라우팅이며, 읽기 분산은 별도 정합성 검증 전까지 활성화하지 않는다.
- `openSqlHaConnectionTest`는 마이그레이션·앱 계정 분리, DB 대상, 쓰기 가능한 리더, 앱 계정의 스키마 생성 권한 부재, 프록시 A·B 개별 접속과 다중 호스트 연결을 확인한다. 일반 `test`에서는 외부 인프라 의존 테스트를 제외한다.

## 연결 및 재검증 절차

비밀값 전달이 승인되고 두 프록시의 전용 풀이 적용된 뒤에만 아래를 실행한다. SSH 터널은 로컬 `127.0.0.1`에만 바인딩한다. 포트 `15432/25432/35432`는 각 PostgreSQL 노드, `16432/26432`는 node2·3의 OpenProxy에 전달한다. 공개 DB 방화벽 규칙은 만들지 않는다.

```text
OPENSQL_MIGRATION_JDBC_URL=jdbc:postgresql://127.0.0.1:15432,127.0.0.1:25432,127.0.0.1:35432/docgrid?currentSchema=public&sslmode=disable&targetServerType=primary&connectTimeout=3
OPENSQL_APP_JDBC_URL=jdbc:postgresql://127.0.0.1:16432,127.0.0.1:26432/docgrid?currentSchema=public&sslmode=disable&connectTimeout=3
OPENSQL_PROXY_A_JDBC_URL=jdbc:postgresql://127.0.0.1:16432/docgrid?currentSchema=public&sslmode=disable&connectTimeout=3
OPENSQL_PROXY_B_JDBC_URL=jdbc:postgresql://127.0.0.1:26432/docgrid?currentSchema=public&sslmode=disable&connectTimeout=3
OPENSQL_MIGRATION_USER=docgrid_migrator
OPENSQL_APP_USER=docgrid_app
```

`OPENSQL_MIGRATION_PASSWORD`와 `OPENSQL_APP_PASSWORD`는 보호된 비밀값으로만 주입한다. 애플리케이션을 시작할 때는 `SPRING_PROFILES_ACTIVE=opensql-ha`를 사용한다. 먼저 `./backend/gradlew -p backend openSqlHaConnectionTest`를 실행하고, 이어서 Spring Boot 기동·Flyway 전체 적용·Hibernate 검증과 핵심 API를 확인한다. 기존 `openSqlVerification`은 테스트 전용 스키마를 생성하므로 앱 계정이 아니라 별도로 승인된 마이그레이션 경로와 외부 MinIO·BGE-M3를 사용해 실행해야 한다. 이후 PDF·DOCX 인덱싱과 권한 검색을 재검증한다.

프록시 장애 시험은 정상 상태에서 두 엔드포인트를 개별 확인한 뒤 A만 중단하고, 동일한 `OPENSQL_APP_JDBC_URL`로 새 연결이 B에 도달하는지 재실행한다. 이때 `OPENSQL_PROXY_A_DOWN=true`를 지정해 A 개별 접속 검사만 제외한다. A를 복구한 후 양쪽 개별 연결을 다시 확인한다. 이 절차는 기존 연결의 무중단 보장이 아니라 **새 JDBC 연결의 전환**을 검증한다.

이전 단일 노드와 이번 3노드 결과는 하드웨어·네트워크가 달라 절대 성능을 직접 비교하지 않는다. 현재 3개 VM은 동일 영역에 있으므로 영역 장애 내성도 검증했다고 주장하지 않는다.
61 changes: 61 additions & 0 deletions scripts/opensql/add-docgrid-openproxy-pool.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
#!/usr/bin/env bash
set -euo pipefail

# 공급사의 기존 opensql 풀은 유지하고 DocGrid 전용 DB·계정 풀만 추가한다.
# 이 스크립트는 실행 중인 프록시를 재시작하지 않으므로 적용 후 접속 검증이 필요하다.
secret_file="${OPENSQL_APP_SECRET_FILE:?root-only secret file required}"
config="${OPENSQL_PROXY_CONFIG_FILE:?proxy config path required}"
node1_host="${OPENSQL_NODE1_HOST:?node1 host required}"
node2_host="${OPENSQL_NODE2_HOST:?node2 host required}"
node3_host="${OPENSQL_NODE3_HOST:?node3 host required}"

if [[ "${EUID}" -ne 0 ]]; then
printf 'root 권한으로 실행해야 합니다.\n' >&2
exit 1
fi
if [[ ! -f "${config}" || ! -f "${secret_file}" ]]; then
printf 'OpenProxy 설정 또는 비공개 암호 파일이 없습니다.\n' >&2
exit 1
fi
for host in "${node1_host}" "${node2_host}" "${node3_host}"; do
if [[ ! "${host}" =~ ^[[:alnum:].:-]+$ ]]; then
printf '노드 주소 형식이 올바르지 않습니다.\n' >&2
exit 1
fi
done
if grep -q '^\[pools.docgrid\]$' "${config}"; then
printf 'DocGrid 풀이 이미 있어 중단합니다.\n' >&2
exit 1
fi

# 1. 암호를 명령 인자와 출력에 노출하지 않고 root 전용 파일에서만 읽는다.
set -a
. "${secret_file}"
set +a
if [[ ! "${DOCGRID_APP_PASSWORD:-}" =~ ^[[:xdigit:]]{64}$ ]]; then
printf 'DocGrid 앱 암호 형식이 올바르지 않습니다.\n' >&2
exit 1
fi

# 2. 기존 설정을 복구 가능한 형태로 보관한 뒤 같은 디렉터리에서 원자적으로 교체한다.
backup="${config}.before-docgrid-$(date -u +%Y%m%dT%H%M%SZ)"
cp -p "${config}" "${backup}"
chown root:root "${backup}"
chmod 0600 "${backup}"
temporary="$(mktemp "${config}.docgrid.XXXXXX")"
trap 'rm -f "${temporary}"' EXIT
cat "${config}" > "${temporary}"
printf '\n[pools.docgrid]\n' >> "${temporary}"
printf 'pool_mode = "session"\ndefault_role = "primary"\nquery_parser_enabled = false\n' >> "${temporary}"
printf '\n[pools.docgrid.users.0]\nusername = "docgrid_app"\npassword = "%s"\npool_size = 5\n' \
"${DOCGRID_APP_PASSWORD}" >> "${temporary}"
printf '\n[pools.docgrid.shards.0]\nservers = [\n' >> "${temporary}"
printf ' ["%s", 5432, "auto"],\n ["%s", 5432, "auto"],\n ["%s", 5432, "auto"],\n' \
"${node1_host}" "${node2_host}" "${node3_host}" >> "${temporary}"
printf ']\ndatabase = "docgrid"\nuse_patroni = true\npatroni_port = "8008"\n' >> "${temporary}"
chown opensql:opensql "${temporary}"
chmod 0600 "${temporary}"
mv "${temporary}" "${config}"
trap - EXIT

printf 'DocGrid OpenProxy 풀 추가 완료 (백업: %s)\n' "${backup}"
63 changes: 63 additions & 0 deletions scripts/opensql/provision-docgrid-database.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
#!/usr/bin/env bash
set -euo pipefail

# 3노드 검증 클러스터의 현재 리더에서만 DocGrid 전용 DB와 분리된 계정을 한 번 생성한다.
# 기존 DB/계정이 있으면 중단해 자격 증명이나 데이터를 덮어쓰지 않는다.
container="${OPENSQL_CONTAINER_NAME:?container name required}"
credentials="${OPENSQL_DOCGRID_CREDENTIALS_FILE:?root-only credential path required}"
superuser_env="${OPENSQL_SUPERUSER_ENV_FILE:?container credential path required}"
psql_bin="${OPENSQL_PSQL_BIN:?container psql path required}"

if [[ "${EUID}" -ne 0 ]]; then
printf 'root 권한으로 실행해야 합니다.\n' >&2
exit 1
fi
if [[ -e "${credentials}" ]]; then
printf '기존 DocGrid 자격 증명 파일이 있어 중단합니다: %s\n' "${credentials}" >&2
exit 1
fi

psql_super() {
local database="$1"
docker exec -i "${container}" sh -c '
. "$1"
export PGPASSWORD="${PG_SUPERUSER_PASSWORD}"
exec "$2" -h 127.0.0.1 -U postgres -d "$3" -v ON_ERROR_STOP=1 -At
' sh "${superuser_env}" "${psql_bin}" "${database}"
}

# 1. 리더와 이름 충돌을 먼저 확인한다.
if [[ "$(printf 'SELECT pg_is_in_recovery();\n' | psql_super postgres)" != "f" ]]; then
printf '%s은 현재 리더가 아닙니다.\n' "${container}" >&2
exit 1
fi
if [[ "$(printf "SELECT count(*) FROM pg_database WHERE datname = 'docgrid';\n" | psql_super postgres)" != "0" ]]; then
printf 'docgrid DB가 이미 있어 중단합니다.\n' >&2
exit 1
fi
if [[ "$(printf "SELECT count(*) FROM pg_roles WHERE rolname IN ('docgrid_migrator', 'docgrid_app');\n" | psql_super postgres)" != "0" ]]; then
printf 'DocGrid 계정이 이미 있어 중단합니다.\n' >&2
exit 1
fi

# 2. 암호는 저장소나 명령 인자가 아닌 root 전용 영속 볼륨에만 보관한다.
umask 077
migration_password="$(openssl rand -hex 32)"
app_password="$(openssl rand -hex 32)"
printf 'DOCGRID_MIGRATION_PASSWORD=%s\nDOCGRID_APP_PASSWORD=%s\n' \
"${migration_password}" "${app_password}" > "${credentials}"
chmod 0600 "${credentials}"
chown root:root "${credentials}"

# 3. DB 소유자와 런타임 계정을 분리하고 벡터 확장을 준비한다.
printf "CREATE ROLE docgrid_migrator LOGIN PASSWORD '%s';\nCREATE ROLE docgrid_app LOGIN PASSWORD '%s';\nCREATE DATABASE docgrid OWNER docgrid_migrator;\n" \
"${migration_password}" "${app_password}" | psql_super postgres >/dev/null
printf '%s\n' \
'CREATE EXTENSION IF NOT EXISTS vector WITH SCHEMA public;' \
'GRANT CONNECT ON DATABASE docgrid TO docgrid_app;' \
'GRANT USAGE ON SCHEMA public TO docgrid_app;' \
'ALTER DEFAULT PRIVILEGES FOR ROLE docgrid_migrator IN SCHEMA public GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO docgrid_app;' \
'ALTER DEFAULT PRIVILEGES FOR ROLE docgrid_migrator IN SCHEMA public GRANT USAGE, SELECT ON SEQUENCES TO docgrid_app;' \
| psql_super docgrid >/dev/null

printf 'DocGrid DB와 분리 계정 생성 완료: %s\n' "${container}"
Loading