🔖

[スタートアップ] Amazon CloudWatchやKibanaを使わずに無課金でログ分析

に公開

Amazon CloudWatchやKibanaを使わずに無課金でログ分析 !

自己紹介

株式会社ココナラの大塚 泰成 卍

前提 (制約)

Amazon CloudWatchやKibanaを積極的に使わないケースにおいて、
ログの解析を容易かつシンプルに構築したいと思います。

やらないこと

※ 各ライブラリについて補足説明は省略します。(nginxとはこういうOSSですみたいな)

無課金で達成したいことの詳細 (ゴール)

上記の前提を踏まえた上で

  • パスごとにアクセス数を集計できる
  • エラー率を算出出来る
  • どのパスに何時頃にアクセスが集中しているかを特定できる
  • エラー時の調査が行え、原因特定の証跡となるものを残せること

を達成したいと思います。

方法 (設計)

  • 分析用のツールとしてduckdbを利用
  • ロードバランサとしてnginxを利用
  • nginxからログをjsonで吐き出しduckdbでjsonを無加工でロードし、SQLクエリを投げて集計
  • アプリケーションサーバはgolangのnet/httpを利用
  • ローカルや本番サーバ側はdockerで動いてるものとします
  • ログ要件 (吐き出す内容)
    • request 時間
    • request 内容
    • remote ip
    • request path
    • response status code
    • リファラ
    • ユーザエージェント
    • エラー内容

方法 (実装)

成果物のファイル構成

.
├── app
│   ├── go.mod
│   └── main.go
├── docker-compose.yaml
├── justfile ... なくてもいい
├── logs
│   ├── access.log
│   ├── error.log
│   └── static_assets.log
└── nginx.conf

nginxの設定

nginx用のファイルを作成するためnginx.confを作成します。

/favicon.icoみたいなのが都度リクエストで来ていると分析時のノイズになるので、static_assets.logという別ファイルにログを切り出してます。

nginx.conf
events {
}

http {
    log_format json escape=json '{'
    '"time_local": "$time_local", '
    '"remote_addr": "$remote_addr", '
    '"remote_user": "$remote_user", '
    '"request": "$request", '
    '"path": "$request_uri", '
    '"status": $status, '
    '"request_body": "$request_body", '
    '"body_bytes_sent": $body_bytes_sent, '
    '"http_referer": "$http_referer", '
    '"request_id": "$request_id", '
    '"http_user_agent": "$http_user_agent"'
    '}';

    access_log /var/log/nginx/access.log json;

    client_max_body_size 10M;
    client_body_buffer_size 128k;

    server {
        listen 80;
        location / {
            proxy_pass_request_body on;
            proxy_set_header X-Request-Id $request_id;
            proxy_set_header Content-Length $content_length;
            proxy_pass http://app.local:3000;
        }

        location ~* \.(ico|css|js)$ {
            access_log /var/log/nginx/static_assets.log;
            expires 30d;
            log_not_found off;
        }
    }
}

APサーバ用のファイル

※ nodeでもPHPでも構いません

POST /itemsのbodyがjsonと認識できなかった時にBadRequestを返すようにしています。

main.go
package main

import (
	"context"
	"encoding/json"
	"fmt"
	"log"
	"log/slog"
	"net/http"
	"os"
	"sync"
	"time"
)

type Item struct {
	ID   string `json:"id"`
	Name string `json:"name"`
}

var (
	items = []Item{{ID: "1", Name: "Golang Basics"}}
	mu    sync.Mutex
)

type contextKey string

const requestIDKey contextKey = "requestId"

type RequestIDHandler struct {
	slog.Handler
}

func (h *RequestIDHandler) Handle(ctx context.Context, r slog.Record) error {
	if rid, ok := ctx.Value(requestIDKey).(string); ok {
		r.AddAttrs(slog.String("request_id", rid))
	}

	return h.Handler.Handle(ctx, r)
}

func main() {
	file, err := os.OpenFile("application.log", os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644)
	if err != nil {
		log.Fatalf("Failed to open log file: %v", err)
	}
	defer file.Close()

	jsonHandler := slog.NewJSONHandler(file, &slog.HandlerOptions{
		Level: slog.LevelInfo,
	})

	logger := slog.New(&RequestIDHandler{jsonHandler})
	slog.SetDefault(logger)

	mux := http.NewServeMux()

	mux.HandleFunc("GET /", home)
	mux.HandleFunc("GET /items", getItems)
	mux.HandleFunc("POST /items", createItem)

	wrappedMux := requestIDMiddleware(mux)

	fmt.Println("Starting server on port 3000")
	if err := http.ListenAndServe(":3000", wrappedMux); err != nil {
		fmt.Printf("Error starting server: %s\n", err)
	}
}

