Chạy Elasticsearch một node trong Docker thì chỉ cần một lệnh docker run. Đưa đúng cấu hình đó lên Kubernetes thì mọi thứ bắt đầu lệch: pod ở trạng thái Pending, sự kiện FailedMount hiện lên rồi biến mất, container khởi động lại liên tục với lỗi AccessDeniedException, và khi đã chạy được thì ứng dụng Laravel vẫn báo "No alive nodes found". Bài này đi theo một tình huống điển hình: Elasticsearch 7.15 một node, bật xpack security, dùng chung cho nhiều site Laravel. Mỗi bước có lệnh kiểm tra và cách phân quyền theo tiền tố index để mỗi site chỉ chạm vào dữ liệu của mình.

Khuôn chart và fsGroup nói chung đã có trong Chart dùng chung: tái sử dụng Helm cho Laravel, MySQL và Redis. Bài này tập trung vào các lỗi riêng của Elasticsearch.

Bài toán: một cụm Elasticsearch dùng chung cho nhiều site

Mỗi site Laravel cần tìm kiếm full-text, nhưng một cụm riêng cho từng site rất tốn RAM. Cách gọn hơn là một cụm single-node, bật xpack security, và mỗi site có một role và một user riêng, chỉ được thao tác trên các index có tiền tố của site đó. Cụm này chạy như StatefulSet một replica, có Service headless và một volume 10Gi cho dữ liệu.

StatefulSet Elasticsearch tối thiểu

Bản dưới đây gom những điểm đã rút ra sau các lần lỗi bên dưới. Tên search, elasticsearch chỉ là ví dụ.

apiVersion: apps/v1
kind: StatefulSet
metadata:
  name: elasticsearch
  namespace: search
spec:
  serviceName: elasticsearch
  replicas: 1
  selector:
    matchLabels:
      app: elasticsearch
  template:
    metadata:
      labels:
        app: elasticsearch
    spec:
      securityContext:
        fsGroup: 1000
      containers:
      - name: elasticsearch
        image: docker.elastic.co/elasticsearch/elasticsearch:7.15.1
        env:
        - name: discovery.type
          value: single-node
        - name: xpack.security.enabled
          value: "true"
        - name: ES_JAVA_OPTS
          value: "-Xms1g -Xmx1g"
        - name: ELASTIC_PASSWORD
          valueFrom:
            secretKeyRef:
              name: elasticsearch-secret
              key: ELASTIC_PASSWORD
        ports:
        - containerPort: 9200
          name: http
        - containerPort: 9300
          name: transport
        resources:
          requests:
            cpu: 500m
            memory: 2Gi
          limits:
            memory: 2Gi
        readinessProbe:
          tcpSocket:
            port: 9200
        volumeMounts:
        - name: data
          mountPath: /usr/share/elasticsearch/data
  volumeClaimTemplates:
  - metadata:
      name: data
    spec:
      accessModes: ["ReadWriteOnce"]
      storageClassName: linode-block-storage
      resources:
        requests:
          storage: 10Gi
---
apiVersion: v1
kind: Service
metadata:
  name: elasticsearch
  namespace: search
spec:
  clusterIP: None
  selector:
    app: elasticsearch
  ports:
  - port: 9200
    name: http

Giá trị biến môi trường phải là chuỗi nên "true" cần có dấu nháy. Khi bật xpack security thì httpGet vào cổng 9200 nhận 401 và bị tính là thất bại, vì vậy ví dụ dùng tcpSocket. Mật khẩu elastic lấy từ Secret, không viết thẳng vào manifest. Dòng ES_JAVA_OPTS là phần bổ sung ngoài ví dụ gốc: đặt heap khoảng một nửa limit bộ nhớ. Từ 7.11 Elasticsearch tự chọn heap theo bộ nhớ của container; đặt tường minh để heap cố định và dễ dự đoán, không bắt buộc.

Pod Pending: thiếu CPU hoặc RAM

Lỗi đầu tiên thường không thuộc về Elasticsearch. Với request vài trăm millicore và vài GiB RAM, pod có thể nằm ở Pending, và thêm một node nhỏ vẫn không đủ. Lý do là Allocatable của node luôn thấp hơn dung lượng ghi trên gói máy, vì hệ điều hành, kubelet và pod hệ thống đã chiếm một phần. Với node 2GB không đủ chỗ, còn node 4GB thì pod lên. Xem phần "Allocated resources" của kubectl describe node để biết chỗ trống thật, cách làm có trong Pod Pending vì Insufficient cpu: tính chỗ trống thật từ kubectl describe node.

