EC2からS3へのログ転送スクリプトを真面目に書く

EC2からS3へのログ転送スクリプトを真面目に書く

2026.01.17

はじめに

皆様こんにちは、あかいけです。

最近EC2からS3へログを転送するシェルスクリプトを真面目に書く機会があったため、
実装にあたり考えたこととコードの例をブログにします。

ちょっと長文になってしまったので、読むのがめんどくさい方は生成AIに食わせて要約してもらったり、
実際にコードを作成したい場合はこのブログと要件を渡して書き出させてあげるといいと思います。

モチベーション

EC2のOSログやアプリログを保存する場合、
一般的に CloudWatch Agent を用いて CloudWatch Logs に転送することが多いのではないでしょうか。
またEC2以外のサービスも標準でCloudWatch Logsに対応していることが多いので、
ログ集約の観点からEC2のログも他と合わせてCloudWatch Logsに保存するパターンもあるでしょう。

ただし以下のような理由から、ログ保存先としてS3が適している場合もあります。

  • ログ保存コストを抑えたい
  • 監査目的であり参照頻度が高くなく、高度な分析機能はいらない
  • ログ量が多くCloudWatch Logsだと取り込み料金が高くなってしまう
  • 保存期間が長いのでCloudWatch Logsだと保存料金が高くなってしまう

CloudWatch Logs と S3 の料金比較は、以下ブログがわかりやすいのでおすすめです。
https://dev.classmethod.jp/articles/comparison-of-fees-for-cloudwatch-logs-and-s3/

そしてS3へログを転送する場合、一般的な選択肢として以下の2通りがあると思います。

方法①:OSSを利用する

ログ収集・S3へのログ転送出力に対応しているOSSとして、以下が代表的です。

基本的にEC2でのログ転送であれば、Fluent Bitがおすすめです。
軽量でリソース消費が少なく、S3への出力プラグインも標準で備わっています。

https://dev.classmethod.jp/articles/ec2-amazon-linux-2023-fluent-bit-s3/
https://dev.classmethod.jp/articles/al2023-fluentd-s3/
https://dev.classmethod.jp/articles/vector-install-al2/

方法②:自作する

①のOSSが使えたら一番楽ですが、
システムや開発環境の要件によっては、以下のような理由で利用できない場合もあります。

  • OSSのパッチ当てやバージョンアップの保守工数がない
  • OSS利用のために社内申請が必要で稟議が通るか怪しい・時間が足りない
  • 社内規則的にそもそもOSSの利用が難しい

こういった場合にログ転送用のスクリプトを作成する必要が出てきます。
S3への転送はAWS CLIや各種SDKなどを利用することができますが、
個人的な肌感として、AWS CLIを用いたシェルスクリプトで実装することが多いような気がします。

ただこの部分は実装するシステムや対応するエンジニアによってまちまちでしょう。
例えばインフラ担当者がいるならシェルスクリプトを使いがちでしょうし、
アプリ担当者がインフラを兼任する場合は、アプリの実装と同じ言語のSDKを用いるかもしれません。

なので実装方法はお好みで大丈夫です。


というわけで今回は、
EC2からS3へのログ転送をシェルスクリプトで実装するポイントを書いていきます。

最小の実装 (あまりよくない例)

「めっちゃ前書き長いけど、要はS3へログファイルをコピーするだけでしょう?」
そう思った方も多いでしょう。

確かに転送するだけなら以下のような内容で、
あとはcronとかに登録して定期実行するだけです。超簡単ですね。

s3-log-transfer.sh
#!/bin/bash

YESTERDAY_DATE=$(date -d "1 day ago" +%Y%m%d)
SRC_PATH="/var/log/messages-${YESTERDAY_DATE}"
DST_BUCKET="ec2-log-export-s3"

aws s3 cp "${SRC_PATH}" \
  s3://"${DST_BUCKET}"/messages-"${YESTERDAY_DATE}"

ただし、このコードには以下のような問題点があります。
(要件次第なので必ず問題になるわけではないですが…)

  • 複数のログファイルにどうやって対応するか
  • 複数のEC2でS3を共通で利用した場合、ログファイルが混ざってしまう
  • S3バケット名やパスがハードコードされており、環境ごとに書き換えが必要
  • ログファイルを圧縮せずに転送するとストレージコストが増加する
  • 転送が成功したか失敗したかの記録が残らない