func home(w http.ResponseWriter, r *http.Request) {
	w.Write([]byte("Hello, world!"))
}

func getItems(w http.ResponseWriter, r *http.Request) {
	w.Header().Set("Content-Type", "application/json")
	json.NewEncoder(w).Encode(items)
}

func createItem(w http.ResponseWriter, r *http.Request) {
	var newItem Item
	if err := json.NewDecoder(r.Body).Decode(&newItem); err != nil {
		slog.ErrorContext(r.Context(), "Error decoding request body", "error", err.Error())
		http.Error(w, err.Error(), http.StatusBadRequest)
		return
	}

	mu.Lock()
	items = append(items, newItem)
	mu.Unlock()

	w.WriteHeader(http.StatusCreated)
	json.NewEncoder(w).Encode(newItem)
}

type responseWriterProxy struct {
	http.ResponseWriter
	statusCode int
}

func (rw *responseWriterProxy) WriteHeader(code int) {
	rw.statusCode = code
	rw.ResponseWriter.WriteHeader(code)
}

func requestIDMiddleware(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		start := time.Now()

		rid := r.Header.Get("X-Request-Id")
		if rid == "" {
			rid = "unknown-request-id"
		}

		ctx := context.WithValue(r.Context(), requestIDKey, rid)

		proxy := &responseWriterProxy{ResponseWriter: w, statusCode: http.StatusOK}
		next.ServeHTTP(proxy, r.WithContext(ctx))

		duration := time.Since(start)

		if proxy.statusCode >= 400 {
			slog.ErrorContext(ctx, "Request failed",
				slog.String("method", r.Method),
				slog.String("path", r.URL.Path),
				slog.Int("status", proxy.statusCode),
				slog.Duration("latency", duration),
			)
		}
	})
}

Dockerの設定

※ local環境での利用を想定している為alpineで稼働させています。

docker-compose.yaml
services:
  nginx:
    image: nginx:alpine3.23
    ports:
      - 8080:80
    volumes:
      - ./nginx.conf:/etc/nginx/nginx.conf
      - ./logs:/var/log/nginx
    environment:
      TZ: Asia/Tokyo
  app:
    image: golang:alpine3.23
    working_dir: /app
    hostname: app.local
    volumes:
      - ./app:/app
    environment:
      TZ: Asia/Tokyo
    command: go run main.go

実際に動かす

この時点でdocker compose upを打つとサーバがlocalhost:8080で動くはずです。
「Hello, world!」と表示されればOKです。

localhostアクセス後にログが吐かれているか確認

cat logs/access.log
# {"time_local": "18/Dec/2025:17:53:46 +0900", "remote_addr": "****", "remote_user": "", "request": "GET / HTTP/1.1", "path": "/", "status": 200, "request_body": "", "body_bytes_sent": 13, "http_referer": "", "http_user_agent": "Mozilla/5.0 ****"}

duckdbでアクセスログを解析する

## 正常系のアクセスログを抽出
docker run --rm -it -v "$(pwd):/app" \
  duckdb/duckdb duckdb -list \
  -c "select time_local, path from read_json('/app/logs/access.log') where status < 400"

# time_local|path
# 18/Dec/2025:16:34:15 +0900|/
# 18/Dec/2025:16:34:15 +0900|/
# 18/Dec/2025:16:34:15 +0900|/
# 18/Dec/2025:17:07:24 +0900|/
# 18/Dec/2025:17:07:41 +0900|/items
# 18/Dec/2025:17:53:23 +0900|/
# 18/Dec/2025:17:53:46 +0900|/

duckdbに関するオプション

dockerを利用しなくてもlocal PCにduckdbを入れることは可能です。

またlocal PCに入れたくないかつ長い文字を打ちたくない人はjustfileを活用することで短縮できます。

justのパッケージマネージャー一覧

justfile
duck +args:
    docker run --rm -it -v "$(pwd):/app" duckdb/duckdb duckdb -list -c "{{args}}"

利用方法

