Copy & Run

Kibana 대시보드와 GeoIP 지도로 접속 현황 보기

Docker로 Elasticsearch 8.19와 Kibana 8.19를 띄우고, ingest pipeline의 geoip 프로세서로 접근 로그의 IP에 위치 정보를 붙입니다. 상위 IP, 상태 코드, 시간대별 요청 수, 지도 패널을 대시보드로 저장하고 저장 객체를 내보내 다시 가져옵니다.

실습 노트 · 2026-10-16 발행 · 20분 · 2026-10 확인

접근 로그의 source.ip에 ingest pipeline으로 국가와 위치 필드를 붙이고, Kibana에서 상위 IP, 상태 코드, 시간대별 요청 수, 지도 패널을 한 대시보드에 모읍니다. 로그는 이 글에서 직접 만드는 가짜 데이터이고, 지도에 쓰는 IP는 공개 DNS 서비스 주소 네 개와 비공개 대역 두 개뿐입니다.

이 글의 구성은 보안 기능을 끈 로컬 실습용입니다. 포트를 127.0.0.1에만 바인딩하므로 내 PC 밖에서는 접속되지 않으며, 같은 설정을 공용 네트워크나 서버에 그대로 올리면 안 됩니다. Kibana를 직접 설치하는 방법은 Kibana 설치하고 Elasticsearch에 연결하기에서 다룹니다. 여기서는 Docker 컨테이너로 같은 구성을 빠르게 띄웁니다.

준비물

항목 내용
Docker Docker Desktop (Windows, Git Bash에서 실행)
Elasticsearch 8.19.4 (docker.elastic.co/elasticsearch/elasticsearch:8.19.4)
Kibana 8.19.4 (docker.elastic.co/kibana/kibana:8.19.4)
메모리 Elasticsearch 힙 1GB를 포함해 여유 3GB 이상
네트워크 컨테이너가 인터넷에 접속할 수 있어야 함 (GeoIP 데이터베이스 내려받기, 지도 배경)
Git Bash 도구 curl(8.19.0), awk(GNU Awk 5.4.0, 즉 gawk), python(3.12)
GeoIP 데이터 MaxMind GeoLite2. 사용 조건은 아래 안내 참고
예상 소요 시간 약 40분 (이미지 내려받기와 GeoIP 데이터베이스 내려받기 시간 제외)

명령은 bash 기준입니다. 컨테이너, 네트워크, 볼륨 이름 앞에는 cr-kibana-를 붙이고, 호스트 포트는 Elasticsearch 19600, Kibana 19601을 씁니다. 작업 폴더를 하나 만들어 두고 이 글의 파일을 모두 그 안에 둡니다.

Python은 명령 이름이 python이어야 합니다. 이 환경의 Git Bash에서는 python3가 Microsoft Store 안내용 연결 파일이라 실행되지 않았습니다. awk는 6번의 샘플 로그를 만들 때 쓰는데, 같은 건수를 얻으려면 awk --version이 GNU Awk로 나오는지 확인합니다.