またあくまで私の経験ですが、
こういったシェルスクリプトは他プロジェクトやシステムでも流用する場合があるため、意外と汎用性が求められたりもします。

なのでこれらを解消する方法を、真面目に考えていきましょう。

実装にあたる考慮ポイント

文法編

シェルスクリプト全般に当てはまる話ですが、
私は毎回ど忘れしている気がするので備忘として書いておきます。

シェバンに気をつけよう

シェルスクリプトの1行目に書くシェバン(shebang)は、スクリプトを実行するインタプリタを指定します。

#!/bin/bash

Amazon LinuxなどほとんどのLinuxディストリビューションでは/bin/bashにBashがインストールされているため、上記で問題ありません。

ただし、macOSやBSD系など一部の環境ではBashのパスが異なる場合があります。
そのような環境でも動作させたい場合は、#!/usr/bin/env bashを使うことで、環境変数PATHからbashを検索して実行できます。

#!/usr/bin/env bash

RHEL、AmazonLinux、Ubuntuなど一般的なディストリビューションであれば#!/bin/bashで十分ですが、より汎用性を持たせたい場合は#!/usr/bin/env bashがおすすめです。

set を活用しよう

シェルスクリプトの冒頭でsetコマンドを使うことで、スクリプトの動作を安全にできます。
これはおまじない的な感じでとりあえず書く場合も多いので、見かけたことがある人も多いのではないでしょうか。

set -euo pipefail

各オプションの意味は以下の通りです。
特に-uは変数名のタイポを検出できるため、デバッグ時間の短縮に役立ちます。

オプション 効果
-e コマンドがエラー(終了コード0以外)になった時点でスクリプトを終了
-u 未定義の変数を参照した場合にエラー終了
-o pipefail パイプラインの途中でエラーが発生した場合、その終了ステータスがパイプライン全体の終了ステータスになる

readonly / local を活用しよう

変数のスコープと変更可能性を適切に制御することで、バグを防ぎやすくなります。

他のプログラミング言語だと定数/変数の使い分けは当たり前に認知されていますが、
シェルスクリプトだと意外と蔑ろにされがちな気がします。

readonly

スクリプト全体で変更されるべきでない値はreadonlyで定義します。
ただしreadonlyの場合はグローバル変数になってしまうので、シェルスクリプト全体で参照したい場合を除いて、基本的には後述のlocalを利用しましょう。

readonly SCRIPT_NAME=$(basename "$0")
readonly SCRIPT_VERSION="1.0.0"
readonly DEFAULT_CONFIG_FILE="/etc/s3-log-transfer/config.conf"
local

関数内で使う変数はlocalで宣言することで、関数内でのみ参照できます。
逆にlocalを付けないと、関数内で定義した変数がグローバルになり、関数外から参照できてしまいます。

upload_file() {
    local source_file="$1"
    local s3_path="$2"
    local filename
    filename=$(basename "${source_file}")
    # ...
}

また local -r とすることで、読み取り専用の関数内に閉じた変数を宣言できます。

upload_file() {
    local -r source_file="$1"
    local -r s3_path="$2"
    local -r filename=$(basename "${source_file}")
    # ...
}

設計編

コードと設定ファイルを分割しよう

環境ごとに異なる設定値(S3バケット名、リージョン、ログパスなど)は、設定ファイルに外出しすることで再利用性が高まります。
これにより、ログ転送スクリプト自体を修正する必要がなくなります。

設定ファイルの例 (/etc/s3-log-transfer/config.conf)

# S3 Settings
S3_BUCKET="my-log-bucket"
AWS_REGION="ap-northeast-1"

# Log entries (format: log_type:log_dir:pattern)
LOG_ENTRIES=(
    "messages:/var/log:messages-*"
    "secure:/var/log:secure-*"
    "app:/var/log/myapp:app.log-*"
)

スクリプト側での読み込み例

load_config() {
    if [[ ! -f "${CONFIG_FILE}" ]]; then
        log_error "Configuration file not found: ${CONFIG_FILE}"
        exit 1
    fi
    source "${CONFIG_FILE}"
}

転送先のパスを考えよう

S3上でログを整理しやすくするため、転送先パスの設計は重要です。
ログの分析するしないに関わらず、適当な設計をすると後々困ることになるので、ちゃんと考えましょう。

