jqコマンドによる複雑なJSONデータ操作のDevOps実践ガイド

Tech

本記事はGeminiの出力をプロンプト工学で整理した業務ドラフト(未検証)です。

jqコマンドによる複雑なJSONデータ操作のDevOps実践ガイド

要件と前提

本ガイドでは、DevOpsエンジニアが日常的に遭遇する複雑なJSONデータ操作を、jq コマンドを用いて効率的かつ安全に自動化する手法を解説します。外部APIからのデータ取得、変換、そしてそのプロセスを systemd で自動実行するまでの手順を網羅します。

目的とスコープ

  • 外部APIからJSONデータを取得し、jq で必要な情報に変換・整形する。

  • 処理スクリプトの冪等性安全性を確保する。

  • systemd を利用して、定期的なデータ処理を自動化する。

  • curl コマンドで安全なAPIアクセス(TLS、再試行、バックオフ)を実現する。

前提ツール

以下のツールがシステムにインストールされていることを前提とします。

  • bash (バージョン 4.x以上推奨)

  • curl (バージョン 7.x以上推奨)

  • jq (バージョン 1.6以上推奨、最新安定版は 1.7.1。2024年2月29日にリリース) [1]

  • systemd (バージョン 230以上推奨)

安全なスクリプトの原則

堅牢なスクリプトを作成するために、以下の原則を順守します。

  • set -euo pipefail:

    • -e: エラー発生時にスクリプトを即座に終了させる。

    • -u: 未定義変数を使用した場合にエラーとする。

    • -o pipefail: パイプライン中のコマンドが一つでも失敗したらパイプライン全体を失敗とする。

  • trap: スクリプト終了時に一時ファイルを確実にクリーンアップする。

  • mktemp -d: 一時ディレクトリを安全に作成し、衝突を避ける。

実装

全体処理フロー

以下に、本ガイドで実装するデータ処理の全体フローを示します。

graph TD
    A["外部API"] -->|HTTPリクエスト| B(cURL)
    B -->|JSONデータ取得| C("jqコマンド")
    C -->|整形済みJSON| D["出力ファイル/DB"]
    D -->|サービス処理| E("Systemd Service")
    E -->|実行スケジュール| F("Systemd Timer")

複雑なJSONデータ操作スクリプト

ここでは、仮の外部APIから複雑なJSONデータを取得し、特定のフィールドを抽出し、新しい構造に変換するスクリプトを作成します。

例として扱うJSONデータ構造:

{
  "meta": {
    "requestId": "abc-123",
    "timestamp": "2024-05-10T10:00:00Z",
    "status": "success"
  },
  "data": [
    {
      "id": "item001",
      "name": "Product A",
      "details": {
        "price": 100,
        "currency": "USD",
        "stock": {
          "warehouse1": 50,
          "warehouse2": 30
        }
      },
      "tags": ["electronics", "gadget"]
    },
    {
      "id": "item002",
      "name": "Product B",
      "details": {
        "price": 250,
        "currency": "USD",
        "stock": {
          "warehouse1": 10
        }
      },
      "tags": ["home", "appliance"]
    }
  ]
}

このデータを、各商品のID、名前、価格、総在庫数、および最初のタグのみを抽出し、以下の形式に変換します。

[
  {
    "productId": "item001",
    "productName": "Product A",
    "priceUSD": 100,
    "totalStock": 80,
    "category": "electronics"
  },
  {
    "productId": "item002",
    "productName": "Product B",
    "priceUSD": 250,
    "totalStock": 10,
    "category": "home"
  }
]

スクリプト process_product_data.sh

#!/usr/bin/env bash


# 目的: 外部APIからJSONデータを取得し、jqで整形してファイルに保存する。


# 入力: なし (APIエンドポイントはスクリプト内にハードコードまたは環境変数)


# 出力: 整形されたJSONファイル (.json)


# 前提: curl, jqコマンドが利用可能であること。


# 計算量: n個のデータアイテムに対して、jqのmap操作はO(n)のオーダー。curlのネットワーク時間はデータサイズに比例。


# メモリ条件: 取得するJSONデータサイズとjqの処理量に依存。数GB程度のJSONであればメモリに乗り切る場合が多い。

set -euo pipefail

# 一時ディレクトリの作成

readonly TMP_DIR=$(mktemp -d -t json_proc_XXXXXXXX)