FailedMount "Unable to find device path": thoáng qua, đừng nhầm với lỗi quyền

Khi describe pod, bạn có thể thấy chuỗi sự kiện như sau: FailedScheduling với "unbound immediate PersistentVolumeClaims", rồi SuccessfulAttachVolume, rồi vài lần FailedMount với thông báo "Unable to find device path out of attempted paths", và cuối cùng container vẫn Started. Đó là volume đang được tạo và gắn vào node; kubelet thử lại vài lần rồi thành công. Nó không phải nguyên nhân khiến pod restart. Nếu nó lặp mãi, xem Pod kẹt ContainerCreating trên LKE: Multi-Attach và Unable to find device path.

AccessDeniedException ở data/nodes: dữ liệu thuộc root, tiến trình uid 1000

Pod restart liên tục, kubectl logs hiện:

ElasticsearchException[failed to bind service]; nested:
AccessDeniedException[/usr/share/elasticsearch/data/nodes];

Theo tài liệu của Elasticsearch, image chạy bằng user elasticsearch (uid 1000, gid 0). Volume ext4 mới được định dạng thường có owner là root, nên tiến trình không tạo được thư mục nodes. Đây là giả thuyết khớp với triệu chứng, chưa được kiểm chứng vì chưa có kết quả ls -ln trên thư mục data. Bạn nên tự kiểm tra trước khi sửa:

kubectl -n search exec elasticsearch-0 -- ls -ldn /usr/share/elasticsearch/data
kubectl -n search exec elasticsearch-0 -- id

Một cách sửa hay gặp là thêm postStart chạy chown -R 1000:1000, kèm đổi vài thứ khác cùng lúc (thêm storageClassName, bỏ subPath), nên không biết thay đổi nào có tác dụng. Riêng subPath là một ứng viên nguyên nhân thứ hai: thư mục con do kubelet tạo cho subPath thường thuộc root nên cũng có thể gây AccessDeniedException. Biến này cần được thử riêng, ví dụ này chưa tách nó ra. Hướng này có vấn đề: hook postStart chạy bằng chính user của container (uid 1000), mà user thường không đổi được owner của thư mục thuộc root. Hook cũng chạy song song với entrypoint nên không đảm bảo xong trước khi Elasticsearch ghi dữ liệu.

CáchCơ chếGhi chú
postStart chownChạy cùng lúc với entrypoint, bằng uid 1000Không đổi được thư mục của root, có race
fsGroup: 1000Kubelet đổi group của volume trước khi container chạyGọn nhất, cần driver CSI hỗ trợ
Bỏ subPathTránh thư mục con do kubelet tạo, thường thuộc rootChưa kiểm chứng riêng, cần thử tách biến
initContainer rootchown -R 1000:0 rồi mới tới container chínhChạy xong trước container chính, nhưng cần quyền root

Khuyến nghị: dùng fsGroup: 1000 như manifest ở trên, hoặc initContainer chạy root. Nguyên lý chung về owner trên volume có trong Permission denied trên volume: vì sao chown trong Dockerfile không đủ.

Phân quyền xpack theo tiền tố index

Với tài khoản elastic (superuser), tạo role giới hạn theo tiền tố index, rồi tạo user gắn với role đó. Ví dụ cho một site tên site-a:

curl -u "elastic:$ELASTIC_PASSWORD" -X POST \
  "http://localhost:9200/_security/role/site_a_role" \
  -H "Content-Type: application/json" \
  -d '{
    "cluster": ["monitor"],
    "indices": [{
      "names": ["site-a-*"],
      "privileges": ["create_index", "read", "write", "delete"]
    }]
  }'

curl -u "elastic:$ELASTIC_PASSWORD" -X POST \
  "http://localhost:9200/_security/user/site-a" \
  -H "Content-Type: application/json" \
  -d @user-site-a.json

File user-site-a.json chứa password, roles và full_name. Cách này giữ mật khẩu của user site ngoài lịch sử shell; nhớ xoá file JSON sau khi tạo. Mật khẩu elastic trong -u hiện trong danh sách tiến trình lúc chạy; có thể đưa vào file cấu hình curl bằng -K.

Hãy chọn tiền tố có dấu phân tách rõ: mẫu site-a-* cũng khớp site-a-b-..., nên không đặt tiền tố của site này làm phần đầu tên site khác. Một lỗi hay gặp là cấp "all" cho cả cluster lẫn index: chạy được nhưng user của site thành quản trị cả cụm. Hãy bắt đầu từ monitor và bốn privilege ở trên. Một số thư viện còn cần view_index_metadata hoặc manage để kiểm tra index tồn tại; hãy thêm khi gặp lỗi và điều này chưa được kiểm chứng.