以下はあくまで一例ですが、その後どのようにログを参照するかを軸に決めましょう。

Athenaなどで分析する場合

Athenaでクエリする場合、Hive形式のパーティション(key=value)を使うとパーティションプルーニングが効き、スキャン量を削減できます。

s3://bucket-name/
  └── log_type=messages/
      └── hostname=web-server-01/
          └── year=2025/
              └── month=01/
                  └── day=15/
                      └── messages-20250115.gz

このような構造にすると、以下のようなWHERE句でスキャン対象を絞り込めます。

-- 特定日のログを取得
SELECT log_line
FROM os_logs
WHERE log_type = 'messages'
  AND hostname = 'web-server-01'
  AND year = '2025'
  AND month = '01'
  AND day = '15'
LIMIT 100;
人間が目視で確認する場合

S3コンソールやAWS CLIで直接ファイルを探す場合、好みの問題かもしれませんがホスト名から辿れる構造が直感的だと思います。
この構造であれば「このサーバーの1月15日のログを見たい」という場面で素早くアクセスできます。

s3://bucket-name/
  └── web-server-01/
      └── 2025/
          └── 01/
              └── 15/
                  └── messages/
                      └── messages-20250115.gz

コマンドライン引数を処理しよう

getoptsを使うことで、コマンドライン引数を簡単にパースできます。
正直定期実行するスクリプトに多彩なオプションは不要ではありますが、テストなどの際に役立つでしょう。

while getopts "c:d:nh" opt; do
    case "${opt}" in
        c)
            CONFIG_FILE="${OPTARG}"
            ;;
        d)
            TARGET_DATE="${OPTARG}"
            ;;
        n)
            DRY_RUN=true
            ;;
        h)
            show_usage
            exit 0
            ;;
        *)
            show_usage
            exit 1
            ;;
    esac
done

今回の用途であれば、以下のようなオプションを用意してあげると親切でしょう。

./s3-log-transfer.sh -d 20260117        # 特定日付のログを転送
./s3-log-transfer.sh -c /path/to/config # 別の設定ファイルを使用
./s3-log-transfer.sh -n                 # ドライランモード

ヘルプを表示しよう

-hオプションで使用方法を表示できると、スクリプトの使い方がわかりやすくなります。
またヒアドキュメント(<< EOF)を使うことで、複数行のテキストを見やすく記述できます。

show_usage() {
    cat << EOF
Usage: ${SCRIPT_NAME} [-c config_file] [-d date] [-n] [-h]

Options:
  -c config_file  Path to configuration file (default: ${DEFAULT_CONFIG_FILE})
  -d date         Target date in YYYYMMDD format (default: yesterday)
  -n              Dry-run mode (show commands without executing)
  -h              Show this help message

Example:
  ${SCRIPT_NAME}                    # Transfer yesterday's logs
  ${SCRIPT_NAME} -d 20260117        # Transfer logs for specific date
  ${SCRIPT_NAME} -n                 # Dry-run mode

Version: ${SCRIPT_VERSION}
EOF
}

個人的にコードの設計書を読むのはめんどくさい気持ちになってしまうので、
ヘルプを表示してくれるとかなり親切な感じがしていいと思います。

ドライランモードを実装しよう

何が行われるかを確認できるドライランモードを実装すると、テストやデバッグが容易になります。
これはスクリプトを作りながらの段階でも役立つでしょうし、実際に使い始めた後の運用でも役立つと思います。

DRY_RUN=false

# コマンドライン引数で -n を受け取った場合
if [[ "${DRY_RUN}" == true ]]; then
    log_info "[DRY-RUN] Would upload: ${source_file} -> ${s3_dest}"
else
    aws s3 cp "${source_file}" "${s3_dest}"
fi

エラーハンドリングを考えよう

シェルスクリプトに限った話ではないですが、エラーが発生した際の挙動を適切に制御することが重要です。

また前述のset -eにより、コマンドが失敗した時点でスクリプトが終了します。
ただし、意図的にエラーを無視したい場合は|| trueを付けます。

# このコマンドが失敗してもスクリプトは継続
some_command || true

# または、エラーを変数に格納
if ! result=$(some_command 2>&1); then
    log_warn "Command failed: ${result}"
fi

関数の戻り値を活用する

関数からはreturnで終了コードを返し、呼び出し側で確認できます。