# クリーンアップ処理

function cleanup {
    echo "Cleaning up temporary directory: ${TMP_DIR}"
    rm -rf "${TMP_DIR}"
}
trap cleanup EXIT # スクリプト終了時にcleanup関数を実行

# 設定

API_URL="https://example.com/api/products" # 仮のAPIエンドポイント
OUTPUT_FILE="/var/lib/data_processor/processed_products_$(date +%Y%m%d%H%M%S).json"

# 出力ディレクトリの作成 (冪等性確保のため、存在しない場合のみ作成)

OUTPUT_DIR=$(dirname "${OUTPUT_FILE}")
mkdir -p "${OUTPUT_DIR}"

echo "$(date +'%Y-%m-%d %H:%M:%S JST') - Starting JSON data processing..."

# 1. 外部APIからのデータ取得 (curlによる安全なアクセス)


# TLS1.2の強制、接続タイムアウト、全体タイムアウト、エラー時の再試行と指数バックオフ


# --retry: 最大再試行回数


# --retry-delay: 最初のリトライまでの秒数(その後倍々に増加)


# --retry-max-time: リトライ全体の最大時間


# --fail-with-body: HTTPエラー時にエラーボディを出力して終了


# -s: サイレントモード

echo "Fetching data from ${API_URL}..."
if ! curl_output=$(curl \
    --fail-with-body \
    --silent \
    --show-error \
    --location \
    --header "Accept: application/json" \
    --tlsv1.2 \
    --connect-timeout 10 \
    --max-time 30 \
    --retry 5 \
    --retry-delay 5 \
    --retry-max-time 60 \
    "${API_URL}"); then
    echo "$(date +'%Y-%m-%d %H:%M:%S JST') - Error: Failed to fetch data from API." >&2
    exit 1
fi

# 取得した生データを一時ファイルに保存

RAW_JSON_FILE="${TMP_DIR}/raw_data.json"
echo "${curl_output}" > "${RAW_JSON_FILE}"
echo "Raw JSON data saved to ${RAW_JSON_FILE}"

# 2. jqによる複雑なデータ変換


# map: 配列の各要素に関数を適用


# add: 数値の合計を計算

