자체 호스팅 Qdrant 인스턴스 보안
자체 호스팅 Qdrant 인스턴스 보안 (tutorials-operations-secure-qdrant)
Qdrant는 데이터를 보호하고 여러 수준에서 접근을 제어할 수 있는 포괄적인 보안·접근 제어 기능을 제공해요. 기본적으로 이런 기능은 Qdrant Cloud 배포에서 활성화돼 있어요. 하지만 자체 호스팅 Qdrant는 기본적으로 인증도, 암호화도 없는 상태로 시작해요. 호스트의 모든 인터페이스가 키나 비밀번호 없이 접근 가능하죠. 그래서 자체 호스팅 인스턴스는 어떤 네트워크에 연결하기 전에 반드시 보안을 설정하는 게 중요해요.
이 튜토리얼에서는 자체 호스팅 Qdrant 인스턴스를 단계별로 보안 설정하는 방법을 살펴볼게요. 우리가 할 일은 다음과 같아요.
- TLS 활성화 — 클라이언트와 Qdrant 인스턴스 사이의 트래픽을 암호화해요.
- 관리자 API 키 설정 — 모든 요청에 인증을 요구해요.
- 읽기 전용 키로 소비자 제한 — 의도치 않은 쓰기를 방지해요.
- 세분화된 접근 API 키 발급 — 특정 컬렉션으로 권한 범위를 제한해요.
Qdrant Cloud 배포는 항상 기본적으로 보안이 설정돼 있어요. 이 튜토리얼은 자체 호스팅 배포만 다뤄요. Docker Compose를 사용하지만, 동일한 보안 기능과 구성은 모든 자체 호스팅 배포 방식에 적용돼요.
| 시간: 45분 | 난이도: 중급 |
|---|
사전 준비 (Prerequisites)
- Docker와 Docker Compose 설치
- 터미널에
curl사용 가능 - 로컬 자체 서명 인증서 생성을 위한 mkcert (설치 안내)
- TLS는 Qdrant 1.2 이상, API 키 인증은 Qdrant 1.2 이상, 세분화된 접근 API 키(JWT)는 Qdrant 1.9 이상이 필요해요. 이 튜토리얼은 이 모든 기능을 포함한 최신 Qdrant 이미지를 사용해요.
Step 1: 보안이 없는 인스턴스 시작
표준 Docker Compose 설정으로 Qdrant를 시작해요. docker-compose.yml 파일을 만들어 볼게요.
services:
qdrant:
image: qdrant/qdrant
ports:
- "6333:6333"
- "6334:6334"
volumes:
- qdrant_storage:/qdrant/storage:z
volumes:
qdrant_storage:
인스턴스를 시작해요.
docker compose up -d
REST API 포트에 curl로 접속할 때 자격 증명이 필요 없는지 확인해 볼게요.
curl http://localhost:6333
예상 응답:
{"title":"qdrant - vector search engine","version":"...","commit":"..."}
api-key헤더가 필요 없었어요. 이 포트에 접근할 수 있는 사람은 누구나 모든 데이터를 읽고, 쓰고, 삭제할 수 있어요.
Step 2: TLS 활성화
암호화되지 않은 연결은 네트워크의 누구나 전송 중인 API 키와 데이터를 읽을 수 있게 해요. 모든 트래픽을 암호화하도록 TLS를 활성화할게요.
먼저 시스템 신뢰 저장소에 로컬 인증 기관을 추가해서, curl과 브라우저가 별도 플래그 없이 인증서를 수용하도록 해요.
mkcert -install
다음으로 mkcert로 로컬 신뢰 인증서를 생성해요.
mkdir tls && mkcert -cert-file tls/cert.pem -key-file tls/key.pem localhost 127.0.0.1
Python이나 TypeScript 클라이언트를 사용한다면, 클라이언트가 인증서를 찾을 수 있도록 다음 환경 변수를 설정해요.
export SSL_CERT_FILE=$(mkcert -CAROOT)/rootCA.pem
export NODE_EXTRA_CA_CERTS=$(mkcert -CAROOT)/rootCA.pem
Java 클라이언트를 사용한다면, 인증서를 Java 신뢰 저장소에 추가해요.
keytool -importcert \
-file $(mkcert -CAROOT)/rootCA.pem \
-alias mkcert-local \
-keystore $JAVA_HOME/lib/security/cacerts \
-storepass changeit -noprompt
다음으로 TLS를 활성화하고 인증서 파일을 마운트하도록 docker-compose.yml을 업데이트해요.
services:
qdrant:
image: qdrant/qdrant
ports:
- "6333:6333"
- "6334:6334"
environment:
QDRANT__SERVICE__ENABLE_TLS: "true"
QDRANT__TLS__CERT: /qdrant/tls/cert.pem
QDRANT__TLS__KEY: /qdrant/tls/key.pem
volumes:
- ./tls:/qdrant/tls:ro
- qdrant_storage:/qdrant/storage:z
volumes:
qdrant_storage:
변경 사항을 적용하려면 Qdrant를 재시작해요.
docker compose down && docker compose up -d
이제 암호화되지 않은 HTTP 요청은 거부돼요.
curl http://localhost:6333
하지만 HTTPS 요청은 성공해요.
curl https://localhost:6333
TLS 구성에 대해 더 자세히 알아보려면 Security > TLS를 참고하세요.
Step 3: 관리자 API 키 활성화
인증을 활성화하지 않으면, Qdrant 인스턴스에 네트워크로 접근할 수 있는 사람은 누구나 모든 데이터를 읽고, 쓰고, 삭제할 수 있어요. 매 요청마다 자격 증명을 요구하도록 관리자 API 키를 설정해요.
docker-compose.yml에서 QDRANT__SERVICE__API_KEY 환경 변수를 API 키로 설정해요.
environment:
QDRANT__SERVICE__ENABLE_TLS: "true"
QDRANT__TLS__CERT: /qdrant/tls/cert.pem
QDRANT__TLS__KEY: /qdrant/tls/key.pem
QDRANT__SERVICE__API_KEY: "my-admin-key"
변경 사항을 적용하려면 Qdrant를 재시작해요.
docker compose down && docker compose up -d
이제 인증되지 않은 요청이 거부되는지 확인해 볼게요.
curl https://localhost:6333/collections
동일한 동작이 클라이언트에도 적용돼요. API 키가 없으면 포인트를 인제스트(upsert)하는 것이 차단돼요.
from qdrant_client import QdrantClient, models
client = QdrantClient(url="https://localhost:6333")
try:
client.create_collection(
collection_name="my_collection",
vectors_config=models.VectorParams(size=4, distance=models.Distance.COSINE),
)
client.upsert(
collection_name="my_collection",
points=[models.PointStruct(id=1, vector=[0.1, 0.2, 0.3, 0.4])],
)
except Exception as e:
print(e) # 401 Unauthorized
import { QdrantClient } from "@qdrant/js-client-rest";
client = new QdrantClient({ url: "https://localhost:6333" });
try {
await client.createCollection("my_collection", {
vectors: { size: 4, distance: "Cosine" },
});
await client.upsert("my_collection", {
points: [{ id: 1, vector: [0.1, 0.2, 0.3, 0.4] }],
});
} catch (e: any) {
console.error(e.message); // 401 Unauthorized
}
let client = Qdrant::from_url("https://localhost:6334").build()?;
let result = client
.create_collection(
CreateCollectionBuilder::new("my_collection")
.vectors_config(VectorParamsBuilder::new(4, Distance::Cosine)),
)
.await;
if let Err(e) = result {
println!("{}", e); // Unauthorized
}
let result = client
.upsert_points(UpsertPointsBuilder::new(
"my_collection",
vec![PointStruct::new(1, vec![0.1_f32, 0.2, 0.3, 0.4], [("source", "tutorial".into())])],
))
.await;
if let Err(e) = result {
println!("{}", e); // Unauthorized
}
import static io.qdrant.client.PointIdFactory.id;
import static io.qdrant.client.VectorsFactory.vectors;
import io.qdrant.client.QdrantClient;
import io.qdrant.client.QdrantGrpcClient;
import io.qdrant.client.grpc.Collections.Distance;
import io.qdrant.client.grpc.Collections.VectorParams;
import io.qdrant.client.grpc.Points.PointStruct;
import java.util.List;
client = new QdrantClient(
QdrantGrpcClient.newBuilder("localhost", 6334, true).build());
try {
client.createCollectionAsync("my_collection",
VectorParams.newBuilder()
.setSize(4)
.setDistance(Distance.Cosine)
.build()).get();
client.upsertAsync("my_collection", List.of(
PointStruct.newBuilder()
.setId(id(1))
.setVectors(vectors(0.1f, 0.2f, 0.3f, 0.4f))
.build()
)).get();
} catch (Exception e) {
System.out.println(e.getMessage()); // UNAUTHENTICATED
}
using Qdrant.Client;
using Qdrant.Client.Grpc;
var client = new QdrantClient(host: "localhost", port: 6334, https: true);
try
{
await client.CreateCollectionAsync(
collectionName: "my_collection",
vectorsConfig: new VectorParams { Size = 4, Distance = Distance.Cosine }
);
await client.UpsertAsync(
collectionName: "my_collection",
points: new List<PointStruct>
{
new() { Id = 1, Vectors = new[] { 0.1f, 0.2f, 0.3f, 0.4f } }
}
);
}
catch (Exception e)
{
Console.WriteLine(e.Message); // Unauthenticated
}
import (
"context"
"fmt"
"github.com/qdrant/go-client/qdrant"
)
client, err = qdrant.NewClient(&qdrant.Config{
Host: "localhost",
Port: 6334,
UseTLS: true,
})
if err != nil {
panic(err)
}
client.CreateCollection(context.Background(), &qdrant.CreateCollection{
CollectionName: "my_collection",
VectorsConfig: qdrant.NewVectorsConfig(&qdrant.VectorParams{
Size: 4,
Distance: qdrant.Distance_Cosine,
}),
})
_, err = client.Upsert(context.Background(), &qdrant.UpsertPoints{
CollectionName: "my_collection",
Points: []*qdrant.PointStruct{
{
Id: qdrant.NewIDNum(1),
Vectors: qdrant.NewVectors(0.1, 0.2, 0.3, 0.4),
},
},
})
if err != nil {
fmt.Println(err) // Unauthenticated
}
관리자 API 키를 사용하면 요청이 성공해요.
curl -X PUT 'https://localhost:6333/collections/my_collection' \
-H 'Content-Type: application/json' \
-H 'api-key: *** \
-d '{
"vectors": {
"size": 4,
"distance": "Cosine"
}
}'
curl -X PUT 'https://localhost:6333/collections/my_collection/points' \
-H 'Content-Type: application/json' \
-H 'api-key: *** \
-d '{
"points": [
{"id": 1, "vector": [0.1, 0.2, 0.3, 0.4]}
]
}'
client = QdrantClient(url="https://localhost:6333", api_key="my-admin-key")
client.create_collection(
collection_name="my_collection",
vectors_config=models.VectorParams(size=4, distance=models.Distance.COSINE),
)
client.upsert(
collection_name="my_collection",
points=[models.PointStruct(id=1, vector=[0.1, 0.2, 0.3, 0.4])],
)
client = new QdrantClient({ url: "https://localhost:6333", apiKey: *** });
await client.createCollection("my_collection", {
vectors: { size: 4, distance: "Cosine" },
});
await client.upsert("my_collection", {
points: [{ id: 1, vector: [0.1, 0.2, 0.3, 0.4] }],
});
let client = Qdrant::from_url("https://localhost:6334")
.api_key("my-admin-key")
.build()?;
client
.create_collection(
CreateCollectionBuilder::new("my_collection")
.vectors_config(VectorParamsBuilder::new(4, Distance::Cosine)),
)
.await?;
client
.upsert_points(UpsertPointsBuilder::new(
"my_collection",
vec![PointStruct::new(1, vec![0.1_f32, 0.2, 0.3, 0.4], [("source", "tutorial".into())])],
))
.await?;
client = new QdrantClient(
QdrantGrpcClient.newBuilder("localhost", 6334, true)
.withApiKey("my-admin-key")
.build());
client.createCollectionAsync("my_collection",
VectorParams.newBuilder()
.setSize(4)
.setDistance(Distance.Cosine)
.build()).get();
client.upsertAsync("my_collection", List.of(
PointStruct.newBuilder()
.setId(id(1))
.setVectors(vectors(0.1f, 0.2f, 0.3f, 0.4f))
.build()
)).get();
client = new QdrantClient(host: "localhost", port: 6334, https: true, apiKey: ***
await client.CreateCollectionAsync(
collectionName: "my_collection",
vectorsConfig: new VectorParams { Size = 4, Distance = Distance.Cosine }
);
await client.UpsertAsync(
collectionName: "my_collection",
points: new List<PointStruct>
{
new() { Id = 1, Vectors = new[] { 0.1f, 0.2f, 0.3f, 0.4f } }
}
);
client, err = qdrant.NewClient(&qdrant.Config{
Host: "localhost",
Port: 6334,
APIKey: ***
UseTLS: true,
})
if err != nil {
panic(err)
}
client.CreateCollection(context.Background(), &qdrant.CreateCollection{
CollectionName: "my_collection",
VectorsConfig: qdrant.NewVectorsConfig(&qdrant.VectorParams{
Size: 4,
Distance: qdrant.Distance_Cosine,
}),
})
client.Upsert(context.Background(), &qdrant.UpsertPoints{
CollectionName: "my_collection",
Points: []*qdrant.PointStruct{
{
Id: qdrant.NewIDNum(1),
Vectors: qdrant.NewVectors(0.1, 0.2, 0.3, 0.4),
},
},
})
관리자 API 키(API key 순환 포함)에 대해 더 알아보려면 Security > Authentication을 참고하세요.
Step 4: 읽기 전용 API 키 활성화
데이터만 읽으면 되는 서비스를 위해 별도의 읽기 전용 API 키를 발급해요. 이 키를 쓰면 클라이언트 애플리케이션이 검색하고 읽을 수는 있지만, upsert·삭제·수정은 할 수 없어요.
docker-compose.yml에서 QDRANT__SERVICE__READ_ONLY_API_KEY 환경 변수를 읽기 전용 키로 설정해요.
environment:
QDRANT__SERVICE__ENABLE_TLS: "true"
QDRANT__TLS__CERT: /qdrant/tls/cert.pem
QDRANT__TLS__KEY: /qdrant/tls/key.pem
QDRANT__SERVICE__API_KEY: "my-admin-key"
QDRANT__SERVICE__READ_ONLY_API_KEY: "my-read-only-key"
Qdrant를 재시작해요.
docker compose down && docker compose up -d
읽기 전용 키로 삭제 시도가 거부되는지 확인해 볼게요.
curl -X POST https://localhost:6333/collections/my_collection/points/delete \
-H "api-key: *** \
-H "Content-Type: application/json" \
-d '{"points": [1]}'
또는 클라이언트로 확인해 볼게요.
client = QdrantClient(url="https://localhost:6333", api_key="my-read-only-key")
try:
client.delete(
collection_name="my_collection",
points_selector=models.PointIdsList(points=[1]),
)
except Exception as e:
print(e) # 403 Forbidden
client = new QdrantClient({ url: "https://localhost:6333", apiKey: "my-re...key" });
try {
await client.delete("my_collection", { points: [1] });
} catch (e: any) {
console.error(e.message); // 403 Forbidden
}
let client = Qdrant::from_url("https://localhost:6334")
.api_key("my-read-only-key")
.build()?;
let result = client
.delete_points(
DeletePointsBuilder::new("my_collection").points(PointsIdsList {
ids: vec![1.into()],
}),
)
.await;
if let Err(e) = result {
println!("{}", e); // PermissionDenied
}
client = new QdrantClient(
QdrantGrpcClient.newBuilder("localhost", 6334, true)
.withApiKey("my-read-only-key")
.build());
try {
client.deleteAsync("my_collection", List.of(id(1))).get();
} catch (Exception e) {
System.out.println(e.getMessage()); // PERMISSION_DENIED
}
client = new QdrantClient(host: "localhost", port: 6334, https: true, apiKey: "my-re...y");
try
{
await client.DeleteAsync(collectionName: "my_collection", ids: (ulong[])[1]);
}
catch (Exception e)
{
Console.WriteLine(e.Message); // PermissionDenied
}
client, err = qdrant.NewClient(&qdrant.Config{
Host: "localhost",
Port: 6334,
APIKey: "my-re...ey",
UseTLS: true,
})
if err != nil {
panic(err)
}
_, err = client.Delete(context.Background(), &qdrant.DeletePoints{
CollectionName: "my_collection",
Points: qdrant.NewPointsSelector(qdrant.NewIDNum(1)),
})
if err != nil {
fmt.Println(err) // PermissionDenied
}
읽기 전용 키로는 읽기가 여전히 성공해요.
curl https://localhost:6333/collections/my_collection \
-H "api-key: ***"
두 키를 동시에 사용할 수 있어요. 자세한 내용은 Security > Read-Only API Key를 참고하세요.
Step 5: 세분화된 접근 API 키 설정 (JWT)
관리자 키와 읽기 전용 키는 전역적으로 적용돼요. 더 세밀한 제어를 원한다면 세분화된 접근 API 키(JSON Web Token, JWT)를 사용해요. 예를 들어 JWT로 한 컬렉션에는 읽기-쓰기 접근을, 다른 컬렉션에는 읽기 전용 접근을 제공할 수 있어요.
docker-compose.yml에서 JWT RBAC를 활성화해요.
environment:
QDRANT__SERVICE__ENABLE_TLS: "true"
QDRANT__TLS__CERT: /qdrant/tls/cert.pem
QDRANT__TLS__KEY: /qdrant/tls/key.pem
QDRANT__SERVICE__API_KEY: "my-admin-key"
QDRANT__SERVICE__READ_ONLY_API_KEY: "my-read-only-key"
QDRANT__SERVICE__JWT_RBAC: "true"
재시작해요.
docker compose down && docker compose up -d
관리자 API 키로 두 번째 컬렉션 other_collection을 만들어요.
curl -X PUT https://localhost:6333/collections/other_collection \
-H "api-key: *** \
-H "Content-Type: application/json" \
-d '{"vectors": {"size": 4, "distance": "Cosine"}}'
Web UI에서 JWT를 생성해요.
-
https://localhost:6333/dashboard#/jwt를 열어요.연결이 비공개가 아니라는 경고가 나오면, 인증서가 자체 서명돼 있기 때문이에요. 그렇다면 브라우저를 재시작하면 인증서를 신뢰하는 것으로 인식할 거예요.
-
Collection Access를 선택해요.
-
my_collection에 대해 Read와 Write를 선택해요. -
other_collection에 대해 Read만 선택해요. -
생성된 JWT Token을 복사해요.
Web UI로 원하는 접근 수준의 JWT 토큰을 생성하는 모습.
JWT 토큰은 프로그래밍 방식으로도 생성할 수 있어요. JWT 토큰 생성에 사용할 수 있는 라이브러리 목록은 Security > Granular Access API Keys를 참고하세요.
JWT 토큰을 사용하면 my_collection(rw 범위)에 쓰기가 성공해야 해요.
curl -X PUT https://localhost:6333/collections/my_collection/points \
-H "api-key: *** \
-H "Content-Type: application/json" \
-d '{"points": [{"id": 2, "vector": [0.5, 0.6, 0.7, 0.8]}]}'
클라이언트로도 확인해 볼게요.
client = QdrantClient(url="https://localhost:6333", api_key="<your-jwt>")
client.upsert(
collection_name="my_collection",
points=[models.PointStruct(id=2, vector=[0.5, 0.6, 0.7, 0.8])],
)
client = new QdrantClient({ url: "https://localhost:6333", apiKey: *** });
await client.upsert("my_collection", {
points: [{ id: 2, vector: [0.5, 0.6, 0.7, 0.8] }],
});
let client = Qdrant::from_url("https://localhost:6334")
.api_key("<your-jwt>")
.build()?;
client
.upsert_points(UpsertPointsBuilder::new(
"my_collection",
vec![PointStruct::new(2, vec![0.5_f32, 0.6, 0.7, 0.8], [("source", "tutorial".into())])],
))
.await?;
client = new QdrantClient(
QdrantGrpcClient.newBuilder("localhost", 6334, true)
.withApiKey("<your-jwt>")
.build());
client.upsertAsync("my_collection", List.of(
PointStruct.newBuilder()
.setId(id(2))
.setVectors(vectors(0.5f, 0.6f, 0.7f, 0.8f))
.build()
)).get();
client = new QdrantClient(host: "localhost", port: 6334, https: true, apiKey: ***
await client.UpsertAsync(
collectionName: "my_collection",
points: new List<PointStruct>
{
new() { Id = 2, Vectors = new[] { 0.5f, 0.6f, 0.7f, 0.8f } }
}
);
client, err = qdrant.NewClient(&qdrant.Config{
Host: "localhost",
Port: 6334,
APIKey: ***
UseTLS: true,
})
if err != nil {
panic(err)
}
client.Upsert(context.Background(), &qdrant.UpsertPoints{
CollectionName: "my_collection",
Points: []*qdrant.PointStruct{
{
Id: qdrant.NewIDNum(2),
Vectors: qdrant.NewVectors(0.5, 0.6, 0.7, 0.8),
},
},
})
하지만 other_collection(r 범위)에 쓰기는 차단돼요.
curl -X PUT https://localhost:6333/collections/other_collection/points \
-H "api-key: *** \
-H "Content-Type: application/json" \
-d '{"points": [{"id": 2, "vector": [0.5, 0.6, 0.7, 0.8]}]}'
클라이언트로도 확인해 볼게요.
client = QdrantClient(url="https://localhost:6333", api_key="<your-jwt>")
try:
client.upsert(
collection_name="other_collection",
points=[models.PointStruct(id=2, vector=[0.5, 0.6, 0.7, 0.8])],
)
except Exception as e:
print(e) # 403 Forbidden
client = new QdrantClient({ url: "https://localhost:6333", apiKey: *** });
try {
await client.upsert("other_collection", {
points: [{ id: 2, vector: [0.5, 0.6, 0.7, 0.8] }],
});
} catch (e: any) {
console.error(e.message); // 403 Forbidden
}
let client = Qdrant::from_url("https://localhost:6334")
.api_key("<your-jwt>")
.build()?;
let result = client
.upsert_points(UpsertPointsBuilder::new(
"other_collection",
vec![PointStruct::new(2, vec![0.5_f32, 0.6, 0.7, 0.8], [("source", "tutorial".into())])],
))
.await;
if let Err(e) = result {
println!("{}", e); // PermissionDenied
}
client = new QdrantClient(
QdrantGrpcClient.newBuilder("localhost", 6334, true)
.withApiKey("<your-jwt>")
.build());
try {
client.upsertAsync("other_collection", List.of(
PointStruct.newBuilder()
.setId(id(2))
.setVectors(vectors(0.5f, 0.6f, 0.7f, 0.8f))
.build()
)).get();
} catch (Exception e) {
System.out.println(e.getMessage()); // PERMISSION_DENIED
}
client = new QdrantClient(host: "localhost", port: 6334, https: true, apiKey: ***
try
{
await client.UpsertAsync(
collectionName: "other_collection",
points: new List<PointStruct>
{
new() { Id = 2, Vectors = new[] { 0.5f, 0.6f, 0.7f, 0.8f } }
}
);
}
catch (Exception e)
{
Console.WriteLine(e.Message); // PermissionDenied
}
client, err = qdrant.NewClient(&qdrant.Config{
Host: "localhost",
Port: 6334,
APIKey: ***
UseTLS: true,
})
if err != nil {
panic(err)
}
_, err = client.Upsert(context.Background(), &qdrant.UpsertPoints{
CollectionName: "other_collection",
Points: []*qdrant.PointStruct{
{
Id: qdrant.NewIDNum(2),
Vectors: qdrant.NewVectors(0.5, 0.6, 0.7, 0.8),
},
},
})
if err != nil {
fmt.Println(err) // PermissionDenied
}
사용 가능한 JWT 클레임 전체 목록과 접근 수준 표는 Security > Granular Access Control with JWT를 참고하세요.
다음 단계 (What's Next)
이제 인스턴스에 TLS 암호화, API 키 인증, 쿼리 소비자용 읽기 전용 키, 컬렉션 범위 JWT 토큰이 설정됐어요. 프로덕션 배포를 위해 다음도 고려해 보세요.
- Network Bind — Qdrant가 수신 대기하는 네트워크 인터페이스를 제한해요.
- API Key Rotation — 분산 배포에서 다운타임 없이 관리자 API 키를 순환해요.
- Production Checklist — 프로덕션을 위한 보안·신뢰성 설정 전체 체크리스트예요.