upload_file() {
    # ...
    if ! aws s3 cp "${file}" "${dest}"; then
        log_error "Failed to upload: ${file}"
        return 1
    fi
    return 0
}

# 呼び出し側
if upload_file "${file}" "${s3_path}"; then
    ((success_count++))
else
    ((fail_count++))
fi

クリーンアップ処理を入れよう

スクリプト実行中に作成される一時ファイル(圧縮ファイルなど)は、終了時に確実に削除しましょう。
trapコマンドを使うことで、正常終了・異常終了に関わらずクリーンアップ処理を実行できます。

readonly TEMP_DIR="/tmp/s3-log-transfer"

cleanup() {
    if [[ -d "${TEMP_DIR}" ]]; then
        rm -rf "${TEMP_DIR}"
    fi
}

trap cleanup EXIT

trap ... EXITは、スクリプトが終了する際に必ず実行されます。
こうすることでset -eでエラー終了した場合も一時ファイルが残りません。

依存関係をチェックしよう

スクリプトが依存するコマンド(aws, gzip, hostnameなど)が存在するかを起動時にチェックすることで、実行途中でエラーになることを防げます。

check_dependencies() {
    local deps=("aws" "gzip" "hostname")
    for dep in "${deps[@]}"; do
        if ! command -v "${dep}" &> /dev/null; then
            log_error "Required command not found: ${dep}"
            exit 1
        fi
    done
}

ファイル処理編

ログの転送単位を考えよう

ログ転送の単位には主に2つのパターンがあります。
一般的な監査ログのような用途では日次転送、
逆にリアルタイムにログを分析したい場合は、リアルタイム転送が良いかと思います。

パターン 説明 メリット デメリット
日次転送 1日分のログをまとめて転送 シンプル、ローテーション後のファイルを転送しやすい リアルタイム性が低い
リアルタイム転送 書き込み中のログを随時転送 即座にS3で確認可能 実装が複雑、追記の追跡が必要

本記事の最後に紹介するサンプルスクリプトでは、日次転送の想定で書いています。

ログファイルの命名規則に対応しよう

ログファイルの命名規則はアプリケーションによって様々です。
代表的なパターンに対応できるようにしておきましょう。

ログ種別 命名規則の例
syslog系 messages-YYYYMMDD, secure-YYYYMMDD
Tomcat catalina.YYYY-MM-DD.log, localhost_access_log.YYYY-MM-DD.txt
アプリ app.log-YYYYMMDD, batch_log.YYYY-MM-DD.txt

また設定ファイルでパターンを指定できるようにすると、汎用性が高まるのでおすすめです。

LOG_ENTRIES=(
    "messages:/var/log:messages-*"
    "tomcat:/var/log/tomcat:*.YYYY-MM-DD.log"
)

圧縮が必要か考えよう

ログファイルは一般的にテキストデータであり、gzip圧縮により大幅にサイズを削減できます。

gzip -c "${source_file}" > "${compressed_file}"
aws s3 cp "${compressed_file}" "${s3_dest}"

目視でログファイルをすぐに確認したい場合などを除けば、
基本的には圧縮して問題ないかと思います。

  • メリット

    • S3のストレージコスト削減(テキストログは1/5〜1/10程度になることも)
    • ネットワーク転送時間の短縮
  • デメリット

    • 圧縮処理のCPU負荷
    • S3上で直接内容を確認できない(ダウンロードして解凍が必要)

ただしAthenaで分析したい場合は一部圧縮形式に対応していない場合があるので、
その点は事前に確認しておきましょう。

https://docs.aws.amazon.com/ja_jp/athena/latest/ug/compression-formats.html

ログファイルのローテーションについて考えよう

多くのLinuxシステムでは、logrotateによって自動的にログファイルがローテーションされます。
ログ転送にあたっては、ローテーション後のファイル名の規則を確認することが大事でしょう。

logrotateの設定例:

/etc/logrotate.d/rsyslog
/var/log/cron
/var/log/maillog
/var/log/messages
/var/log/secure
/var/log/spooler
{
    daily
    dateext
    rotate 7
    missingok
    sharedscripts
    postrotate
        /usr/bin/systemctl kill -s HUP rsyslog.service >/dev/null 2>&1 || true
    endscript
}