echo "Transforming JSON data using jq..."
if ! jq_output=$(jq -c '
    .data | map({
        productId: .id,
        productName: .name,
        priceUSD: .details.price,
        totalStock: (.details.stock | to_entries | map(.value) | add),
        category: (.tags[0] // "unknown") # tagsが空の場合に備えてデフォルト値を設定
    })
' "${RAW_JSON_FILE}"); then
    echo "$(date +'%Y-%m-%d %H:%M:%S JST') - Error: jq transformation failed." >&2
    exit 1
fi

# 変換されたJSONデータを出力ファイルに保存

echo "${jq_output}" > "${OUTPUT_FILE}"
echo "Processed JSON data saved to ${OUTPUT_FILE}"
echo "$(date +'%Y-%m-%d %H:%M:%S JST') - JSON data processing completed successfully."
exit 0

jq フィルターの詳細:

  • .data: ルートオブジェクトの data キーの配列を選択。

  • map(...): data 配列の各要素に対して、中括弧 {} で定義された新しいオブジェクトを生成。

  • productId: .id: 各要素の idproductId としてマッピング。

  • productName: .name: 各要素の nameproductName としてマッピング。

  • priceUSD: .details.price: 各要素の details.pricepriceUSD としてマッピング。

  • totalStock: (.details.stock | to_entries | map(.value) | add):

    • .details.stock: stock オブジェクトを選択。

    • to_entries: オブジェクトを {"key": "warehouse1", "value": 50} のような配列に変換。

    • map(.value): 各エントリから value (在庫数) のみを含む配列を生成。

    • add: 生成された数値配列の合計を計算。

  • category: (.tags[0] // "unknown"):

    • .tags[0]: tags 配列の最初の要素を選択。

    • // "unknown": tags 配列が空、または tags[0]null の場合に、デフォルト値として "unknown" を使用する。

冪等性の確保

上記のスクリプトは、ファイル名にタイムスタンプを含めることで、実行ごとに異なる出力ファイルを生成し、既存のデータを上書きしないようにしています。これにより、スクリプトを複数回実行しても、過去のデータが失われることはありません。出力ディレクトリは mkdir -p で冪等に作成されます。

検証

スクリプトの動作検証には、ダミーのAPIサーバーを立てるか、curl--output オプションで一時ファイルに保存したJSONデータを入力として使うことができます。

# スクリプトの実行シミュレーション


# 実際のAPIエンドポイントをモックするためのダミーJSONファイルを準備

cat <<EOF > /tmp/mock_api_response.json
{
  "meta": {
    "requestId": "test-req",
    "timestamp": "2024-05-10T10:30:00Z",
    "status": "success"
  },
  "data": [
    {
      "id": "item003",
      "name": "Widget X",
      "details": {
        "price": 50,
        "currency": "USD",
        "stock": {
          "store1": 15
        }
      },
      "tags": ["tools"]
    }
  ]
}
EOF

# スクリプトを直接テストする場合 (API_URLを一時的に置き換え)


# curl_output=$(cat /tmp/mock_api_response.json) のように書き換え、APIへのcurl呼び出しをスキップ

# スクリプトを直接実行して確認


# bash process_product_data.sh # 上記API_URLをダミーエンドポイントに書き換えまたはコメントアウトしてテスト

ユニットテストとしては、jq フィルター部分のみを独立させて、固定の入力JSONに対して期待する出力が得られるかを確認することが有効です。

運用

systemdによる自動化

作成したスクリプトを定期的に実行するために、systemd のサービスユニットとタイマーユニットを使用します。

サービスユニット: /etc/systemd/system/product-data-processor.service

# /etc/systemd/system/product-data-processor.service

[Unit]
Description=Product Data Processing Service
Documentation=https://example.com/docs/product-data-processor
Requires=network-online.target # ネットワークが利用可能であることを保証
After=network-online.target

[Service]
Type=oneshot # 処理が完了したら終了するサービス
ExecStart=/usr/local/bin/process_product_data.sh # スクリプトのパス
User=data_processor # スクリプト実行ユーザー (最小権限のユーザーを推奨)
Group=data_processor # スクリプト実行グループ (最小権限のグループを推奨)
WorkingDirectory=/var/lib/data_processor # スクリプトの作業ディレクトリ
StandardOutput=journal # 標準出力をsystemd journalに送る
StandardError=journal # 標準エラー出力をsystemd journalに送る

# リソース制限 (オプション)


# MemoryMax=256M


# CPUQuota=50%

[Install]
WantedBy=multi-user.target

root権限の扱いと権限分離の注意点:

  • systemd サービスは通常 root で管理されますが、ExecStart で実行されるスクリプト自体は User=Group= ディレクティブで指定された非rootユーザーで実行されるべきです。これにより、万が一スクリプトに脆弱性があった場合でも、システム全体への影響を最小限に抑えることができます [7]。

  • data_processor ユーザーは、OUTPUT_DIR (/var/lib/data_processor など) への書き込み権限と、jq, curl コマンドへの実行権限のみを持つように構成してください。

タイマーユニット: /etc/systemd/system/product-data-processor.timer

# /etc/systemd/system/product-data-processor.timer

[Unit]
Description=Run Product Data Processing Service daily

[Timer]
OnCalendar=daily # 毎日一度実行 (深夜0時頃に実行されることが多い)

# OnCalendar=*-*-* 03:00:00 # 毎日午前3時に実行したい場合

Persistent=true # タイマーが停止していた期間の実行を、再起動後に試みる
Unit=product-data-processor.service # 起動するサービスユニット

[Install]
WantedBy=timers.target

systemdユニットの有効化と起動

  1. スクリプトの配置と権限設定:

    # スクリプトの実行権限を付与
    
    chmod +x /usr/local/bin/process_product_data.sh
    
    # スクリプト実行ユーザー/グループの作成
    
    sudo useradd -r -s /sbin/nologin data_processor || true
    
    # 作業ディレクトリの作成と権限設定 (出力ファイルはこのディレクトリに保存される)
    
    sudo mkdir -p /var/lib/data_processor
    sudo chown data_processor:data_processor /var/lib/data_processor
    
  2. systemdユニットファイルの配置: 上記 .service.timer ファイルを /etc/systemd/system/ に配置します。

  3. systemd設定のリロード:

    sudo systemctl daemon-reload
    
  4. タイマーの有効化と起動:

    sudo systemctl enable product-data-processor.timer
    sudo systemctl start product-data-processor.timer
    
  5. タイマーとサービスの状態確認:

    sudo systemctl status product-data-processor.timer
    sudo systemctl status product-data-processor.service
    sudo systemctl list-timers --all # すべてのタイマーリスト
    

    タイマーの最終実行時刻と次回の実行時刻を確認できます。

監視とロギング

StandardOutput=journalStandardError=journal により、スクリプトの出力は systemd journal に記録されます。 ログの確認:

sudo journalctl -u product-data-processor.service
sudo journalctl -u product-data-processor.timer

特定の日付以降のログを確認する場合 ({{jst_today}} の部分を具体的な日付に置き換えてください):

sudo journalctl -u product-data-processor.service --since "{{jst_today}} 00:00:00"

トラブルシュート

一般的なエラーとデバッグ

  • jqのエラー: jq: error (at <stdin>:X): ... のようなメッセージが出た場合、jq フィルターの構文エラーや、入力JSON構造とフィルターの不一致が考えられます。RAW_JSON_FILE の内容を確認し、小さなJSONサンプルでjqフィルターをテストしてください。

  • curlのエラー: curl: (X) ... のエラーは、ネットワーク接続、URLの誤り、API認証の失敗、TLSの問題などを示します。--verbose オプションを追加して詳細な出力を確認してください。

  • systemdのエラー: systemctl status でエラーメッセージを確認します。journalctl でサービスユニットのログを詳細に調べることで、スクリプト実行時のエラーを特定できます。

    • ExecStart パスが間違っている。

    • User/Group に指定したユーザーが存在しないか、必要なファイルへのアクセス権限がない。

    • スクリプト内で使用している環境変数やパスが systemd 環境下で設定されていない。

パフォーマンス問題

  • 大規模JSONの処理: 数GBを超えるJSONファイルの場合、jq はメモリ消費が大きくなることがあります。この場合、jq にはストリーミングパーサー機能がないため、jellogojq のような代替ツールを検討するか、jq を複数回実行して小さなチャンクで処理するなどの工夫が必要です。

  • APIからのデータ取得が遅い: curl--max-time--connect-timeout を調整し、API側にボトルネックがないか確認します。必要であればAPI側のパフォーマンス改善を依頼します。

まとめ

本ガイドでは、jq コマンドを用いた複雑なJSONデータ操作を、curl による安全なAPIアクセスと systemd による確実な自動化と組み合わせて実装するDevOpsの実践的な手法を解説しました。set -euo pipefailtrap を用いた安全なBashスクリプトの記述、最小権限の原則に基づく systemd サービスの運用は、システムの安定性とセキュリティを確保する上で不可欠です。これらのプラクティスを適用することで、日々のデータ処理タスクを堅牢に自動化し、DevOpsの生産性向上に貢献できるでしょう。


参考文献: [1] jqlang/jq. “Releases · jqlang/jq”. GitHub. 2024年2月29日. https://github.com/jqlang/jq/releases/tag/jq-1.7.1 (最終アクセス: {{jst_today}}) [2] jqlang. “jq 1.7.1 Manual”. https://jqlang.github.io/jq/manual/ (2024年2月29日更新, 最終アクセス: {{jst_today}}) [3] Stenberg, Daniel et al. “Warnings”. everything.curl.dev. https://everything.curl.dev/using/warnings (最終アクセス: {{jst_today}}) [4] Poettering, Lennart et al. “systemd.unit”. https://www.freedesktop.org/software/systemd/man/systemd.unit.html (最終アクセス: {{jst_today}}) [5] Poettering, Lennart et al. “systemd.timer”. https://www.freedesktop.org/software/systemd/man/systemd.timer.html (最終アクセス: {{jst_today}}) [6] Wooledge, Greg. “BashFAQ/028”. mywiki.wooledge.org. https://mywiki.wooledge.org/BashFAQ/028 (最終アクセス: {{jst_today}}) [7] Poettering, Lennart et al. “systemd.service”. https://www.freedesktop.org/software/systemd/man/systemd.service.html (最終アクセス: {{jst_today}})

ライセンス:本記事のテキスト/コードは特記なき限り CC BY 4.0 です。引用の際は出典URL(本ページ)を明記してください。
利用ポリシー もご参照ください。

コメント

タイトルとURLをコピーしました