GeoLite2 사용 조건. GeoIP 데이터는 MaxMind의 GeoLite2입니다. Elasticsearch 8.19 공식 문서는 이 데이터베이스가 CC BY-SA 4.0 라이선스로 제공된다고 안내합니다. MaxMind의 GeoLite 최종 사용자 약관(https://www.maxmind.com/en/geolite2/eula)은 MaxMind에 대한 출처 표시를 요구하며, 예시 문구로 "This product includes GeoLite Data created by MaxMind, available from https://www.maxmind.com"을 듭니다. 이 글은 지도 화면을 공개하므로 같은 문구를 본문에 적었습니다. 약관은 데이터의 정확도를 보장하지 않으며, 우편번호보다 정밀한 지리 단위를 안정적으로 식별하지 못하고, 특정 가구나 개인, 도로명 주소를 가려내는 데 쓰지 말라고 안내합니다. 내용은 이 글을 쓴 시점에 확인한 약관을 풀어 쓴 것이므로 업무에 쓰기 전에 원문을 직접 확인합니다.

mkdir -p ~/kibana-geoip-lab && cd ~/kibana-geoip-lab

1. Elasticsearch와 Kibana 띄우기

네트워크와 볼륨을 만들고 두 컨테이너를 실행합니다. 보안 기능을 끄는 xpack.security.enabled=false는 로컬 실습에서만 씁니다.

docker network create cr-kibana-net
docker volume create cr-kibana-esdata

docker run -d --name cr-kibana-es --network cr-kibana-net \
  -p 127.0.0.1:19600:9200 \
  -v cr-kibana-esdata:/usr/share/elasticsearch/data \
  -e discovery.type=single-node \
  -e xpack.security.enabled=false \
  -e ES_JAVA_OPTS="-Xms1g -Xmx1g" \
  docker.elastic.co/elasticsearch/elasticsearch:8.19.4

docker run -d --name cr-kibana-kb --network cr-kibana-net \
  -p 127.0.0.1:19601:5601 \
  -e ELASTICSEARCH_HOSTS=http://cr-kibana-es:9200 \
  docker.elastic.co/kibana/kibana:8.19.4

Elasticsearch가 응답할 때까지 기다린 뒤 상태를 봅니다. Kibana는 기동이 더 느려서 api/status가 응답할 때까지 기다립니다.

until curl -sf http://127.0.0.1:19600 >/dev/null; do sleep 3; done
curl -s 'http://127.0.0.1:19600/_cluster/health?filter_path=status'
until curl -sf http://127.0.0.1:19601/api/status >/dev/null; do sleep 5; done
echo "Kibana 기동 완료"

기대 출력입니다. 단일 노드이고 아직 인덱스가 없으므로 green이 나옵니다.

{"status":"green"}
Kibana 기동 완료

2. GeoIP 데이터베이스가 내려받아지는지 확인하기

geoip 프로세서는 MaxMind GeoLite2 데이터베이스(City, Country, ASN)를 씁니다. Elasticsearch 8.19에서 ingest.geoip.downloader.enabled의 기본값은 true이고, 노드가 데이터베이스를 스스로 내려받아 갱신합니다. 기본 내려받기 주소는 https://geoip.elastic.co/v1/database이며, 공식 문서는 노드가 storage.googleapis.com 도메인에 접속할 수 있어야 한다고 안내합니다. 파이프라인에서 geoip 프로세서를 쓰기 전에는 내려받지 않는 것이 기본 동작입니다(ingest.geoip.downloader.eager.download 기본값 false). 먼저 설정과 현재 상태를 확인합니다.

curl -s 'http://127.0.0.1:19600/_cluster/settings?include_defaults=true&filter_path=defaults.ingest.geoip.downloader&pretty'
curl -s 'http://127.0.0.1:19600/_ingest/geoip/stats?pretty&filter_path=stats'

기대 출력입니다. 아직 파이프라인이 없으므로 내려받은 데이터베이스가 0개입니다.

{
  "defaults" : {
    "ingest" : {
      "geoip" : {
        "downloader" : {
          "endpoint" : "https://geoip.elastic.co/v1/database",
          "poll" : { "interval" : "3d" },
          "eager" : { "download" : "false" },
          "enabled" : "true"
        }
      }
    }
  }
}
{
  "stats" : {
    "successful_downloads" : 0,
    "failed_downloads" : 0,
    "total_download_time" : 0,
    "databases_count" : 0,
    "skipped_updates" : 0,
    "expired_databases" : 0
  }
}

실제 출력은 poll 과 eager 항목이 여러 줄로 펼쳐져 나옵니다.

3. geoip 파이프라인 만들기

source.ip를 읽어 source.geo에 위치를, source.as에 ASN(자율 시스템 번호와 조직명)을 채우는 파이프라인입니다. ASN 프로세서를 따로 둔 이유는 4번의 결과 확인에서 설명합니다. ECS 문서의 AS 필드 이름은 source.as.number와 source.as.organization.name이라 이 글의 source.as.asn, source.as.organization_name과 다릅니다. 이 글은 geoip 프로세서가 기본으로 내는 이름을 그대로 썼으므로, ECS 이름이 필요하면 필드 이름을 바꾸는 단계를 따로 더해야 합니다. 8.19 공식 문서에는 같은 방식으로 동작하는 ip_location 프로세서도 나오지만(이 문서는 더 이상 갱신하지 않는다고 표시되어 있습니다), 이 글은 geoip 프로세서만 실행해 확인했습니다. ignore_missing은 source.ip가 없는 문서를, ignore_failure는 IP 형식이 잘못된 문서를 오류 없이 통과시킵니다.

cat > pipeline.json <<'EOF'
{
  "description": "source.ip 로 source.geo(위치)와 source.as(ASN) 채우기",
  "processors": [
    {
      "geoip": {
        "field": "source.ip",
        "target_field": "source.geo",
        "ignore_missing": true,
        "ignore_failure": true
      }
    },
    {
      "geoip": {
        "field": "source.ip",
        "target_field": "source.as",
        "database_file": "GeoLite2-ASN.mmdb",
        "properties": ["asn", "organization_name"],
        "ignore_missing": true,
        "ignore_failure": true
      }
    }
  ]
}
EOF

curl -s -X PUT http://127.0.0.1:19600/_ingest/pipeline/access-geoip \
  -H 'Content-Type: application/json' --data-binary @pipeline.json; echo

파이프라인을 만들면 Elasticsearch가 데이터베이스를 내려받기 시작합니다. 한 번에 다 오지 않으므로 몇 번에 나누어 databases_count를 확인합니다.

curl -s 'http://127.0.0.1:19600/_ingest/geoip/stats?pretty&filter_path=stats,nodes.*.databases'

기대 출력입니다. 처음에는 ASN 하나만 보일 수 있습니다. 이 환경에서는 파이프라인을 만들고 약 3분이 지나서 세 개가 모두 내려받아졌습니다.

{"acknowledged":true}
  "stats" : {
    "successful_downloads" : 1,
    "failed_downloads" : 0,
    "databases_count" : 1,
    ...
          "name" : "GeoLite2-ASN.mmdb"

몇 분 뒤 출력을 줄여 다시 확인합니다.

curl -s 'http://127.0.0.1:19600/_ingest/geoip/stats?pretty&filter_path=stats.databases_count,stats.failed_downloads,nodes.*.databases'

세 개가 모두 내려받아지면 다음과 같이 나옵니다.

  "stats" : {
    "failed_downloads" : 0,
    "databases_count" : 3
  },
  ...
          "name" : "GeoLite2-ASN.mmdb"
          "name" : "GeoLite2-Country.mmdb"
          "name" : "GeoLite2-City.mmdb"

파이프라인을 만든 직후에는 GeoLite2-ASN 하나만 내려받아졌고, 시간이 지나 databases_count가 3이 되면서 failed_downloads가 0으로 유지되는 것을 확인합니다.
파이프라인을 만든 직후에는 GeoLite2-ASN 하나만 내려받아졌고, 시간이 지나 databases_count가 3이 되면서 failed_downloads가 0으로 유지되는 것을 확인합니다.

databases_count가 오르지 않고 failed_downloads가 늘면 컨테이너가 인터넷에 접속하지 못하는 경우입니다. 2번에서 본 storage.googleapis.com에 접속되는지 확인합니다. 공식 문서에 따르면 내려받은 파일은 각 노드의 임시 디렉터리에 저장됩니다.

4. 파이프라인 결과 먼저 확인하기

문서를 색인하기 전에 _simulate로 세 종류의 IP를 넣어 봅니다. 구글 퍼블릭 DNS, 클라우드플레어 DNS, 비공개 대역 주소입니다.

curl -s -X POST 'http://127.0.0.1:19600/_ingest/pipeline/access-geoip/_simulate?filter_path=docs.doc._source&pretty' \
  -H 'Content-Type: application/json' \
  -d '{"docs":[{"_source":{"source":{"ip":"8.8.8.8"}}},{"_source":{"source":{"ip":"1.1.1.1"}}},{"_source":{"source":{"ip":"10.0.0.15"}}}]}'

기대 출력의 요지입니다. 세 문서의 결과가 서로 다릅니다.

8.8.8.8     source.geo: United States, location lat 37.751 / lon -97.822
            source.as : 15169, Google LLC
1.1.1.1     source.geo: (없음)
            source.as : 13335, Cloudflare, Inc.
10.0.0.15   source.geo: (없음), source.as: (없음)

8.8.8.8은 위치와 ASN이 모두 채워지고, 1.1.1.1은 ASN만, 10.0.0.15는 아무것도 채워지지 않는 것을 확인합니다.
8.8.8.8은 위치와 ASN이 모두 채워지고, 1.1.1.1은 ASN만, 10.0.0.15는 아무것도 채워지지 않는 것을 확인합니다.

이 결과에서 두 가지를 알 수 있습니다. 비공개 대역(10.x, 192.168.x)은 데이터베이스에 없으므로 source.geo가 생기지 않고, 오류도 나지 않습니다. 공인 IP라도 위치가 없을 수 있습니다. 1.1.1.1과 9.9.9.9는 City와 Country 데이터베이스 모두에서 결과가 없었고 ASN 데이터베이스에서만 사업자가 나왔습니다. 여러 지역에서 같은 주소를 쓰는 서비스라서 그렇다고 추측할 수 있으나, 이 글에서 확인한 것은 결과가 비어 있다는 사실까지입니다. 그래서 위치가 없는 IP도 사업자는 볼 수 있도록 ASN 프로세서를 함께 둔 것입니다. 또 8.8.8.8의 좌표(위도 37.751, 경도 -97.822)는 이 결과에서는 국가 중심 좌표로 보입니다. 이 글에서 확인한 것은 시 단위 정보가 없다는 점까지이며, 지도의 점은 국가 단위로 보는 편이 안전합니다. IP 위치는 어디까지나 근사값입니다. GeoLite2의 정확도에는 한계가 있고 MaxMind도 정확도를 보장하지 않으므로, 분석 단서로만 쓰고 특정인이나 장소를 가리는 근거로 쓰지 않습니다.

5. 인덱스 템플릿으로 필드 타입 고정하기

지도에 점을 찍으려면 source.geo.location이 geo_point 타입이어야 합니다. 템플릿 없이 색인하면 이 필드가 lat와 lon 두 개의 float로 동적 매핑되어 지도에서 위치 필드로 고를 수 없습니다. 이 환경에서 템플릿 없이 같은 파이프라인으로 색인해 보니 실제로 그렇게 매핑되었습니다. 기존 필드의 타입은 바꿀 수 없으므로 템플릿을 먼저 만듭니다. 템플릿에는 기본 파이프라인도 지정해 색인할 때마다 geoip가 적용되게 합니다.

cat > template.json <<'EOF'
{
  "index_patterns": ["access-*"],
  "template": {
    "settings": {
      "number_of_replicas": 0,
      "index.default_pipeline": "access-geoip"
    },
    "mappings": {
      "properties": {
        "@timestamp": { "type": "date" },
        "source": {
          "properties": {
            "ip": { "type": "ip" },
            "geo": { "properties": { "location": { "type": "geo_point" } } },
            "as": {
              "properties": {
                "asn": { "type": "long" },
                "organization_name": { "type": "keyword" }
              }
            }
          }
        },
        "http": {
          "properties": {
            "request": { "properties": { "method": { "type": "keyword" } } },
            "response": { "properties": { "status_code": { "type": "integer" } } }
          }
        },
        "url": { "properties": { "path": { "type": "keyword" } } }
      }
    }
  }
}
EOF

curl -s -X PUT http://127.0.0.1:19600/_index_template/access-template \
  -H 'Content-Type: application/json' --data-binary @template.json; echo

기대 출력입니다.

{"acknowledged":true}

6. 샘플 접근 로그 만들어 색인하기

아래 스크립트는 같은 결과가 나오도록 고정된 난수열로 120건의 로그를 만듭니다. IP는 8.8.8.8, 1.1.1.1, 9.9.9.9, 208.67.222.222와 비공개 대역 10.0.0.15, 192.168.1.20 여섯 개이고, 시각은 2026-10-16 09시부터 14시(UTC) 사이입니다. 파일은 _bulk 형식입니다.

cat > make-logs.sh <<'EOF'
awk 'function r(n) { x = (x * 1103515245 + 12345) % 2147483648; return int(x / 65536) % n + 1 }
BEGIN {
  x = 42
  split("8.8.8.8 8.8.8.8 8.8.8.8 1.1.1.1 1.1.1.1 9.9.9.9 208.67.222.222 10.0.0.15 10.0.0.15 192.168.1.20", ip, " ")
  split("200 200 200 200 200 200 301 403 404 404 500", st, " ")
  split("/ /login /api/items /admin /static/app.js /search", pa, " ")
  split("GET GET GET POST", me, " ")
  for (i = 0; i < 120; i++) {
    h = 8 + r(6); m = r(60) - 1; s = r(60) - 1
    printf "{\"index\":{}}\n"
    printf "{\"@timestamp\":\"2026-10-16T%02d:%02d:%02dZ\",\"source\":{\"ip\":\"%s\"},\"http\":{\"request\":{\"method\":\"%s\"},\"response\":{\"status_code\":%s}},\"url\":{\"path\":\"%s\"}}\n", h, m, s, ip[r(10)], me[r(4)], st[r(11)], pa[r(6)]
  }
}' > access-bulk.ndjson
EOF

bash make-logs.sh
wc -l access-bulk.ndjson
head -4 access-bulk.ndjson

기대 출력입니다. 120건이므로 240줄이 나옵니다. 난수는 awk 안에서 직접 계산하지만 int()와 나머지 연산의 처리가 구현마다 다를 수 있어, 이 값은 GNU Awk 5.4.0(Git Bash의 awk)에서 얻은 것입니다. 다른 awk에서 앞 두 건의 값이 아래와 다르면 IP별 건수도 아래 표와 조금 다를 수 있으므로, 그 경우 뒤의 건수와 화면은 숫자만 다르게 나온다고 보면 됩니다.

240 access-bulk.ndjson
{"index":{}}
{"@timestamp":"2026-10-16T10:53:40Z","source":{"ip":"9.9.9.9"},"http":{"request":{"method":"GET"},"response":{"status_code":404}},"url":{"path":"/search"}}
{"index":{}}
{"@timestamp":"2026-10-16T09:47:35Z","source":{"ip":"208.67.222.222"},"http":{"request":{"method":"GET"},"response":{"status_code":200}},"url":{"path":"/admin"}}

색인합니다. access-demo는 5번의 템플릿(access-*)에 걸리므로 기본 파이프라인이 자동으로 적용됩니다.

curl -s -X POST 'http://127.0.0.1:19600/access-demo/_bulk?refresh=true&filter_path=errors,items.index.result' \
  -H 'Content-Type: application/x-ndjson' --data-binary @access-bulk.ndjson | head -c 120; echo
curl -s 'http://127.0.0.1:19600/access-demo/_count?filter_path=count'; echo

기대 출력입니다.

{"errors":false,"items":[{"index":{"result":"created"}},{"index":{"result":"created"}},{"index":{"result":"created"}},{"
{"count":120}

IP별로 위치와 ASN이 몇 건씩 채워졌는지 SQL로 셉니다. as가 SQL 예약어라서 source.as 필드는 큰따옴표로 감쌉니다.

cat > q.json <<'EOF'
{"query": "SELECT source.ip AS ip, COUNT(*) AS requests, COUNT(source.geo.country_iso_code) AS with_geo, COUNT(\"source.as.organization_name\") AS with_asn FROM \"access-demo\" GROUP BY source.ip ORDER BY requests DESC"}
EOF
curl -s -X POST 'http://127.0.0.1:19600/_sql?format=txt' -H 'Content-Type: application/json' --data-binary @q.json

기대 출력입니다. 위치는 8.8.8.8과 208.67.222.222만 채워지고, 1.1.1.1과 9.9.9.9는 ASN만 채워지며, 비공개 대역 두 개는 둘 다 비어 있습니다.

      ip       |   requests    |   with_geo    |   with_asn    
---------------+---------------+---------------+---------------
8.8.8.8        |45             |45             |45             
1.1.1.1        |26             |0              |26             
10.0.0.15      |18             |0              |0              
9.9.9.9        |13             |0              |13             
208.67.222.222 |10             |10             |10             
192.168.1.20   |8              |0              |0

8.8.8.8과 208.67.222.222만 with_geo가 요청 수와 같고, 1.1.1.1과 9.9.9.9는 with_asn만, 비공개 대역 두 개는 둘 다 0인 것을 확인합니다.
8.8.8.8과 208.67.222.222만 with_geo가 요청 수와 같고, 1.1.1.1과 9.9.9.9는 with_asn만, 비공개 대역 두 개는 둘 다 0인 것을 확인합니다.

지도에는 with_geo가 채워진 55건(45건과 10건)만 나옵니다. 나머지 65건은 지도에 안 보이는 것이 정상이며 문서가 사라지는 것은 아닙니다. 상위 IP 막대와 상태 코드 도넛에는 120건이 모두 들어갑니다.

7. Kibana에서 대시보드 만들기

브라우저에서 http://127.0.0.1:19601로 접속합니다. 화면이 열리지 않으면 http://로 입력했는지 봅니다.

먼저 화면의 시각 표시를 UTC로 맞춥니다. 샘플 로그는 2026-10-16 09시부터 14시(UTC)인데, Kibana는 기본값인 브라우저 시간대로 시각을 보여 줍니다. 이 환경에서는 대시보드의 시간 범위 08:00이 한국 시간대 PC에서 17:00으로 표시되었습니다. Stack Management, Advanced Settings로 이동해 Space Settings 탭의 General 항목에서 검색창에 dateFormat:tz를 입력합니다. 항목 이름은 Time zone이고 기본값은 Browser입니다. 값을 UTC로 고르면 화면 아래에 1 unsaved setting 표시줄이 나타나며, Save changes를 누르면 저장됩니다.

대시보드는 Saved Objects 가져오기로 한 번에 만듭니다. 화면에서 하나씩 만드는 방법은 이 아래에서 따로 설명합니다. 아래 파일은 데이터 뷰 하나(id access-dv), Lens 시각화 세 개, 지도 하나, 대시보드 하나(id access-dashboard)를 담은 ndjson입니다. 한 줄이 객체 하나이므로 줄바꿈을 바꾸지 말고 그대로 저장합니다.

cat > access-dashboard-import.ndjson <<'EOF2'
{"type":"index-pattern","id":"access-dv","attributes":{"title":"access-*","name":"access-*","timeFieldName":"@timestamp"},"references":[],"coreMigrationVersion":"8.8.0","typeMigrationVersion":"8.0.0"}
{"type":"lens","id":"access-top-ip","attributes":{"title":"상위 IP","description":"","visualizationType":"lnsXY","state":{"visualization":{"legend":{"isVisible":false,"position":"right"},"valueLabels":"show","preferredSeriesType":"bar_horizontal","layers":[{"layerId":"l1","accessors":["c2"],"position":"top","seriesType":"bar_horizontal","showGridlines":false,"layerType":"data","xAccessor":"c1"}]},"query":{"query":"","language":"kuery"},"filters":[],"datasourceStates":{"formBased":{"layers":{"l1":{"columns":{"c1":{"label":"Top 5 values of source.ip","dataType":"ip","operationType":"terms","scale":"ordinal","sourceField":"source.ip","isBucketed":true,"params":{"size":5,"orderBy":{"type":"column","columnId":"c2"},"orderDirection":"desc","otherBucket":false,"missingBucket":false}},"c2":{"label":"Count of records","dataType":"number","operationType":"count","isBucketed":false,"scale":"ratio","sourceField":"___records___","params":{"emptyAsNull":true}}},"columnOrder":["c1","c2"],"incompleteColumns":{}}}}}}},"references":[{"type":"index-pattern","id":"access-dv","name":"indexpattern-datasource-layer-l1"}],"coreMigrationVersion":"8.8.0","typeMigrationVersion":"8.9.0"}
{"type":"lens","id":"access-status","attributes":{"title":"상태 코드","description":"","visualizationType":"lnsPie","state":{"visualization":{"shape":"donut","layers":[{"layerId":"l1","primaryGroups":["c1"],"metrics":["c2"],"numberDisplay":"percent","categoryDisplay":"default","legendDisplay":"default","nestedLegend":false,"layerType":"data"}]},"query":{"query":"","language":"kuery"},"filters":[],"datasourceStates":{"formBased":{"layers":{"l1":{"columns":{"c1":{"label":"Top 6 values of http.response.status_code","dataType":"number","operationType":"terms","scale":"ordinal","sourceField":"http.response.status_code","isBucketed":true,"params":{"size":6,"orderBy":{"type":"column","columnId":"c2"},"orderDirection":"desc","otherBucket":false,"missingBucket":false}},"c2":{"label":"Count of records","dataType":"number","operationType":"count","isBucketed":false,"scale":"ratio","sourceField":"___records___","params":{"emptyAsNull":true}}},"columnOrder":["c1","c2"],"incompleteColumns":{}}}}}}},"references":[{"type":"index-pattern","id":"access-dv","name":"indexpattern-datasource-layer-l1"}],"coreMigrationVersion":"8.8.0","typeMigrationVersion":"8.9.0"}
{"type":"lens","id":"access-over-time","attributes":{"title":"시간대별 요청 수","description":"","visualizationType":"lnsXY","state":{"visualization":{"legend":{"isVisible":false,"position":"right"},"valueLabels":"show","preferredSeriesType":"bar","layers":[{"layerId":"l1","accessors":["c2"],"position":"top","seriesType":"bar","showGridlines":false,"layerType":"data","xAccessor":"c1"}]},"query":{"query":"","language":"kuery"},"filters":[],"datasourceStates":{"formBased":{"layers":{"l1":{"columns":{"c1":{"label":"@timestamp","dataType":"date","operationType":"date_histogram","sourceField":"@timestamp","isBucketed":true,"scale":"interval","params":{"interval":"1h","includeEmptyRows":true,"dropPartials":false}},"c2":{"label":"Count of records","dataType":"number","operationType":"count","isBucketed":false,"scale":"ratio","sourceField":"___records___","params":{"emptyAsNull":true}}},"columnOrder":["c1","c2"],"incompleteColumns":{}}}}}}},"references":[{"type":"index-pattern","id":"access-dv","name":"indexpattern-datasource-layer-l1"}],"coreMigrationVersion":"8.8.0","typeMigrationVersion":"8.9.0"}
{"type":"map","id":"access-map","attributes":{"title":"요청 위치 지도","description":"","mapStateJSON":"{\"zoom\":1.4,\"center\":{\"lon\":-30,\"lat\":30},\"timeFilters\":{\"from\":\"2026-10-16T08:00:00.000Z\",\"to\":\"2026-10-16T15:00:00.000Z\"},\"refreshConfig\":{\"isPaused\":true,\"interval\":0},\"query\":{\"query\":\"\",\"language\":\"kuery\"},\"filters\":[],\"settings\":{}}","layerListJSON":"[{\"sourceDescriptor\":{\"type\":\"EMS_TMS\",\"isAutoSelect\":true},\"id\":\"base\",\"label\":null,\"minZoom\":0,\"maxZoom\":24,\"alpha\":1,\"visible\":true,\"style\":{\"type\":\"EMS_VECTOR_TILE\",\"color\":\"\"},\"type\":\"EMS_VECTOR_TILE\"},{\"sourceDescriptor\":{\"id\":\"src1\",\"type\":\"ES_SEARCH\",\"geoField\":\"source.geo.location\",\"indexPatternRefName\":\"layer_1_source_index_pattern\",\"scalingType\":\"LIMIT\",\"tooltipProperties\":[],\"applyGlobalQuery\":true,\"applyGlobalTime\":true,\"applyForceRefresh\":true,\"filterByMapBounds\":true,\"sortField\":\"\",\"sortOrder\":\"desc\",\"topHitsSize\":1},\"id\":\"lay1\",\"label\":\"요청 위치\",\"minZoom\":0,\"maxZoom\":24,\"alpha\":0.75,\"visible\":true,\"style\":{\"type\":\"VECTOR\",\"properties\":{\"fillColor\":{\"type\":\"STATIC\",\"options\":{\"color\":\"#E7664C\"}},\"lineColor\":{\"type\":\"STATIC\",\"options\":{\"color\":\"#FFFFFF\"}},\"lineWidth\":{\"type\":\"STATIC\",\"options\":{\"size\":1}},\"iconSize\":{\"type\":\"STATIC\",\"options\":{\"size\":6}}},\"isTimeAware\":true},\"type\":\"GEOJSON_VECTOR\"}]","uiStateJSON":"{\"isLayerTOCOpen\":false,\"openTOCDetails\":[]}"},"references":[{"type":"index-pattern","id":"access-dv","name":"layer_1_source_index_pattern"}],"coreMigrationVersion":"8.8.0","typeMigrationVersion":"8.4.0"}
{"type":"dashboard","id":"access-dashboard","attributes":{"title":"접속 현황","description":"","panelsJSON":"[{\"type\":\"lens\",\"embeddableConfig\":{},\"panelIndex\":\"p1\",\"gridData\":{\"x\":0,\"y\":0,\"w\":24,\"h\":15,\"i\":\"p1\"},\"panelRefName\":\"panel_p1\"},{\"type\":\"lens\",\"embeddableConfig\":{},\"panelIndex\":\"p2\",\"gridData\":{\"x\":24,\"y\":0,\"w\":24,\"h\":15,\"i\":\"p2\"},\"panelRefName\":\"panel_p2\"},{\"type\":\"lens\",\"embeddableConfig\":{},\"panelIndex\":\"p3\",\"gridData\":{\"x\":0,\"y\":15,\"w\":24,\"h\":15,\"i\":\"p3\"},\"panelRefName\":\"panel_p3\"},{\"type\":\"map\",\"embeddableConfig\":{},\"panelIndex\":\"p4\",\"gridData\":{\"x\":24,\"y\":15,\"w\":24,\"h\":15,\"i\":\"p4\"},\"panelRefName\":\"panel_p4\"}]","optionsJSON":"{\"useMargins\":true,\"syncColors\":false,\"syncCursor\":true,\"syncTooltips\":false,\"hidePanelTitles\":false}","timeRestore":true,"timeFrom":"2026-10-16T08:00:00.000Z","timeTo":"2026-10-16T15:00:00.000Z","refreshInterval":{"pause":true,"value":0},"kibanaSavedObjectMeta":{"searchSourceJSON":"{\"query\":{\"query\":\"\",\"language\":\"kuery\"},\"filter\":[]}"}},"references":[{"type":"lens","id":"access-top-ip","name":"panel_p1"},{"type":"lens","id":"access-status","name":"panel_p2"},{"type":"lens","id":"access-over-time","name":"panel_p3"},{"type":"map","id":"access-map","name":"panel_p4"}],"coreMigrationVersion":"8.8.0","typeMigrationVersion":"10.3.0"}
EOF2

curl -s -X POST 'http://127.0.0.1:19601/api/saved_objects/_import?overwrite=true' \
  -H 'kbn-xsrf: true' -F file=@access-dashboard-import.ndjson \
  | python -c "import json,sys;d=json.load(sys.stdin);print('가져온 객체 수:', d['successCount'], d['success'])"

curl -s 'http://127.0.0.1:19601/api/saved_objects/_find?type=dashboard&type=lens&type=map&fields=title' \
  | python -c "import json,sys;print('저장 객체 수:', json.load(sys.stdin)['total'])"

기대 출력입니다. 데이터 뷰는 _find의 대상 종류에 넣지 않았으므로 대시보드 하나, Lens 세 개, 지도 하나를 합쳐 5입니다.

가져온 객체 수: 6 True
저장 객체 수: 5

가져온 패널의 설정은 다음과 같습니다. 대시보드에는 시간 범위(2026-10-16 08:00부터 15:00 UTC)가 함께 저장되어 있어서, 열면 샘플 로그가 있는 구간이 바로 보입니다.

패널 차트 종류 가로축 또는 조각 세로축 또는 값
상위 IP Bar horizontal Top values of source.ip, 개수 5 Count of records
상태 코드 Donut Top values of http.response.status_code, 개수 6 Count of records
시간대별 요청 수 Bar vertical Date histogram @timestamp, 간격 1시간 Count of records
요청 위치 지도 Maps 문서 레이어 위치 필드 source.geo.location 문서

왼쪽 메뉴의 Dashboards에서 접속 현황을 엽니다. 주소로 바로 열려면 http://127.0.0.1:19601/app/dashboards#/view/access-dashboard를 사용합니다. 아래는 가져온 직후의 대시보드입니다.

상위 IP 막대에 5개 IP가 요청 수 순서로, 상태 코드 도넛에 200이 가장 큰 비율로, 시간대별 막대에 09시부터 14시까지 여섯 구간이, 지도에 미국 위치의 점 하나가 보이는 것을 확인합니다.
상위 IP 막대에 5개 IP가 요청 수 순서로, 상태 코드 도넛에 200이 가장 큰 비율로, 시간대별 막대에 09시부터 14시까지 여섯 구간이, 지도에 미국 위치의 점 하나가 보이는 것을 확인합니다.

지도의 점이 하나뿐인 이유는 8.8.8.8과 208.67.222.222가 같은 좌표로 조회되었기 때문입니다. 두 주소의 점이 한곳에 겹쳐 있습니다. 비공개 대역은 위치가 없어서 찍히지 않고, 1.1.1.1과 9.9.9.9도 위치가 없어서 찍히지 않습니다.

Maps 앱에서 연 지도입니다. 배경 지도는 Elastic Maps Service에서 인터넷으로 받아 오므로 오프라인 환경에서는 배경 없이 점만 보이거나 타일이 비어 보일 수 있습니다.

배경 지도 위에 미국 중부 좌표에 요청 위치 점 하나가 찍히고, 오른쪽 아래에 지도 데이터 제공처가 표시되는 것을 확인합니다.
배경 지도 위에 미국 중부 좌표에 요청 위치 점 하나가 찍히고, 오른쪽 아래에 지도 데이터 제공처가 표시되는 것을 확인합니다.

이 글의 지도 화면은 MaxMind가 만든 GeoLite2 데이터를 사용했습니다. This product includes GeoLite Data created by MaxMind, available from https://www.maxmind.com

화면에서 직접 만들 때

가져오기 대신 화면에서 만들 수도 있습니다. 이 글을 쓰면서 직접 눌러 확인한 부분은 데이터 뷰 만들기와 Lens 패널 하나(상위 IP 막대)까지입니다.

데이터 뷰는 Stack Management, Data Views에서 Create data view를 눌러 만듭니다.

화면 입력칸 입력값
Create data view Name access-*
Create data view Index pattern access-*
Create data view Timestamp field @timestamp

Name과 Index pattern에 access-*를 입력하면 오른쪽에 access-demo 인덱스가 하나 일치하고 Timestamp field에 @timestamp가 선택된 것을 확인합니다.
Name과 Index pattern에 access-*를 입력하면 오른쪽에 access-demo 인덱스가 하나 일치하고 Timestamp field에 @timestamp가 선택된 것을 확인합니다.

Save data view to Kibana를 누르면 저장됩니다. 화면에서 만든 데이터 뷰의 id는 무작위 값이라 가져오기 파일의 access-dv와 다릅니다.

Lens는 주소창에 http://127.0.0.1:19601/app/lens를 열어 시작했습니다. 이 화면은 Visualize Library 아래의 Create 화면입니다. 시간 범위는 Absolute로 Oct 16, 2026 08:00부터 15:00까지 지정하고, 왼쪽 필드 목록에서 source.ip 항목에 마우스를 올리면 오른쪽에 + 버튼이 나타납니다. 이 버튼을 누르면 Horizontal axis에 Top 5 values of source.ip, Vertical axis에 Count of records가 자동으로 놓입니다. 기본 차트는 세로 막대(Bar, Stacked)이고 Top values의 Other 항목이 켜져 있어서 여섯 번째 막대로 Other가 붙었습니다. 가져온 패널은 가로 막대이고 Other를 껐습니다.

source.ip 옆의 + 버튼으로 만든 막대 차트에서 8.8.8.8이 가장 높고 1.1.1.1, 10.0.0.15, 9.9.9.9, 208.67.222.222가 뒤따르며 마지막에 Other 막대가 붙는 것을 확인합니다.
source.ip 옆의 + 버튼으로 만든 막대 차트에서 8.8.8.8이 가장 높고 1.1.1.1, 10.0.0.15, 9.9.9.9, 208.67.222.222가 뒤따르며 마지막에 Other 막대가 붙는 것을 확인합니다.

상태 코드 도넛, 시간대별 막대, Maps 지도의 화면 조작 순서는 확인하지 못했습니다. 이 세 패널은 위 표의 설정을 기준으로 직접 만들어 보고, 위치 필드 목록이 비어 있으면 5번의 geo_point 매핑이 빠진 것입니다.

8. 저장 객체 내보내고 다시 가져오기

대시보드를 다른 Kibana로 옮기거나 백업하려면 저장 객체로 내보냅니다. 화면에서는 Stack Management, Saved Objects에서 대시보드를 선택하고 Export를 누릅니다. 같은 일을 API로 하면 다음과 같습니다. includeReferencesDeep을 켜야 대시보드가 쓰는 시각화, 지도, 데이터 뷰까지 파일에 담깁니다. 7번에서 가져온 대시보드는 id가 access-dashboard입니다. 화면에서 직접 만든 대시보드는 id가 무작위이므로, 먼저 curl -s 'http://127.0.0.1:19601/api/saved_objects/_find?type=dashboard&fields=title'로 id를 찾아 아래 요청의 id를 바꿉니다.

cat > show.py <<'EOF2'
import json
for line in open("access-dashboard.ndjson", encoding="utf-8"):
    o = json.loads(line)
    print(o.get("type", "exportDetails"), o.get("id", o.get("exportedCount", "")))
EOF2

curl -s -X POST http://127.0.0.1:19601/api/saved_objects/_export \
  -H 'kbn-xsrf: true' -H 'Content-Type: application/json' \
  -d '{"objects":[{"type":"dashboard","id":"access-dashboard"}],"includeReferencesDeep":true}' \
  -o access-dashboard.ndjson
python show.py

기대 출력입니다. 데이터 뷰, Lens 시각화 세 개, 지도, 대시보드가 한 파일에 들어 있고 마지막 줄은 내보내기 요약입니다.

index-pattern access-dv
lens access-top-ip
lens access-status
lens access-over-time
map access-map
dashboard access-dashboard
exportDetails 6

Saved Objects 화면에 접속 현황 대시보드, 시각화 세 개, 지도, 데이터 뷰가 나열되고 위쪽에 Import와 Export 버튼이 있으며 Advanced Settings 같은 설정 객체도 함께 보이는 것을 확인합니다. 버튼의 Export 8 objects에는 이 설정 객체들이 포함되어 있습니다.
Saved Objects 화면에 접속 현황 대시보드, 시각화 세 개, 지도, 데이터 뷰가 나열되고 위쪽에 Import와 Export 버튼이 있으며 Advanced Settings 같은 설정 객체도 함께 보이는 것을 확인합니다. 버튼의 Export 8 objects에는 이 설정 객체들이 포함되어 있습니다.

가져오기도 같은 방식입니다. 화면에서는 Saved Objects의 Import에 파일을 올립니다. 아래는 객체를 모두 지운 뒤 방금 내보낸 파일을 가져와 되살아나는지 확인하는 순서입니다. 지우는 명령은 7번에서 만든 여섯 객체의 type과 id를 지정합니다. 객체 수를 세는 첫 줄이 5, 지운 뒤 두 번째 줄이 0, 마지막 줄이 5이면 성공입니다.

curl -s 'http://127.0.0.1:19601/api/saved_objects/_find?type=dashboard&type=lens&type=map&fields=title' \
  | python -c "import json,sys;print('저장 객체 수:', json.load(sys.stdin)['total'])"

cat > delete-objects.json <<'EOF2'
[{"type":"dashboard","id":"access-dashboard"},{"type":"map","id":"access-map"},{"type":"lens","id":"access-top-ip"},{"type":"lens","id":"access-status"},{"type":"lens","id":"access-over-time"},{"type":"index-pattern","id":"access-dv"}]
EOF2
curl -s -X POST 'http://127.0.0.1:19601/api/saved_objects/_bulk_delete?force=true' \
  -H 'kbn-xsrf: true' -H 'Content-Type: application/json' --data-binary @delete-objects.json >/dev/null

curl -s 'http://127.0.0.1:19601/api/saved_objects/_find?type=dashboard&type=lens&type=map&fields=title' \
  | python -c "import json,sys;print('저장 객체 수:', json.load(sys.stdin)['total'])"

curl -s -X POST 'http://127.0.0.1:19601/api/saved_objects/_import?overwrite=true' \
  -H 'kbn-xsrf: true' -F file=@access-dashboard.ndjson \
  | python -c "import json,sys;d=json.load(sys.stdin);print('가져온 객체 수:', d['successCount'], d['success'])"

curl -s 'http://127.0.0.1:19601/api/saved_objects/_find?type=dashboard&type=lens&type=map&fields=title' \
  | python -c "import json,sys;print('저장 객체 수:', json.load(sys.stdin)['total'])"

기대 출력입니다. 데이터 뷰는 이 목록에 세지 않으므로 대시보드, Lens 세 개, 지도 합계 5입니다.

저장 객체 수: 5
저장 객체 수: 0
가져온 객체 수: 6 True
저장 객체 수: 5

정리하기

실습이 끝나면 컨테이너, 볼륨, 네트워크를 지웁니다. 볼륨에는 색인한 데이터와 Kibana 설정이 들어 있으므로 지우면 되돌릴 수 없습니다.

docker rm -f cr-kibana-kb cr-kibana-es
docker volume rm cr-kibana-esdata
docker network rm cr-kibana-net

자주 막히는 곳

GET _ingest/geoip/stats의 databases_count가 0에서 오르지 않습니다. 파이프라인이나 템플릿에서 geoip 프로세서를 쓰기 전에는 데이터베이스를 내려받지 않는 것이 기본입니다. 2번에서 본 것처럼 처음에는 0이 정상이고, 3번에서 파이프라인을 만든 뒤에 내려받기가 시작됩니다. 이 환경에서는 ASN, Country, City가 순서대로 도착해 세 개가 모두 모이는 데 약 3분이 걸렸습니다. 몇 분이 지나도 failed_downloads만 오르면 컨테이너의 인터넷 접속과 내려받기 주소 접근 가능 여부를 확인합니다.

ingest.geoip.downloader.enabled를 false로 두면 위치가 비고 문서에 tags가 붙습니다. 공식 문서는 이 값이 false이면 내려받기를 멈추고 이미 받은 데이터베이스도 지운다고 설명합니다. 이 상태에서 geoip 프로세서만 둔 파이프라인에 8.8.8.8을 넣으면 위치 대신 "tags": ["_geoip_database_unavailable_GeoLite2-City.mmdb"]가 붙었습니다. 위치가 안 나오면 먼저 문서의 tags와 _ingest/geoip/stats를 봅니다. 이 글의 파이프라인처럼 ignore_failure를 켜 둔 경우에는 그 태그가 문서에 보이지 않아 원인을 찾기 어려우므로 stats로 확인해야 합니다. 설정은 PUT _cluster/settings로 되돌립니다.

템플릿 없이 색인했더니 지도에서 위치 필드를 고를 수 없습니다. source.geo.location이 geo_point가 아니라 lat, lon 두 개의 float로 만들어졌습니다. 이 환경에서 템플릿에 걸리지 않는 이름의 인덱스에 같은 파이프라인으로 색인해 확인한 결과입니다. 기존 인덱스의 매핑은 바꿀 수 없으므로 5번의 템플릿을 만든 뒤 인덱스를 지우고 다시 색인하거나 _reindex로 옮깁니다.

IP 형식이 아닌 값이나 source.ip 필드가 없는 문서가 색인되지 않습니다. ignore_failure와 ignore_missing을 빼고 geoip 프로세서만 두면 'not-an-ip' is not an IP string literal., field [source] not present as part of path [source.ip] 오류로 해당 문서가 거부됩니다. 이 글의 파이프라인은 두 옵션을 켜서 통과시킵니다. 대신 잘못된 값은 위치 없이 조용히 들어가므로 필드가 비어 있는 비율을 가끔 확인합니다.

대시보드를 열었는데 패널이 비어 있습니다. 데이터가 2026-10-16 09시부터 14시(UTC)에 있으므로 시간 선택기가 최근 15분 같은 상대 범위이면 아무것도 보이지 않습니다. 시간 범위를 절대값으로 바꿉니다. 브라우저 시간대가 UTC가 아니면 화면 시각이 어긋나므로 7번의 dateFormat:tz 설정도 확인합니다.

이어서 해 볼 것

  • 오프라인에서 쓰기: 내려받기 서버에 접속할 수 없는 환경에서는 ingest.geoip.downloader.enabled를 false로 두고 .mmdb 파일을 각 노드의 config/ingest-geoip 폴더에 직접 넣은 뒤 프로세서의 database_file에 파일 이름을 적는 방식이 공식 문서에 있습니다. GeoLite2 데이터베이스 파일을 직접 내려받아 배포할 때의 조건은 MaxMind의 약관에서 따로 확인합니다.
  • 실제 로그 연결: Filebeat나 Logstash에서 웹 서버 접근 로그를 읽어 source.ip로 넣고, 이 글의 파이프라인을 pipeline 설정으로 지정합니다.
  • 지도 레이어 바꾸기: Maps의 Clusters and grids 레이어로 같은 필드를 집계하면 점 개수 대신 요청 수에 따라 크기가 달라지는 표현으로 바꿀 수 있습니다.

확인 범위

직접 실행한 부분은 1번부터 6번까지의 모든 명령, 7번의 가져오기 파일 입력과 가져오기, 8번의 내보내기, 삭제, 다시 가져오기, 정리 명령입니다. 7번과 8번의 명령은 객체를 모두 지운 상태에서 글에 적힌 순서 그대로 실행해 기대 출력과 같은 결과를 얻었습니다. 환경은 Windows의 Docker Desktop 29.5.3, Elasticsearch 8.19.4, Kibana 8.19.4(둘 다 보안 끔, 단일 노드)이며, 컨테이너가 인터넷에 접속되어 GeoLite2 City, Country, ASN 데이터베이스가 자동으로 내려받아지는 것을 확인했습니다. 내려받기를 끈 경우의 태그, 템플릿 없이 색인했을 때의 매핑, 잘못된 IP의 오류도 같은 환경에서 재현했습니다. 샘플 로그의 건수는 GNU Awk 5.4.0 기준이며 다른 awk에서는 실행하지 않았습니다.

화면 조작은 일부만 직접 확인했습니다. 직접 눌러 확인한 것은 Advanced Settings의 dateFormat:tz 변경과 저장, Data Views에서 데이터 뷰 만들기, Lens에서 source.ip 옆 + 버튼으로 막대 차트 만들기까지입니다. 상태 코드 도넛, 시간대별 막대, Maps 지도 패널과 대시보드 구성은 화면에서 만들지 않고, 같은 구성의 저장 객체를 가져오기 파일로 만들어 불러온 뒤 그 화면을 열어 확인했습니다. 따라서 이 세 패널의 메뉴 경로와 클릭 순서는 확인하지 못했습니다. Lens 화면에서 차트 종류를 가로 막대로 바꾸는 방법과 Other 항목을 끄는 방법, Saved Objects 화면의 Export와 Import 버튼도 누르지 않고 같은 기능의 API를 실행했습니다. 가져오기 파일은 실행한 Kibana 8.19.4의 저장 객체 형식이며, 다른 버전에서는 가져오기가 거부되거나 변환될 수 있습니다.

GeoLite2 사용 조건은 Elasticsearch 8.19 GeoIP processor 문서(elastic.co/guide/en/elasticsearch/reference/8.19/geoip-processor.html)와 MaxMind GeoLite 최종 사용자 약관(maxmind.com/en/geolite2/eula)을 읽고 확인했습니다. Elastic 문서는 CC BY-SA 4.0 라이선스를 밝히고, MaxMind 약관은 출처 표시 의무와 정확도 면책을 적습니다. 약관 페이지 자체에는 CC BY-SA 4.0이라는 이름이 직접 적혀 있지 않았고 해당 부분은 Creative Commons 라이선스를 가리키는 링크로 되어 있었으므로, 라이선스 이름은 Elastic 문서를 따랐습니다. 약관의 그 밖의 세부 조항은 이 글에서 다루지 않았습니다.

오프라인 데이터베이스 파일 사용, 내려받기 서버에 30일 이상 접속하지 못했을 때 붙는 _geoip_expired_database 태그, ip_location 프로세서 실행, 인증을 켠 구성, 여러 노드 구성은 실행하지 못했고 공식 문서의 설명만 확인했습니다. 지도 배경 타일은 인터넷이 되는 PC에서 받아 와 정상으로 보였으며 오프라인 상태는 확인하지 않았습니다.

이 글의 다음 단계

SIEM 관제 실습 (ELK) 과정의 일부입니다

이 실습은 교육에서 참가자마다 독립된 환경으로 진행합니다. 원하는 일정을 고르면 문의 양식에 미리 채워집니다.

과정 보기

교육 진행 순서와 자주 묻는 질문 보기

같은 카테고리의 글