この設定では、/var/log/messagesが日次でローテーションされ、messages-20260117のような名前で作成されます。
ログ転送スクリプト側では、このローテーション後のファイルを転送対象にします。

運用編

リトライ処理を考えよう

AWS CLIはデフォルトでリトライ機能を持っています。
標準モードでは最大2回のリトライが行われ、一時的なネットワークエラーやスロットリングに対応してくれます。

最大再試行回数のデフォルト値は 2 で、合計で 3 回呼び出しが試みられます。この値は、max_attempts 設定パラメータを使用して上書きできます。

https://docs.aws.amazon.com/ja_jp/cli/v1/userguide/cli-configure-retries.html

そのため多くの場合、シェルスクリプト側で独自のリトライ処理を実装する必要はありません。
ただしAWS CLI以外の処理(圧縮処理など)でリトライが必要な場合や、よりきめ細かい制御が必要な場合は、以下のような実装を検討してください。

upload_with_retry() {
    local source_file="$1"
    local s3_dest="$2"
    local max_retries=3
    local retry_delay=5
    local attempt=1

    while [[ ${attempt} -le ${max_retries} ]]; do
        if aws s3 cp "${source_file}" "${s3_dest}" --region "${AWS_REGION}" --quiet; then
            return 0
        fi
        log_warn "Upload failed (attempt ${attempt}/${max_retries}), retrying in ${retry_delay}s..."
        sleep ${retry_delay}
        ((attempt++))
    done
    return 1
}

ログ転送のログを出力しよう

ログ転送処理自体のログを出力することで、いつ・どのファイルが転送されたかを追跡できるようになります。
ログ出力の実装方法には主に2つのアプローチがあるので、運用要件に応じて選択しましょう。

方法1: 自前でログ関数を作成する

この方法のメリットは、ログフォーマットを完全に制御できる点です。

log() {
    local level="$1"
    shift
    local message="$*"
    local timestamp
    timestamp=$(date '+%Y-%m-%d %H:%M:%S')
    echo "[${timestamp}] [${level}] ${message}" | tee -a "${LOG_FILE}"
}

log_info() { log "INFO" "$@"; }
log_error() { log "ERROR" "$@"; }

方法2: loggerコマンドを使用する

loggerコマンドはログを/var/log/messagesに出力できるため、その他のOSログと合わせて管理することができます。

logger "s3-log-transfer: Transfer started"

またrsyslogの設定ファイルに設定して-pオプションで指定してあげれば、任意のログファイルに出力することもできます。
なので出力形式に特にこだわりがなければ、loggerコマンドを使えばいいかなと思います。

/etc/rsyslog.conf
local0.*    /var/log/myapp.log
logger -p local0.info "Transfer started"

定期実行しよう

ログ転送スクリプトに限らず、定期実行する場合はcronが一般的でしょう。
またAmazon Linux2023の場合はデフォルトでcronがインストールされておらず、systemd timerがその代わりにインストールされています。

cronの例:

# 毎日午前2時に前日分のログを転送
0 2 * * * /usr/local/bin/s3-log-transfer.sh >> /var/log/s3-log-transfer/cron.log 2>&1

systemd timerの例:

systemd timerを使うと、ジャーナルログでの確認や、失敗時の自動リトライ設定などが可能です。

s3-log-transfer.service
[Unit]
Description=S3 Log Transfer Service
After=network-online.target
Wants=network-online.target

[Service]
Type=oneshot
ExecStart=/usr/local/bin/s3-log-transfer.sh
s3-log-transfer.timer
[Unit]
Description=S3 Log Transfer Timer
Requires=s3-log-transfer.service

[Timer]
OnCalendar=*-*-* 02:00:00
Persistent=true

[Install]
WantedBy=timers.target

systemd timerのより詳しい話は、以下ブログがわかりやすくておすすめです。

https://dev.classmethod.jp/articles/slug-btThPHViGsPt/

もろもろ反映した実装例

さて、以上の諸々の考慮事項を反映すると、以下のようになります。
ログを転送するだけにしてはなんだか過剰な気もしますが、これぐらい真面目に書いていれば大抵の要件に対応できると思います。(たぶん…)

/etc/s3-log-transfer/config.conf
# =============================================================================
# S3ログ転送スクリプト 設定ファイル
# =============================================================================

# S3バケット名
S3_BUCKET="bucket-name"