just duck "select time_local,path from read_json('/app/logs/access.log')"

ユースケースごとのクエリ

パスごとにアクセス数を集計できる

select path, count(path) as count from read_json('/app/logs/access.log') group by path;

-- path|count
-- /|18
-- /items|8

エラー率を算出出来る

select count(*) as success from read_json('/app/logs/access.log') where status < 400;
-- success
-- 25

select count(*) as error from read_json('/app/logs/access.log') where status >= 400;
-- error
-- 5

5 / 30 = 16.7%

どのパスに何時頃にアクセスが集中しているかを特定できる

SELECT
    extract(hour FROM strptime(time_local, '%d/%b/%Y:%H:%M:%S %z')) AS hour_of_day,
    count(*) AS count, path
FROM read_json('logs/access.log')
GROUP BY hour_of_day, path
ORDER BY count desc;

-- hour_of_day|count|path
-- 18|12|/
-- 18|7|/items
-- 17|3|/
-- 16|3|/
-- 17|1|/items

エラー時の調査が行え、原因特定の証跡となるものを残せること

以下のコマンドでエラーを起こせます (json形式が不正)

curl -X POST -H "Content-Type: application/json" -d '{"items":123"}' 'http://localhost:8080/items'
## invalid character '"' after object key:value pair

エラーがnginxにも出ているか確認します。

select time_local, status, request_id from read_json('logs/access.log') where status >= 400 order by time_local desc

-- time_local|status|request_id
-- 18/Dec/2025:19:02:18 +0900|400|e442ba13a8af4b8da7d2d9e6f959a7e5
-- 18/Dec/2025:19:01:04 +0900|400|ac1ad6897d0c7b0da5a3414bbd8bec2a
-- 18/Dec/2025:18:37:21 +0900|502|8b00ad461121236bcfd0c488b19a2e65

まずはnginxのエラーログを確認します

cat logs/error.log

# nginxのerror_logはlog_formatが使えません
# 公式の商用版は出来るらしいです。

# 2025/12/18 18:37:21 [error] 22#22: *1 connect() failed (111: Connection refused) while connecting to upstream, client: 192.168.160.1, server: , request: "GET / HTTP/1.1", upstream: "http://192.168.160.3:3000/", host: "localhost:8080"

時間が、502と同一なのでリバースプロキシに接続出来なかったことがわかります。

次はgolang側のapplication.logを確認してみます。

-- nginxのselectで表示されていたrequest_idを利用
select path, method, error, request_id
from read_json('/app/app/application.log')
where request_id in ('e442ba13a8af4b8da7d2d9e6f959a7e5', 'ac1ad6897d0c7b0da5a3414bbd8bec2a')

-- path|method|error|request_id
-- NULL|NULL|invalid character '"' after object key:value pair|ac1ad6897d0c7b0da5a3414bbd8bec2a
-- /items|POST|NULL|ac1ad6897d0c7b0da5a3414bbd8bec2a

-- NULL|NULL|invalid character '"' after object key:value pair|e442ba13a8af4b8da7d2d9e6f959a7e5
-- /items|POST|NULL|e442ba13a8af4b8da7d2d9e6f959a7e5

POST /itemsでinvalid character '"' after object key:value pairが起きたことがわかりました。

もう一度nginxに戻ってrequest_bodyがなんであったか確認しましょう。

select request_body from read_json('/app/logs/access.log')
where request_id in ('e442ba13a8af4b8da7d2d9e6f959a7e5', 'ac1ad6897d0c7b0da5a3414bbd8bec2a');

-- request_body
-- {"base_quer":1222"}
-- {"items":123"}

statusコードが40x系に加え、requestのjson形式もおかしいためクライアント側起因でエラーが起きていると断定できる段階になりました。

おわり

Amazon CloudWatchやKibanaなどは「どうやって見るか」にフォーカスしているツールです。
「どうやって見るか」さえどうにか我流でも確立出来れば「何を見るか」は変わらない為、インフラに課金出来ないような状況でもサービスを運用出来ることがわかりました。

浮いたお金でみんなでスタバでも飲みに行きましょう。


明日はかんじゃさんによる記事です。

ココナラでは積極的にエンジニアを採用しています。

採用情報はこちら。
https://coconala.co.jp/recruit/engineer/

カジュアル面談希望の方はこちら。
https://open.talentio.com/r/1/c/coconala/pages/70417

Discussion