网站搜索、日志检索、商品筛选和向量检索一旦超出单表查询的舒适区,数据库里不断叠加 LIKE、复杂排序和聚合通常会越来越慢。Elasticsearch 把文档建立为倒排索引,并提供分词、相关性排序、聚合、地理查询和向量搜索;Kibana 则负责查询、可视化、用户权限与运维管理。
本文以 Elasticsearch 9.5.3、Kibana 9.5.3、Docker Compose、Caddy 和 Ubuntu 24.04 为基准,在 VPS 上搭建一套安全的单节点搜索服务。方案启用账号认证和 Elasticsearch HTTP TLS,9200 与 5601 都只绑定回环地址,公网只开放 Caddy 的 443;同时覆盖 JVM、分片、磁盘水位、用户权限、快照备份、升级和故障排查。版本与官方资料按 2026 年 9 月 16 日核对,部署时仍应再次查看最新补丁版本与升级说明。
Elasticsearch 不是关系型数据库的替代品。业务真相仍应保存在 PostgreSQL、MySQL 等主数据库中,再通过应用、Logstash、Beats 或 CDC 把需要检索的数据写入 Elasticsearch。
它更适合这些需求:
- 网站、文档、商品和知识库全文搜索;
- 日志集中检索、字段聚合和异常调查;
- 自动补全、模糊匹配、同义词与相关性排序;
- 指标、事件和安全数据的时间范围分析;
- 结合 dense vector、关键词与过滤条件实现混合搜索。
如果只是查看容器日志且资源有限,Grafana Loki 通常更轻量;如果核心是 SIEM、Agent 管理和安全规则,可参考 Wazuh 部署方案。Elasticsearch 的优势是通用搜索与分析能力,代价是更高的内存、磁盘和运维成本。
| 架构 | 优点 | 局限 | 适合场景 |
|---|---|---|---|
| 单节点 Elasticsearch | 成本低、配置简单、排障直接 | VPS、磁盘或进程故障都会中断服务;副本无法提供容错 | 开发、内部工具、可重建索引的小型业务 |
| 同一 VPS 运行 3 个节点 | 能验证集群配置 | 仍共享主机和磁盘,不是真正高可用,还会争抢内存 | 仅用于实验 |
| 3 个独立数据节点 | 可分配副本并容忍单节点故障 | 成本更高,需要滚动升级、证书和容量规划 | 关键搜索、日志与安全业务 |
本文使用 discovery.type=single-node。单节点索引应把副本数设为 0,否则集群会因副本无处放置而保持黄色;这不代表数据有第二份副本。业务不能接受停机时,应迁移到至少 3 个独立故障域,并用副本、快照和恢复演练共同保证可用性。
Elasticsearch 对内存与 NVMe 随机读写都很敏感。配置不能只按文档数量判断,还要看字段数量、分词方式、聚合、写入峰值、查询并发、保留时间和副本数。
| 使用规模 | VPS 建议 | JVM Heap 起点 | 说明 |
|---|---|---|---|
| 开发与功能验证 | 4 vCPU、8GB 内存、80GB SSD | 2GB | 低并发,索引可重建 |
| 小型生产搜索 | 8 vCPU、16GB 内存、200GB 以上 NVMe | 4–6GB | 给 Kibana、系统和文件缓存留足空间 |
| 日志或关键业务 | 3 台起,每台 8 vCPU、16–32GB 内存、独立 NVMe | 按节点压测 | 使用副本、ILM 和独立快照仓库 |
官方建议 Heap 不超过容器可用内存的 50%,并指出更小的 Heap 有时能给文件系统缓存留下更多空间。不要看到主机有 16GB 就把 -Xmx 设成 12GB;Elasticsearch 的 off-heap、Kibana、Docker 和 Linux 页缓存同样需要内存。
磁盘容量可以先用下面的思路估算:
每日原始数据 × 索引膨胀系数 × 保留天数 × (1 + 副本数) × 安全余量
索引膨胀与 mapping、是否保存 _source、分词器和字段类型有关,必须用真实数据抽样压测。单节点磁盘最好长期低于 70%–75%,不要等触发 flood-stage watermark、索引被置为只读后才扩容。
| 端口 | 用途 | 本文暴露方式 |
|---|---|---|
443/TCP | Kibana 公网 HTTPS | 由 Caddy 对外提供 |
5601/TCP | Kibana HTTP | 只绑定 127.0.0.1 |
9200/TCP | Elasticsearch HTTPS API | 只绑定 127.0.0.1,并要求认证 |
9300/TCP | Elasticsearch 节点间 transport | 不映射到宿主机 |
9200 不是数据库后台页面,不应直接开放给全网。即使启用了密码与 TLS,也要使用云安全组、主机防火墙和私网限制来源。可以结合数据库与中间件不要直接暴露公网的安全清单检查边界。
以下命令以 Ubuntu 24.04 为例。Docker 建议从官方仓库安装;已有 Docker Compose v2 时不要运行来路不明的一键脚本。
sudo apt update
sudo apt full-upgrade -y
sudo apt install -y ca-certificates curl openssl caddy jq
docker --version
docker compose version
sudo systemctl enable --now caddy
生产环境还需要设置日志轮转、健康检查和资源限制,可参考 Docker Compose 生产配置与回滚清单。
Elasticsearch 9 的官方 Docker 生产文档要求 vm.max_map_count=1048576。先写入独立 sysctl 文件并立即加载:
printf 'vm.max_map_count=1048576\n' | \
sudo tee /etc/sysctl.d/99-elasticsearch.conf >/dev/null
sudo sysctl --system
sysctl vm.max_map_count
如果 VPS 正在频繁使用 swap,应先查清内存不足原因。Elasticsearch 延迟对换页非常敏感;本文同时启用 bootstrap.memory_lock=true 和容器 memlock,但这不能弥补内存配置过小。
sudo install -d -m 0750 -o "$USER" -g "$USER" /opt/elastic-stack
cd /opt/elastic-stack
sudo install -d -m 0770 -o 1000 -g 0 snapshots
umask 077
生成管理员密码、Kibana 系统密码和三个会话加密密钥:
ELASTIC_PASSWORD="$(openssl rand -hex 32)"
KIBANA_PASSWORD="$(openssl rand -hex 32)"
SECURITY_KEY="$(openssl rand -hex 32)"
SAVED_OBJECTS_KEY="$(openssl rand -hex 32)"
REPORTING_KEY="$(openssl rand -hex 32)"
{
printf 'STACK_VERSION=9.5.3\n'
printf 'CLUSTER_NAME=vps-search\n'
printf 'ELASTIC_PASSWORD=%s\n' "$ELASTIC_PASSWORD"
printf 'KIBANA_PASSWORD=%s\n' "$KIBANA_PASSWORD"
printf 'KIBANA_HOST=search.example.com\n'
printf 'XPACK_SECURITY_KEY=%s\n' "$SECURITY_KEY"
printf 'XPACK_SAVED_OBJECTS_KEY=%s\n' "$SAVED_OBJECTS_KEY"
printf 'XPACK_REPORTING_KEY=%s\n' "$REPORTING_KEY"
} > .env
chmod 600 .env
把 search.example.com 替换成实际域名,并让 DNS A/AAAA 记录指向 VPS。不要把 .env 提交到 Git,也不要在工单、截图和容器日志中公开这些值。
下面的 setup 服务参考 Elastic 官方 Compose 模式:先生成 CA 与节点证书,等 Elasticsearch 可用后设置 kibana_system 密码。创建 /opt/elastic-stack/compose.yml:
services:
setup:
image: docker.elastic.co/elasticsearch/elasticsearch:${STACK_VERSION}
user: "0"
volumes:
- certs:/usr/share/elasticsearch/config/certs
environment:
ELASTIC_PASSWORD: ${ELASTIC_PASSWORD}
KIBANA_PASSWORD: ${KIBANA_PASSWORD}
command: >
bash -c '
test -n "$${ELASTIC_PASSWORD}" || { echo "ELASTIC_PASSWORD is empty"; exit 1; };
test -n "$${KIBANA_PASSWORD}" || { echo "KIBANA_PASSWORD is empty"; exit 1; };
if [ ! -f config/certs/ca.zip ]; then
bin/elasticsearch-certutil ca --silent --pem -out config/certs/ca.zip;
unzip config/certs/ca.zip -d config/certs;
fi;
if [ ! -f config/certs/certs.zip ]; then
printf "instances:\n - name: es01\n dns:\n - es01\n - localhost\n ip:\n - 127.0.0.1\n" > config/certs/instances.yml;
bin/elasticsearch-certutil cert --silent --pem
-out config/certs/certs.zip
--in config/certs/instances.yml
--ca-cert config/certs/ca/ca.crt
--ca-key config/certs/ca/ca.key;
unzip config/certs/certs.zip -d config/certs;
fi;
chown -R root:root config/certs;
find config/certs -type d -exec chmod 750 {} \;;
find config/certs -type f -exec chmod 640 {} \;;
until curl -s --cacert config/certs/ca/ca.crt https://es01:9200 |
grep -q "missing authentication credentials"; do sleep 10; done;
until curl -s -X POST --cacert config/certs/ca/ca.crt
-u "elastic:$${ELASTIC_PASSWORD}"
-H "Content-Type: application/json"
https://es01:9200/_security/user/kibana_system/_password
-d "{\"password\":\"$${KIBANA_PASSWORD}\"}" |
grep -q "^{}"; do sleep 10; done;
touch config/certs/.setup-complete;
'
healthcheck:
test: ["CMD-SHELL", "test -f config/certs/ca/ca.crt"]
interval: 2s
timeout: 5s
retries: 60
es01:
image: docker.elastic.co/elasticsearch/elasticsearch:${STACK_VERSION}
container_name: es01
restart: unless-stopped
depends_on:
setup:
condition: service_healthy
ports:
- "127.0.0.1:9200:9200"
volumes:
- certs:/usr/share/elasticsearch/config/certs:ro
- esdata:/usr/share/elasticsearch/data
- ./snapshots:/mnt/snapshots
environment:
node.name: es01
cluster.name: ${CLUSTER_NAME}
discovery.type: single-node
ELASTIC_PASSWORD: ${ELASTIC_PASSWORD}
bootstrap.memory_lock: "true"
xpack.security.enabled: "true"
xpack.security.http.ssl.enabled: "true"
xpack.security.http.ssl.key: certs/es01/es01.key
xpack.security.http.ssl.certificate: certs/es01/es01.crt
xpack.security.http.ssl.certificate_authorities: certs/ca/ca.crt
path.repo: /mnt/snapshots
ES_JAVA_OPTS: -Xms2g -Xmx2g
mem_limit: 4g
ulimits:
memlock:
soft: -1
hard: -1
nofile:
soft: 65535
hard: 65535
healthcheck:
test:
- CMD-SHELL
- curl -s --cacert config/certs/ca/ca.crt https://localhost:9200 | grep -q "missing authentication credentials"
interval: 10s
timeout: 10s
retries: 60
start_period: 60s
logging:
driver: json-file
options:
max-size: 10m
max-file: "5"
kibana:
image: docker.elastic.co/kibana/kibana:${STACK_VERSION}
container_name: kibana
restart: unless-stopped
depends_on:
setup:
condition: service_completed_successfully
es01:
condition: service_healthy
ports:
- "127.0.0.1:5601:5601"
volumes:
- certs:/usr/share/kibana/config/certs:ro
- kibanadata:/usr/share/kibana/data
environment:
SERVER_NAME: kibana
SERVER_PUBLICBASEURL: https://${KIBANA_HOST}
ELASTICSEARCH_HOSTS: '["https://es01:9200"]'
ELASTICSEARCH_USERNAME: kibana_system
ELASTICSEARCH_PASSWORD: ${KIBANA_PASSWORD}
ELASTICSEARCH_SSL_CERTIFICATEAUTHORITIES: config/certs/ca/ca.crt
XPACK_SECURITY_ENCRYPTIONKEY: ${XPACK_SECURITY_KEY}
XPACK_ENCRYPTEDSAVEDOBJECTS_ENCRYPTIONKEY: ${XPACK_SAVED_OBJECTS_KEY}
XPACK_REPORTING_ENCRYPTIONKEY: ${XPACK_REPORTING_KEY}
mem_limit: 2g
healthcheck:
test:
- CMD-SHELL
- curl -s -I http://localhost:5601 | grep -q "HTTP/1.1 302"
interval: 10s
timeout: 10s
retries: 60
start_period: 60s
logging:
driver: json-file
options:
max-size: 10m
max-file: "5"
volumes:
certs:
esdata:
kibanadata:
本文把 Elasticsearch 容器限制为 4GB,并暂时设置 2GB Heap;Kibana 另限制 2GB。因此主机至少需要 8GB 内存,若还运行 Caddy、Agent 或其他服务,应继续增加余量。业务增长后优先用真实压测调整,而不是机械地把 Heap 加到上限。
启动前做静默渲染,避免把包含密码的完整配置输出到终端历史:
cd /opt/elastic-stack
docker compose config --quiet
docker compose pull
docker compose up -d
docker compose ps
docker compose logs --tail=100 setup es01 kibana
载入 .env 后从 Elasticsearch 容器内部检查版本与集群状态:
set -a
. /opt/elastic-stack/.env
set +a
docker compose exec es01 curl -s \
--cacert config/certs/ca/ca.crt \
-u "elastic:$ELASTIC_PASSWORD" \
https://localhost:9200 | jq
docker compose exec es01 curl -s \
--cacert config/certs/ca/ca.crt \
-u "elastic:$ELASTIC_PASSWORD" \
'https://localhost:9200/_cluster/health?pretty'
单节点在仍有一个副本时可能显示 yellow。这通常是副本无法分配,不等于主分片丢失;正确做法是为单节点索引设 number_of_replicas: 0,不是关闭健康告警。
创建 /etc/caddy/Caddyfile:
search.example.com {
encode zstd gzip
reverse_proxy 127.0.0.1:5601
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains"
X-Content-Type-Options "nosniff"
Referrer-Policy "strict-origin-when-cross-origin"
}
}
校验并重载:
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy
sudo journalctl -u caddy -n 50 --no-pager
现在浏览器只访问 https://search.example.com,使用 elastic 和 .env 中的管理员密码首次登录。不要把 5601 直接发布到公网;Caddy 会自动代理 Kibana 的长连接和普通 HTTP 请求。
elastic 是超级用户,不应放进应用配置。对搜索应用可先创建只允许读取 catalog-* 索引的角色:
PUT /_security/role/catalog_reader
{
"cluster": ["monitor"],
"indices": [
{
"names": ["catalog-*"],
"privileges": ["read", "view_index_metadata"]
}
]
}
在 Kibana 的 Dev Tools 执行上面的请求,再到 Stack Management 创建用户并绑定角色。应用程序更适合使用带过期时间和明确权限的 API Key;密钥只在创建时完整返回一次,应放入 Secret Manager 或受限环境变量,而不是写进代码仓库。
需要写入的应用应创建独立 catalog_writer 角色,只授权目标索引的 create_doc、index 或必要的 mapping 权限。不要为了省事让每个服务都使用 elastic。
单节点先为业务索引设置 1 个主分片、0 个副本:
PUT /_index_template/catalog_template
{
"index_patterns": ["catalog-*"],
"template": {
"settings": {
"number_of_shards": 1,
"number_of_replicas": 0,
"refresh_interval": "5s"
},
"mappings": {
"dynamic": "strict",
"properties": {
"title": { "type": "text" },
"sku": { "type": "keyword" },
"price": { "type": "scaled_float", "scaling_factor": 100 },
"updated_at": { "type": "date" }
}
}
}
}
显式 mapping 能避免日期、数字和 ID 被错误推断,也能防止字段无限增长。分片不是越多越快:官方通用建议是让多数分片保持在约 10–50GB,并低于 2 亿文档;实际仍应以查询延迟、恢复速度和真实数据分布为准。
迁移到三节点后,把副本数改为至少 1,并确认主分片和副本落在不同节点。仅修改副本数不会自动把同一台 VPS 变成高可用。
不要直接打包 /usr/share/elasticsearch/data 或 Docker esdata 卷。Elastic 官方明确要求使用 Snapshot and Restore;单个节点数据目录的文件级复制可能不一致,恢复时可能报错或静默丢数据。
在 Kibana Dev Tools 注册文件系统仓库:
PUT /_snapshot/local_fs
{
"type": "fs",
"settings": {
"location": "/mnt/snapshots",
"compress": true
}
}
验证仓库并创建手动快照:
POST /_snapshot/local_fs/_verify
PUT /_snapshot/local_fs/manual-2026-09-16?wait_for_completion=true
{
"include_global_state": true
}
快照在运行中也能创建,且后续快照会做分段级去重。但 /opt/elastic-stack/snapshots 仍在同一台 VPS 上,不能抵御整盘故障。把仓库复制到异地前,必须确保 Elasticsearch 不再写入该仓库,或使用原子文件系统快照;一边写快照一边用普通文件复制可能得到不一致的仓库。
生产环境更适合直接使用受支持的 S3、GCS 或 Azure repository 插件,并通过 Snapshot Lifecycle Management 设置频率和保留策略。无论哪种方式,都要定期在隔离环境验证恢复,而不是只看备份任务显示成功。
至少监控以下指标:
- 集群
status、未分配分片和节点数量; - JVM Heap 使用率、GC 次数与停顿时间;
- 搜索与写入延迟、线程池拒绝次数;
- 每个索引的文档数、分片大小和字段数量;
- 磁盘使用率、I/O 延迟及 low/high/flood-stage watermark;
- Kibana 状态、登录失败和 Caddy 外部探活。
可以把主机与容器指标接入 Prometheus + Grafana VPS 监控方案。单节点的 green 只代表当前分片分配正常,不代表存在冗余或灾备。
常用检查请求:
GET /_cluster/health
GET /_cat/nodes?v&h=name,heap.percent,ram.percent,cpu,load_1m,disk.used_percent
GET /_cat/indices?v&s=store.size:desc
GET /_cat/shards?v&s=store:desc
GET /_cluster/allocation/explain
单节点无法滚动升级,应安排维护窗口。升级前必须阅读 release notes、检查插件兼容并创建可恢复快照。Elasticsearch 不支持直接降级;升级失败时通常需要用升级前版本重建空集群,再恢复升级前快照。
同一小版本补丁升级可按下面的顺序准备:
cd /opt/elastic-stack
set -a
. ./.env
set +a
docker compose exec es01 curl -s \
--cacert config/certs/ca/ca.crt \
-u "elastic:$ELASTIC_PASSWORD" \
'https://localhost:9200/_cluster/health?pretty'
docker compose pull
docker compose up -d
docker compose ps
docker compose logs --tail=100 es01 kibana
先修改 .env 的 STACK_VERSION,让 Elasticsearch 与 Kibana 使用相同版本。跨大版本不能直接套用这组命令:例如从旧 8.x 迁往当前 9.5.3,官方升级路径要求先到最新 8.19,再处理 Upgrade Assistant、旧索引与 breaking changes。
日志出现 vm.max_map_count 错误时,检查宿主机值,而不是在容器内临时修改:
sysctl vm.max_map_count
cat /etc/sysctl.d/99-elasticsearch.conf
目标值为 1048576。修改后执行 sudo sysctl --system,再重启 Elasticsearch。
cd /opt/elastic-stack
docker compose ps
docker compose logs --tail=200 setup es01 kibana
重点检查 setup 是否成功设置 kibana_system 密码、Kibana 是否信任 ca.crt、两个镜像版本是否一致,以及 Elasticsearch 是否已经通过健康检查。
客户端必须信任 certs 卷中的 CA。不要使用 -k 永久跳过校验。容器内测试应使用:
docker compose exec es01 curl -s \
--cacert config/certs/ca/ca.crt \
https://localhost:9200
未携带账号时返回 missing authentication credentials,反而说明 TLS 和 HTTP 层已经工作。
单节点最常见原因是索引仍配置了 1 个副本。先用 _cat/shards 确认未分配的是 replica,再把对应索引模板和现有索引的 number_of_replicas 调为 0。不要在多节点生产集群里为了变绿而盲目删除副本。
这通常与磁盘 flood-stage watermark 有关。先扩容或清理不再需要的索引、快照和宿主机文件,确认磁盘回到安全水位后再解除 index.blocks.read_only_allow_delete。只解除 block 而不处理磁盘,问题会马上复发。
先确认宿主机能访问回环端口:
curl -I http://127.0.0.1:5601
sudo caddy validate --config /etc/caddy/Caddyfile
sudo journalctl -u caddy -n 100 --no-pager
如果本机正常,再检查 DNS、80/443 云安全组和 UFW。可按 VPS 服务外网访问排查流程逐层定位。
仅做开发验证建议从 4 vCPU、8GB 内存和 80GB SSD 起步;同时运行 Kibana 时,4GB 内存通常过于紧张。小型生产更适合 16GB 内存与 NVMe,并按真实数据压测。
两者都能完成搜索与分析。Elasticsearch 与 Kibana 集成紧密、官方功能和文档集中;OpenSearch 使用自己的发行版与 Dashboards。应根据许可证、插件、客户端兼容和团队经验选择,不能直接混用插件与升级包。
通常是索引副本数为 1,但集群只有一个节点,Elasticsearch 不会把主分片和副本放在同一节点。把单节点索引副本数设为 0 即可,但这也意味着没有节点级冗余。
不建议。本文只绑定 127.0.0.1,远程应用应通过 WireGuard、SSH 隧道或受限私网访问,并继续保留 TLS、认证、最小权限与来源限制。
不可以把它当作可靠备份。官方要求使用 Snapshot and Restore 保存集群内容;节点数据目录的普通文件复制可能不一致。快照仓库本身还要复制到独立故障域并验证恢复。
不能依赖直接降级。Elasticsearch 不支持降级节点;正确回退方式通常是启动升级前版本的空集群,并恢复升级前创建的兼容快照。