# AWSリージョン
AWS_REGION="ap-northeast-1"

# S3パスのプレフィックス(省略時はホスト名が使用される)
# S3_PATH_PREFIX="web-server-01"

# ログファイルのパス
LOG_FILE="/var/log/s3-log-transfer/transfer.log"

# =============================================================================
# 転送対象のログ設定
# =============================================================================
# フォーマット: "ログ種別:ディレクトリ:ファイルパターン"
#
# ファイルパターンの特殊文字列(実行時に対象日付に置換される):
#   - YYYYMMDD   : 20250115 形式
#   - YYYY-MM-DD : 2025-01-15 形式
#
# パターン例:
#   - messages-*            : messages-20250115 にマッチ
#   - *.YYYY-MM-DD.log      : catalina.2025-01-15.log にマッチ
#   - *_log.YYYY-MM-DD.txt  : app_log.2025-01-15.txt にマッチ

LOG_ENTRIES=(
    # OSログ
    "os-messages:/var/log:messages-*"
    "os-secure:/var/log:secure-*"

    # アプリケーションログ
    "app:/var/log/myapp:*_log.YYYY-MM-DD.txt"
)
/usr/local/bin/s3-log-transfer.sh
#!/usr/bin/env bash
#
# S3ログ転送スクリプト
# 概要: EC2のログファイルをgzip圧縮してS3に転送する
#
# 使用方法: ./s3-log-transfer.sh [-c config_file] [-d date] [-n]
#   -c config_file : 設定ファイルのパス (デフォルト: /etc/s3-log-transfer/config.conf)
#   -d date        : 対象日付 YYYYMMDD形式 (デフォルト: 前日)
#   -n             : ドライランモード (実行せずにコマンドを表示)
#

set -euo pipefail

# =============================================================================
# 読み込み専用グローバル変数
# =============================================================================
readonly SCRIPT_NAME=$(basename "$0")
readonly SCRIPT_VERSION="1.0.0"
readonly DEFAULT_CONFIG_FILE="/etc/s3-log-transfer/config.conf"
readonly DEFAULT_LOG_FILE="/var/log/s3-log-transfer/transfer.log"
readonly TEMP_DIR="/tmp/s3-log-transfer"

# =============================================================================
# グローバル変数
# =============================================================================
CONFIG_FILE="${DEFAULT_CONFIG_FILE}"
LOG_FILE="${DEFAULT_LOG_FILE}"
TARGET_DATE=""
DRY_RUN=false
HOSTNAME=""

# 設定ファイルから読み込む変数
S3_BUCKET=""
AWS_REGION=""
S3_PATH_PREFIX=""  # S3パスのプレフィックス(省略時はホスト名を使用)
declare -a LOG_ENTRIES=()

# =============================================================================
# ログ出力関数
# =============================================================================

log() {
    local level="$1"
    shift
    local message="$*"
    local timestamp
    timestamp=$(date '+%Y-%m-%d %H:%M:%S')
    local log_dir
    log_dir=$(dirname "${LOG_FILE}")
    [[ -d "${log_dir}" ]] || mkdir -p "${log_dir}"
    echo "[${timestamp}] [${level}] ${message}" | tee -a "${LOG_FILE}"
}

log_info() { log "INFO" "$@"; }
log_warn() { log "WARN" "$@"; }
log_error() { log "ERROR" "$@"; }

# =============================================================================
# ユーティリティ関数
# =============================================================================

# 日付フォーマット変換
format_date() {
    local input_date="$1"
    local format="$2"
    date -d "${input_date}" "+${format}" 2>/dev/null || \
        date -j -f '%Y%m%d' "${input_date}" "+${format}"
}

# ヘルプ表示
show_usage() {
    cat << EOF
使用方法: ${SCRIPT_NAME} [-c config_file] [-d date] [-n] [-h]

オプション:
  -c config_file  設定ファイルのパス (デフォルト: ${DEFAULT_CONFIG_FILE})
  -d date         対象日付 YYYYMMDD形式 (デフォルト: 前日)
  -n              ドライランモード (実行せずにコマンドを表示)
  -h              このヘルプを表示

実行例:
  ${SCRIPT_NAME}                    # 前日のログを転送
  ${SCRIPT_NAME} -d 20250115        # 指定日付のログを転送
  ${SCRIPT_NAME} -n                 # ドライランモード

Version: ${SCRIPT_VERSION}
EOF
}