Thử ranh giới quyền bằng chính user của site: tạo index ngoài tiền tố phải bị từ chối.

curl -u "site-a:$SITE_A_PASSWORD" -X PUT "http://localhost:9200/other-index"
# {"error":{"type":"security_exception",
#   "reason":"action [indices:admin/create] is unauthorized for user [site-a] ..."},
#  "status":403}

Còn PUT /site-a-products phải trả acknowledged: true.

Nối ứng dụng và lỗi "No alive nodes found"

Trong Laravel, tiền tố index trong cấu hình ứng dụng phải khớp với names của role; gọi là tiền tố, không phải hậu tố. Chuỗi kết nối có dạng user:mật-khẩu@host:9200, và nếu mật khẩu có ký tự đặc biệt như @, :, / thì phải mã hoá URL (percent-encode), nếu không chuỗi bị tách sai.

Khi ứng dụng log "No alive nodes found in your cluster", nslookup trả đúng địa chỉ chưa chứng minh được gì, vì DNS chỉ là bước đầu. Trong ví dụ này, lỗi hết sau khi tạo role, user và đặt chuỗi kết nối có thông tin xác thực. Thiếu xác thực là nguyên nhân khả dĩ nhất, nhưng đây là suy luận từ trình tự, vì client có thể gộp cả lỗi kết nối lẫn lỗi 401 vào một thông báo. Cách loại trừ theo thứ tự từ trong pod của ứng dụng:

  1. DNS: nslookup elasticsearch.search.svc.cluster.local.
  2. TCP: curl -sS -m 5 http://elasticsearch.search.svc.cluster.local:9200. Nhận 401 nghĩa là đã tới nơi và còn thiếu xác thực.
  3. Xác thực: thêm -u với user của site, gọi /_cluster/health.
  4. Quyền: thử tạo hoặc ghi một index có tiền tố đúng.
  5. Cuối cùng mới xem cấu hình client: scheme, cổng, mã hoá mật khẩu.

Index yellow trên cụm một node

Sau khi tạo index, _cat/indices hiện trạng thái yellow. Đây là hành vi bình thường: index mặc định có một replica, mà cụm một node không có chỗ đặt bản sao nên replica chưa được gán. Dữ liệu vẫn đầy đủ. Muốn trạng thái xanh, đặt number_of_replicas bằng 0 cho các index hiện có, và dùng index template cho index tạo về sau (thực hiện bằng tài khoản elastic):

curl -u "elastic:$ELASTIC_PASSWORD" -X PUT "http://localhost:9200/site-a-*/_settings" \
  -H "Content-Type: application/json" \
  -d '{"index": {"number_of_replicas": 0}}'

curl -u "elastic:$ELASTIC_PASSWORD" -X PUT "http://localhost:9200/_index_template/site-a" \
  -H "Content-Type: application/json" \
  -d '{"index_patterns": ["site-a-*"], "template": {"settings": {"number_of_replicas": 0}}}'

Bỏ replica nghĩa là không còn bản sao dữ liệu nào, nên cụm cần sao lưu riêng; ví dụ này chưa có quy trình backup hay restore.

Checklist

  • Request bộ nhớ nhỏ hơn Allocatable còn trống của ít nhất một node.
  • Tách sự kiện FailedMount thoáng qua khỏi lỗi quyền trong log container.
  • Chạy ls -ldn và id trước khi sửa owner; ưu tiên fsGroup hoặc initContainer.
  • Mỗi site một role, một user, names theo tiền tố; kiểm chứng bằng một lần 403 có chủ đích.
  • Không dùng all cho cluster hay index; mật khẩu đi qua Secret, không đi vào lịch sử shell.
  • "No alive nodes found": loại trừ theo thứ tự DNS, TCP, xác thực, quyền, client.
  • Đặt replica về 0 cho cụm một node và có kế hoạch sao lưu riêng.

Cú pháp role nằm trong tài liệu Create or update roles API của Elasticsearch, còn index template ở tài liệu Index templates. Cách Kubernetes áp dụng fsGroup cho volume có trong tài liệu Security Context. Nền tảng về volume: Volume, PersistentVolume, PVC và StorageClass trên Kubernetes.

Bài tiếp theo

Volume mount che nội dung image: nhận biết và dựng lại thư mục trên PVC. Mount volume vào thư mục có sẵn code thì nội dung trong image bị che; bài sau chỉ cách nhận ra và dựng lại.