# 一時ファイルのクリーンアップ
cleanup() {
    if [[ -d "${TEMP_DIR}" ]]; then
        rm -rf "${TEMP_DIR}"
    fi
}
trap cleanup EXIT

# 依存コマンドのチェック
check_dependencies() {
    local deps=("aws" "gzip" "hostname")
    for dep in "${deps[@]}"; do
        if ! command -v "${dep}" &> /dev/null; then
            log_error "必要なコマンドが見つかりません: ${dep}"
            exit 1
        fi
    done
}

# 設定ファイルの読み込み
load_config() {
    if [[ ! -f "${CONFIG_FILE}" ]]; then
        log_error "設定ファイルが見つかりません: ${CONFIG_FILE}"
        exit 1
    fi

    # shellcheck source=/dev/null
    source "${CONFIG_FILE}"

    # 必須項目のバリデーション
    if [[ -z "${S3_BUCKET:-}" ]]; then
        log_error "S3_BUCKETが設定されていません"
        exit 1
    fi
    if [[ -z "${AWS_REGION:-}" ]]; then
        log_error "AWS_REGIONが設定されていません"
        exit 1
    fi
    if [[ ${#LOG_ENTRIES[@]} -eq 0 ]]; then
        log_error "LOG_ENTRIESが空です"
        exit 1
    fi

    LOG_FILE="${LOG_FILE:-${DEFAULT_LOG_FILE}}"
}

# 環境の初期化
init_environment() {
    # ログディレクトリの作成
    local log_dir
    log_dir=$(dirname "${LOG_FILE}")
    [[ -d "${log_dir}" ]] || mkdir -p "${log_dir}"

    # 一時ディレクトリの作成
    mkdir -p "${TEMP_DIR}"

    # S3パスに使用するホスト名を設定
    if [[ -n "${S3_PATH_PREFIX:-}" ]]; then
        HOSTNAME="${S3_PATH_PREFIX}"
    else
        HOSTNAME=$(hostname -s)
    fi

    # 対象日付が未指定の場合は前日を設定
    if [[ -z "${TARGET_DATE}" ]]; then
        TARGET_DATE=$(date -d "yesterday" '+%Y%m%d' 2>/dev/null || date -v-1d '+%Y%m%d')
    fi
}

# =============================================================================
# メイン処理
# =============================================================================

# S3パスを生成(形式: s3://bucket/hostname/YYYY/MM/DD/log-type/)
get_s3_path() {
    local log_type="$1"
    local year="${TARGET_DATE:0:4}"
    local month="${TARGET_DATE:4:2}"
    local day="${TARGET_DATE:6:2}"
    echo "s3://${S3_BUCKET}/${HOSTNAME}/${year}/${month}/${day}/${log_type}/"
}

# パターンと日付に一致するログファイルを検索
find_log_files() {
    local log_dir="$1"
    local pattern="$2"
    local files=()
    local formatted_date

    # パターン内の日付プレースホルダーを実際の日付に置換
    formatted_date=$(format_date "${TARGET_DATE}" '%Y-%m-%d')
    local resolved_pattern="${pattern}"
    resolved_pattern="${resolved_pattern//YYYYMMDD/${TARGET_DATE}}"
    resolved_pattern="${resolved_pattern//YYYY-MM-DD/${formatted_date}}"

    # *-* 形式(例: messages-*, secure-*)の場合
    if [[ "${resolved_pattern}" == *-\* ]]; then
        local base_name="${resolved_pattern%-*}"
        local target_file="${log_dir}/${base_name}-${TARGET_DATE}"
        if [[ -f "${target_file}" ]]; then
            files+=("${target_file}")
        fi
    # その他のパターン: findで検索
    else
        while IFS= read -r -d '' file; do
            files+=("${file}")
        done < <(find "${log_dir}" -maxdepth 1 -type f -name "${resolved_pattern}" -print0 2>/dev/null)
    fi

    printf '%s\n' "${files[@]}"
}

# ファイルを圧縮してS3にアップロード
upload_file() {
    local source_file="$1"
    local s3_path="$2"
    local filename
    filename=$(basename "${source_file}")
    local compressed_file="${TEMP_DIR}/${filename}.gz"
    local s3_dest="${s3_path}${filename}.gz"

    log_info "処理中: ${source_file}"

    # 圧縮
    if [[ "${DRY_RUN}" == true ]]; then
        log_info "[DRY-RUN] 圧縮: ${source_file} -> ${compressed_file}"
    else
        if ! gzip -c "${source_file}" > "${compressed_file}"; then
            log_error "圧縮失敗: ${source_file}"
            return 1
        fi
    fi

    # S3へアップロード
    if [[ "${DRY_RUN}" == true ]]; then
        log_info "[DRY-RUN] アップロード: ${compressed_file} -> ${s3_dest}"
    else
        if aws s3 cp "${compressed_file}" "${s3_dest}" --region "${AWS_REGION}" --quiet; then
            log_info "アップロード完了: ${s3_dest}"
        else
            log_error "アップロード失敗: ${source_file} -> ${s3_dest}"
            return 1
        fi
        rm -f "${compressed_file}"
    fi
    return 0
}

# ログエントリを処理(形式: log_type:log_dir:pattern)
process_log_entry() {
    local entry="$1"
    local log_type log_dir pattern
    IFS=':' read -r log_type log_dir pattern <<< "${entry}"

    if [[ -z "${log_type}" ]] || [[ -z "${log_dir}" ]] || [[ -z "${pattern}" ]]; then
        log_warn "無効なログエントリ形式: ${entry}"
        return 1
    fi

    log_info "ログ種別: ${log_type} (ディレクトリ: ${log_dir}, パターン: ${pattern})"

    if [[ ! -d "${log_dir}" ]]; then
        log_warn "ディレクトリが存在しません: ${log_dir}"
        return 0
    fi

    local s3_path
    s3_path=$(get_s3_path "${log_type}")

    local files
    files=$(find_log_files "${log_dir}" "${pattern}")

    if [[ -z "${files}" ]]; then
        log_info "該当ファイルなし: ${pattern} in ${log_dir}"
        return 0
    fi

    local success_count=0
    local fail_count=0

    while IFS= read -r file; do
        if [[ -n "${file}" ]] && [[ -f "${file}" ]]; then
            if upload_file "${file}" "${s3_path}"; then
                ((success_count++))
            else
                ((fail_count++))
            fi
        fi
    done <<< "${files}"

    log_info "${log_type}: ${success_count}件成功, ${fail_count}件失敗"
    return 0
}

main() {
    local start_time end_time duration

    # コマンドライン引数の解析
    while getopts "c:d:nh" opt; do
        case "${opt}" in
            c) CONFIG_FILE="${OPTARG}" ;;
            d) TARGET_DATE="${OPTARG}" ;;
            n) DRY_RUN=true ;;
            h) show_usage; exit 0 ;;
            *) show_usage; exit 1 ;;
        esac
    done

    check_dependencies
    load_config
    init_environment

    start_time=$(date +%s)

    log_info "=========================================="
    log_info "S3ログ転送 開始"
    log_info "設定ファイル: ${CONFIG_FILE}"
    log_info "対象日付: ${TARGET_DATE}"
    log_info "ホスト名: ${HOSTNAME}"
    log_info "S3バケット: ${S3_BUCKET}"
    log_info "ドライラン: ${DRY_RUN}"
    log_info "=========================================="

    # 各ログエントリを処理
    local total_entries=${#LOG_ENTRIES[@]}
    local processed=0

    for entry in "${LOG_ENTRIES[@]}"; do
        ((processed++)) || true
        log_info "処理中: ${processed}/${total_entries}"
        process_log_entry "${entry}" || true
    done

    end_time=$(date +%s)
    duration=$((end_time - start_time))

    log_info "=========================================="
    log_info "S3ログ転送 完了"
    log_info "所要時間: ${duration}秒"
    log_info "=========================================="
}

# =============================================================================
# エントリーポイント
# =============================================================================
main "$@"

さいごに

以上、EC2のログ転送シェルスクリプトを真面目に書いてみました。
ログ転送シェルスクリプトと言いつつ、シェルスクリプト全体に共通して考えることが多かったような気がします。

私は普段Fluent BitなどOSSで設定することが多くログ転送の処理はあまり考えてこなかったですが、改めて考えてみると色々考慮事項があり、脳みそのリフレッシュになった気がします。

このブログが誰かの参考になれば幸いです。

この記事をシェアする

FacebookHatena blogX

関連記事