1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

自宅HDFS上のApache IcebergをAmazon S3へ複製しAthenaから分析してみる【構築編】

1
Last updated at Posted at 2026-08-19

はじめに

前回の設計編では、自宅 HDFS 上の Apache Iceberg を Source of Truth として残し、AWS 側に分析用 Iceberg コピーを作る方針を整理しました。

今回は構築編です。実際に次の流れで作ります。

syslogで疎通確認
↓
authlogへ展開
↓
共通スクリプト化
↓
syslog/authlogを日次同期
↓
Athenaで両方を分析

AWS へ全面移行するのではなく、あくまで次の構成です。

自宅 = メイン環境 / Source of Truth
AWS = 分析用コピー

ゴール

最終的に以下の状態にします。


前提

自宅側は構築済みとします。

項目 値
OS Ubuntu 24.04
Hadoop 3.3.6
HDFS nameservice cluster1
Hive 3.1.3
Spark 3.5.8
Scala 2.12
Apache Iceberg 1.10.1
既存 Iceberg runtime iceberg-spark-runtime-3.5_2.12-1.10.1.jar
自宅側 catalog hive_prod
自宅側 warehouse hdfs://cluster1/warehouse/iceberg

対象テーブルは次の2つです。

hive_prod.logs.syslog_iceberg
hive_prod.logs.authlog_iceberg

既存記事では次のような schema / partition の例を使っています。

CREATE TABLE hive_prod.logs.syslog_iceberg (
  host       string,
  ts         TIMESTAMP_NTZ,
  severity   int,
  program    string,
  msg        string,
  dt         date,
  hr         int
)
USING iceberg
PARTITIONED BY (dt, host);

ただし、構築時には必ず実環境で syslog_iceberg と authlog_iceberg の両方を確認します。2テーブルの schema が完全に同じであるとは決め打ちしません。


IAMユーザー作成

自宅サーバーから AWS CLI / Spark で S3、Glue、Athena にアクセスするため、IAM ユーザーを2つに分けます。

IAMユーザー profile 用途
home-aws-admin home-aws-admin S3 bucket、Glue database、Athena workgroup、IAM policy を作成する初期構築用
home-iceberg-sync home-aws 日次同期で使う Spark / AWS CLI 用

home-aws-admin は初期構築だけに使います。
日次同期では使わず、後続で作成する最小権限の home-iceberg-sync を使います。

本番運用では IAM Identity Center や AssumeRole を使う方が望ましいですが、この記事では自宅検証で手順を追いやすくするため IAM User + Access Key の例にしています。
Access Key / Secret Access Key は記事、GitHub、共有メモに記載しません。

初期構築用IAMユーザーを作成する

まず、AWS 管理コンソールで初期構築用の IAM ユーザーを作成します。
この作業は、AWS アカウントのルートユーザー、または既に管理者権限を持つ IAM ユーザー / Role で実施します。

IAM
↓
ユーザー
↓
ユーザーを作成

作成時のパラメータです。

画面 項目 値
ユーザーの詳細 ユーザー名 home-aws-admin
ユーザーの詳細 AWS Management Console へのユーザーアクセスを提供する 任意
許可を設定 許可のオプション ポリシーを直接アタッチする
許可を設定 許可ポリシー AdministratorAccess
タグ Project home-iceberg-s3-athena
タグ Env lab

作成後、home-aws-admin の Access Key を作成します。

IAM
↓
ユーザー
↓
home-aws-admin
↓
セキュリティ認証情報
↓
アクセスキーを作成

作成した Access Key ID / Secret Access Key は、後続の「AWS CLI準備」で OS ユーザーごとの profile に設定します。

同期用IAMユーザーを作成する

次に、日次同期用の IAM ユーザーを作成します。
このユーザーは AWS アカウント全体を管理する権限を持たせません。

IAM
↓
ユーザー
↓
ユーザーを作成

作成時のパラメータです。

画面 項目 値
ユーザーの詳細 ユーザー名 home-iceberg-sync
ユーザーの詳細 AWS Management Console へのユーザーアクセスを提供する 無効
許可を設定 許可のオプション 後でポリシーを直接アタッチする
タグ Project home-iceberg-s3-athena
タグ Env lab

この時点では、まだ権限を付与しません。
後続の「IAM Policy」で、対象 S3 bucket、Glue database / table、Athena query result bucket に絞った policy を作成して、このユーザーに付与します。


AWS CLI準備

AWS 側リソースの作成や確認では aws コマンドを使います。

Ubuntu 24.04 で AWS CLI が未導入の場合は導入します。

AWSインポートシェルを実行するホスト(ope等)で実施することを推奨します。

実行ユーザー: 通常ユーザー

sudo apt-get update
sudo apt-get install -y unzip curl

curl "https://awscli.amazonaws.com/awscli-exe-linux-x86_64.zip" \
  -o "awscliv2.zip"

unzip awscliv2.zip
sudo ./aws/install

rm -rf aws
rm -f awscliv2.zip

確認します。

aws --version

credential は、aws configure だけでなく AWS credential provider chain で解決されます。

代表例です。

  • 環境変数
  • ~/.aws/credentials
  • ~/.aws/config
  • AWS profile
  • EC2 / ECS / EKS などの Role

ローカルや自宅サーバー上で実行する場合は、まず初期構築用の管理者 profile を設定しておきます。
このコマンドは、AWS リソース作成作業を行う通常ユーザーで実行します。

実行ユーザー: 通常ユーザー

aws configure --profile home-aws-admin

設定する値です。

項目 値
AWS Access Key ID home-aws-admin の Access Key ID
AWS Secret Access Key home-aws-admin の Secret Access Key
Default region name ap-northeast-1
Default output format json

設定後、AWS アカウントへアクセスできることを確認します。

aws sts get-caller-identity --profile home-aws-admin

Arn が以下のように home-aws-admin になっていれば OK です。

arn:aws:iam::<YOUR_AWS_ACCOUNT_ID>:user/home-aws-admin

今回の同期スクリプトは通常ユーザーで実行します。
そのため、同期用 profile も通常ユーザーの ~/.aws/ に作成します。


変数

AWS CLI の準備ができたら、以降のコマンドで使う変数を設定します。

新規に作成する名前と、既存 AWS アカウントの情報を入れる値が混在しているため、先に整理します。

変数 指定する値 種別
AWS_REGION 利用する AWS リージョン。例では東京リージョン ap-northeast-1 任意に指定
ICEBERG_BUCKET Iceberg warehouse 用に新規作成する S3 bucket 名 新規に任意で指定
ATHENA_RESULT_BUCKET Athena query result 用に新規作成する S3 bucket 名 新規に任意で指定
ATHENA_WORKGROUP Athena 用に新規作成する workgroup 名 新規に任意で指定
GLUE_DATABASE AWS Glue Data Catalog に作成する database 名 新規に任意で指定
AWS_PROFILE AWS CLI で使う profile 名 既存設定または新規設定
AWS_SHARED_CREDENTIALS_FILE 現在使う profile の credentials ファイル 用途に応じて切り替え
AWS_CONFIG_FILE 現在使う profile の config ファイル 用途に応じて切り替え
AWS_ACCOUNT_ID 利用する AWS アカウント ID 既存 AWS アカウント情報
IAM_USER_NAME policy を付与する 同期用IAM User 名 既存 AWS アカウント情報

AWS_PROFILE には、AWS 上のリソース名ではなく、AWS CLI を実行する端末に設定した profile 名を指定します。
初期構築用 profile と同期用 profile は、どちらも通常ユーザーの ~/.aws/ を参照します。

profile 名は、次のコマンドで確認できます。

aws configure list-profiles

今回、初期構築用の AWS_PROFILE には home-aws-admin を指定します。

S3 bucket 名は AWS 全体で一意である必要があります。別ユーザーの bucket 名と被らないように、アカウント ID、日付、用途名などを含めます。

例です。

naritomo-123456789012-20260819-iceberg-warehouse
naritomo-123456789012-20260819-athena-result

実行ユーザー: 通常ユーザー

export AWS_REGION=ap-northeast-1
export ICEBERG_BUCKET="your-name-123456789012-20260819-iceberg-warehouse"
export ATHENA_RESULT_BUCKET="your-name-123456789012-20260819-athena-result"
export ATHENA_WORKGROUP="home-log-iceberg"
export GLUE_DATABASE=logs
export AWS_PROFILE="home-aws-admin"
export AWS_SHARED_CREDENTIALS_FILE="${HOME}/.aws/credentials"
export AWS_CONFIG_FILE="${HOME}/.aws/config"
export AWS_ACCOUNT_ID="123456789012"
export IAM_USER_NAME="home-iceberg-sync"

your-name、123456789012、20260819、home-log-iceberg、home-aws-admin は自分の環境に合わせて置き換えてください。

ここでは、S3 bucket、Glue database、Athena workgroup、IAM policy を作るため、手元の shell の AWS_PROFILE は初期構築用の home-aws-admin にしています。
systemd / Spark 用の /etc/iceberg/aws.env は、最初から日次同期用の home-aws を指すようにします。

AWS アカウント ID は以下で確認できます。

aws sts get-caller-identity --profile "${AWS_PROFILE}"

変数を毎回打たないようにする

上記の export は、現在の shell session だけで有効です。ログアウトしたり、別 terminal を開いたりすると再設定が必要になります。

毎回打たなくてよいように、共通の環境変数ファイルを作ります。

ファイル:

/etc/iceberg/aws.env

実行ユーザー: 通常ユーザー

sudo install -d -m 755 /etc/iceberg

sudo tee /etc/iceberg/aws.env > /dev/null <<EOF
AWS_REGION=${AWS_REGION}
ICEBERG_BUCKET=${ICEBERG_BUCKET}
ATHENA_RESULT_BUCKET=${ATHENA_RESULT_BUCKET}
ATHENA_WORKGROUP=${ATHENA_WORKGROUP}
GLUE_DATABASE=${GLUE_DATABASE}
AWS_ADMIN_PROFILE=home-aws-admin
AWS_SYNC_PROFILE=home-aws
AWS_PROFILE=home-aws
AWS_ADMIN_SHARED_CREDENTIALS_FILE=${HOME}/.aws/credentials
AWS_ADMIN_CONFIG_FILE=${HOME}/.aws/config
AWS_SYNC_SHARED_CREDENTIALS_FILE=/var/lib/spark/.aws/credentials
AWS_SYNC_CONFIG_FILE=/var/lib/spark/.aws/config
AWS_SHARED_CREDENTIALS_FILE=/var/lib/spark/.aws/credentials
AWS_CONFIG_FILE=/var/lib/spark/.aws/config
AWS_ACCOUNT_ID=${AWS_ACCOUNT_ID}
IAM_USER_NAME=${IAM_USER_NAME}
SPARK_RUN_USER=spark
SPARK_RUN_LOCAL_DIRS=/var/lib/spark/work
EOF

sudo chmod 644 /etc/iceberg/aws.env

sudo tee /etc/profile.d/iceberg-s3-athena.sh > /dev/null <<'EOF'
use_iceberg_aws_admin() {
  set -a
  . /etc/iceberg/aws.env
  set +a
  export AWS_PROFILE="${AWS_ADMIN_PROFILE}"
  export AWS_SHARED_CREDENTIALS_FILE="${AWS_ADMIN_SHARED_CREDENTIALS_FILE}"
  export AWS_CONFIG_FILE="${AWS_ADMIN_CONFIG_FILE}"
}

use_iceberg_aws_sync() {
  set -a
  . /etc/iceberg/aws.env
  set +a
  export AWS_PROFILE="${AWS_SYNC_PROFILE}"
  export AWS_SHARED_CREDENTIALS_FILE="${AWS_SYNC_SHARED_CREDENTIALS_FILE}"
  export AWS_CONFIG_FILE="${AWS_SYNC_CONFIG_FILE}"
}
EOF

sudo chmod 644 /etc/profile.d/iceberg-s3-athena.sh

このファイルには Access Key / Secret Access Key は書きません。credential は AWS profile、~/.aws/credentials、IAM Role など AWS credential provider chain に任せます。
/etc/iceberg/aws.env の既定値は、systemd / Spark 日次同期用です。
/etc/profile.d/iceberg-s3-athena.sh は、手動作業時に初期構築用 profile と同期用 profile を選ぶための関数だけを定義します。

手動実行するときは、最初に読み込みます。

実行ユーザー: 通常ユーザー

source /etc/profile.d/iceberg-s3-athena.sh
use_iceberg_aws_admin

通常ユーザーで AWS 側リソース作成、IAM policy 作成、S3 / Glue / Athena の管理確認を行う場合も、再ログイン後の shell では環境変数が未反映の状態から始まる前提にします。
そのため、通常ユーザーで初期構築作業を再開するたびに、同じログインセッション内で source /etc/profile.d/iceberg-s3-athena.sh と use_iceberg_aws_admin を実行します。
AWS_PROFILE=home-aws-admin、AWS_SHARED_CREDENTIALS_FILE=<通常ユーザーのHOME>/.aws/credentials になっていることを確認してから、AWS 管理系コマンドを実行します。

変数を変更して反映する

bucket 名、workgroup 名、database 名などを変えたい場合は、/etc/iceberg/aws.env を編集します。

実行ユーザー: 通常ユーザー

sudo vi /etc/iceberg/aws.env

編集後、手元の shell に反映します。

source /etc/profile.d/iceberg-s3-athena.sh

# 初期構築作業の場合
use_iceberg_aws_admin

# Spark 同期作業の場合
# use_iceberg_aws_sync

反映された値を確認します。

printf 'AWS_REGION=%s\n' "${AWS_REGION}"
printf 'AWS_PROFILE=%s\n' "${AWS_PROFILE}"
printf 'AWS_SHARED_CREDENTIALS_FILE=%s\n' "${AWS_SHARED_CREDENTIALS_FILE}"
printf 'AWS_CONFIG_FILE=%s\n' "${AWS_CONFIG_FILE}"
printf 'ICEBERG_BUCKET=%s\n' "${ICEBERG_BUCKET}"
printf 'ATHENA_RESULT_BUCKET=%s\n' "${ATHENA_RESULT_BUCKET}"
printf 'ATHENA_WORKGROUP=%s\n' "${ATHENA_WORKGROUP}"
printf 'GLUE_DATABASE=%s\n' "${GLUE_DATABASE}"

spark ユーザーで AWS / Spark / Iceberg の同期確認を行う前には、同じログインセッション内で必要に応じて次を実行します。

実行ユーザー: spark

source /etc/profile.d/iceberg-s3-athena.sh
use_iceberg_aws_sync

printf 'AWS_PROFILE=%s\n' "${AWS_PROFILE}"
printf 'AWS_SHARED_CREDENTIALS_FILE=%s\n' "${AWS_SHARED_CREDENTIALS_FILE}"
printf 'AWS_CONFIG_FILE=%s\n' "${AWS_CONFIG_FILE}"

AWS_PROFILE=home-aws、AWS_SHARED_CREDENTIALS_FILE=/var/lib/spark/.aws/credentials になっていることを確認してから、後続の spark-sql-iceberg-aws や同期スクリプトを実行します。
ログインし直した場合や、新しい terminal で作業する場合も、この初期化をやり直します。

使い分けは以下です。

作業 実行する関数 参照する credential
S3 bucket / Glue database / Athena workgroup / IAM policy などの管理系操作 use_iceberg_aws_admin 通常ユーザーの ~/.aws/credentials
spark ユーザーでの spark-sql-iceberg-aws や同期スクリプトの直接実行 use_iceberg_aws_sync /var/lib/spark/.aws/credentials
/usr/local/bin/export-iceberg-to-s3 の実行 事前初期化は不要 wrapper が /etc/iceberg/aws.env を読み込み、内部で spark ユーザーへ切り替える

AWS側準備

S3 bucket作成

東京リージョン ap-northeast-1 では、create-bucket-configuration を指定します。

実行ユーザー: 通常ユーザー

aws s3api create-bucket \
  --bucket "${ICEBERG_BUCKET}" \
  --region "${AWS_REGION}" \
  --create-bucket-configuration LocationConstraint="${AWS_REGION}" \
  --profile "${AWS_PROFILE}"

aws s3api create-bucket \
  --bucket "${ATHENA_RESULT_BUCKET}" \
  --region "${AWS_REGION}" \
  --create-bucket-configuration LocationConstraint="${AWS_REGION}" \
  --profile "${AWS_PROFILE}"

Block Public Access を有効化します。

実行ユーザー: 通常ユーザー

for BUCKET in "${ICEBERG_BUCKET}" "${ATHENA_RESULT_BUCKET}"; do
  aws s3api put-public-access-block \
    --bucket "${BUCKET}" \
    --public-access-block-configuration \
      BlockPublicAcls=true,IgnorePublicAcls=true,BlockPublicPolicy=true,RestrictPublicBuckets=true \
    --profile "${AWS_PROFILE}"
done

Server-side encryption を有効化します。

実行ユーザー: 通常ユーザー

for BUCKET in "${ICEBERG_BUCKET}" "${ATHENA_RESULT_BUCKET}"; do
  aws s3api put-bucket-encryption \
    --bucket "${BUCKET}" \
    --server-side-encryption-configuration '{
      "Rules": [
        {
          "ApplyServerSideEncryptionByDefault": {
            "SSEAlgorithm": "AES256"
          }
        }
      ]
    }' \
    --profile "${AWS_PROFILE}"
done

HTTPS 以外のアクセスを bucket policy で拒否します。

s3://${ICEBERG_BUCKET}/... は S3 上の場所を表す URI であり、HTTP 通信を指定しているわけではありません。
ただし、誤設定や古いクライアントから平文 HTTP でアクセスされないように、S3 側でも aws:SecureTransport を使って拒否しておきます。

この policy は、通常の AWS CLI、Spark の Iceberg S3FileIO、Athena からの HTTPS アクセスは拒否しません。
一方、put-bucket-policy は既存の bucket policy を置き換えるため、既存 bucket を使う場合は現在の policy に以下の DenyInsecureTransport statement を追加する形にします。
本記事では新規 bucket を作る前提なので、そのまま設定します。

実行ユーザー: 通常ユーザー

for BUCKET in "${ICEBERG_BUCKET}" "${ATHENA_RESULT_BUCKET}"; do
  cat > "deny-insecure-transport-${BUCKET}.json" <<EOF
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "DenyInsecureTransport",
      "Effect": "Deny",
      "Principal": "*",
      "Action": "s3:*",
      "Resource": [
        "arn:aws:s3:::${BUCKET}",
        "arn:aws:s3:::${BUCKET}/*"
      ],
      "Condition": {
        "Bool": {
          "aws:SecureTransport": "false"
        }
      }
    }
  ]
}
EOF

  aws s3api put-bucket-policy \
    --bucket "${BUCKET}" \
    --policy "file://deny-insecure-transport-${BUCKET}.json" \
    --profile "${AWS_PROFILE}"
done

設定後、bucket policy を確認します。

for BUCKET in "${ICEBERG_BUCKET}" "${ATHENA_RESULT_BUCKET}"; do
  aws s3api get-bucket-policy \
    --bucket "${BUCKET}" \
    --query Policy \
    --output text \
    --profile "${AWS_PROFILE}"
done

S3 Versioning は削除や上書きに対する復旧余地を作れますが、noncurrent version の保存容量も増えます。
そのため、構築編では有効化を必須にせず、継続運用する場合に lifecycle とセットで検討します。
具体的な設定は運用編で扱います。


Athena Workgroup作成

Athena の query result location を毎回手動で指定しなくてよいように、今回の検証用 workgroup を作成します。

作成する workgroup です。

Workgroup:
${ATHENA_WORKGROUP}

Query result location:
s3://${ATHENA_RESULT_BUCKET}/athena-results/

実行ユーザー: 通常ユーザー

この操作を実行する AWS profile には、少なくとも Athena workgroup を作成・参照する権限が必要です。

  • athena:CreateWorkGroup
  • athena:GetWorkGroup
aws athena create-work-group \
  --region "${AWS_REGION}" \
  --name "${ATHENA_WORKGROUP}" \
  --configuration "ResultConfiguration={OutputLocation=s3://${ATHENA_RESULT_BUCKET}/athena-results/},EnforceWorkGroupConfiguration=true,PublishCloudWatchMetricsEnabled=true" \
  --profile "${AWS_PROFILE}"

すでに存在する場合はエラーになります。その場合は確認だけで問題ありません。

aws athena get-work-group \
  --region "${AWS_REGION}" \
  --work-group "${ATHENA_WORKGROUP}" \
  --profile "${AWS_PROFILE}"

この workgroup では EnforceWorkGroupConfiguration=true を指定しています。これにより、Athena 実行時の query result location は workgroup 側の設定を使います。

Athena コンソールから実行する場合も、画面上部の workgroup で ${ATHENA_WORKGROUP} を選択してから SQL を実行します。

既存の primary workgroup を使っても動作しますが、PoC 用に分けておくと以下を確認しやすくなります。

  • query result location
  • 実行履歴
  • Data scanned
  • CloudWatch metrics
  • 後片付け対象

Glue Database作成

実行ユーザー: 通常ユーザー

aws glue create-database \
  --region "${AWS_REGION}" \
  --database-input "{\"Name\":\"${GLUE_DATABASE}\"}" \
  --profile "${AWS_PROFILE}"

すでに存在する場合はエラーになります。その場合は確認だけで問題ありません。

aws glue get-database \
  --region "${AWS_REGION}" \
  --name "${GLUE_DATABASE}" \
  --profile "${AWS_PROFILE}"

IAM Policy

Spark から S3 と Glue にアクセスするための policy 例です。

実行ユーザー: 通常ユーザー

cat > iceberg-s3-glue-policy.json <<EOF
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "AllowCallerIdentity",
      "Effect": "Allow",
      "Action": [
        "sts:GetCallerIdentity"
      ],
      "Resource": "*"
    },
    {
      "Sid": "AllowIcebergBucketList",
      "Effect": "Allow",
      "Action": [
        "s3:ListBucket",
        "s3:GetBucketLocation"
      ],
      "Resource": [
        "arn:aws:s3:::${ICEBERG_BUCKET}",
        "arn:aws:s3:::${ATHENA_RESULT_BUCKET}"
      ]
    },
    {
      "Sid": "AllowIcebergBucketObjects",
      "Effect": "Allow",
      "Action": [
        "s3:GetObject",
        "s3:PutObject",
        "s3:DeleteObject",
        "s3:AbortMultipartUpload",
        "s3:ListMultipartUploadParts"
      ],
      "Resource": [
        "arn:aws:s3:::${ICEBERG_BUCKET}/*",
        "arn:aws:s3:::${ATHENA_RESULT_BUCKET}/*"
      ]
    },
    {
      "Sid": "AllowGlueCatalogForLogs",
      "Effect": "Allow",
      "Action": [
        "glue:GetDatabase",
        "glue:GetDatabases",
        "glue:CreateDatabase",
        "glue:GetTable",
        "glue:GetTables",
        "glue:CreateTable",
        "glue:UpdateTable",
        "glue:DeleteTable",
        "glue:GetPartition",
        "glue:GetPartitions",
        "glue:CreatePartition",
        "glue:UpdatePartition",
        "glue:DeletePartition"
      ],
      "Resource": [
        "arn:aws:glue:${AWS_REGION}:${AWS_ACCOUNT_ID}:catalog",
        "arn:aws:glue:${AWS_REGION}:${AWS_ACCOUNT_ID}:database/${GLUE_DATABASE}",
        "arn:aws:glue:${AWS_REGION}:${AWS_ACCOUNT_ID}:table/${GLUE_DATABASE}/*"
      ]
    },
    {
      "Sid": "AllowAthenaQueryExecution",
      "Effect": "Allow",
      "Action": [
        "athena:StartQueryExecution",
        "athena:GetQueryExecution",
        "athena:GetQueryResults",
        "athena:GetWorkGroup",
        "athena:ListQueryExecutions",
        "athena:StopQueryExecution"
      ],
      "Resource": [
        "arn:aws:athena:${AWS_REGION}:${AWS_ACCOUNT_ID}:workgroup/${ATHENA_WORKGROUP}"
      ]
    }
  ]
}
EOF

生成された policy の中身を確認します。

cat iceberg-s3-glue-policy.json

policy を作成します。

実行ユーザー: 通常ユーザー

aws iam create-policy \
  --policy-name IcebergS3GlueLogsPolicy \
  --policy-document file://iceberg-s3-glue-policy.json \
  --profile "${AWS_PROFILE}"

作成した policy を IAM User または Role に付与します。

aws iam attach-user-policy \
  --user-name "${IAM_USER_NAME}" \
  --policy-arn "arn:aws:iam::${AWS_ACCOUNT_ID}:policy/IcebergS3GlueLogsPolicy" \
  --profile "${AWS_PROFILE}"

EC2 など AWS 上で Spark を実行する場合は、IAM User の Access Key ではなく IAM Role を優先します。


同期用IAMユーザー設定

ここまでの S3 bucket、Glue database、Athena workgroup、IAM policy 作成は、初期構築用の管理者 profile で実行しました。

以降の Spark による日次同期は、同期用IAMユーザー home-iceberg-sync で実行します。

AWS IAM コンソールで home-iceberg-sync のアクセスキーを作成します。

実行ユーザー: 通常ユーザー

aws iam create-access-key \
  --user-name "${IAM_USER_NAME}" \
  --profile "${AWS_PROFILE}"

このコマンドでは Access Key ID と Secret Access Key が表示されます。
Secret Access Key が表示されるのは作成時だけなので、安全な場所に保管します。
記事やリポジトリには貼り付けません。

作成した Access Key ID / Secret Access Key を、spark ユーザーの同期用 profile として設定します。

実行ユーザー: 通常ユーザー

sudo install -d -m 700 -o spark -g spark /var/lib/spark/.aws

sudo -u spark -H aws configure --profile home-aws

設定する値です。

項目 値
AWS Access Key ID home-iceberg-sync の Access Key ID
AWS Secret Access Key home-iceberg-sync の Secret Access Key
Default region name ap-northeast-1
Default output format json

/etc/iceberg/aws.env は最初から systemd / Spark 用に home-aws と /var/lib/spark/.aws/ を指しているため、ここで書き換える必要はありません。

Spark 実行時に使う profile を spark ユーザーで確認します。

sudo -u spark -H env \
  AWS_PROFILE=home-aws \
  AWS_SHARED_CREDENTIALS_FILE=/var/lib/spark/.aws/credentials \
  AWS_CONFIG_FILE=/var/lib/spark/.aws/config \
  aws sts get-caller-identity --profile home-aws

Arn が以下のように home-iceberg-sync になっていれば OK です。

arn:aws:iam::<YOUR_AWS_ACCOUNT_ID>:user/home-iceberg-sync

AWS疎通確認

以降の Spark 手動作業では、spark ユーザーの shell で同期用 profile を有効にします。

実行ユーザー: 通常ユーザー

source /etc/profile.d/iceberg-s3-athena.sh
use_iceberg_aws_sync

S3 へアクセスできることを確認します。

aws s3 ls s3://${ICEBERG_BUCKET} --profile "${AWS_PROFILE}"

SparkへIceberg AWS関連JARを追加

既存環境では Iceberg Spark runtime を使っています。

iceberg-spark-runtime-3.5_2.12-1.10.1.jar

GlueCatalog と S3FileIO を使うには、Iceberg の AWS integration が必要です。Iceberg 1.10.1 では、AWS 関連の依存をまとめた artifact として次を利用します。

iceberg-aws-bundle-1.10.1.jar

不要な AWS SDK JAR を手当たり次第に追加せず、Iceberg の version に合わせた bundle を追加します。

実行ユーザー: 通常ユーザー

sudo mkdir -p /opt/spark/extra-jars

cd /opt/spark/extra-jars

sudo curl -LO \
  https://repo1.maven.org/maven2/org/apache/iceberg/iceberg-aws-bundle/1.10.1/iceberg-aws-bundle-1.10.1.jar

sudo chmod 644 /opt/spark/extra-jars/iceberg-aws-bundle-1.10.1.jar

既存の Iceberg runtime jar も同じディレクトリにあることを確認します。

ls -l /opt/spark/extra-jars/

AWS bundle の中に GlueCatalog が必要とする AWS SDK v2 の Glue class が含まれていることも確認します。

jar tf /opt/spark/extra-jars/iceberg-aws-bundle-1.10.1.jar \
  | grep 'software/amazon/awssdk/services/glue/model/EntityNotFoundException.class'

何も表示されない場合は、iceberg-aws-bundle ではない jar を置いている、ダウンロードに失敗して HTML などを保存している、または version が想定と違っている可能性があります。

jar として読めるかも確認します。

file /opt/spark/extra-jars/iceberg-aws-bundle-1.10.1.jar
jar tf /opt/spark/extra-jars/iceberg-aws-bundle-1.10.1.jar >/dev/null

既存Spark設定を壊さない

既存の hive_prod はそのまま残します。

追加するのは AWS 用の glue_prod です。

hive_prod = 自宅HDFS Iceberg
glue_prod = AWS S3 Iceberg

既存の /usr/local/bin/spark-sql-iceberg も可能な限りそのまま使います。


Spark Catalog設定

/etc/spark/conf/spark-defaults.conf に glue_prod を追加します。

実行ユーザー: 通常ユーザー

sudo cp -a \
  /etc/spark/conf/spark-defaults.conf \
  /etc/spark/conf/spark-defaults.conf.$(date +%Y%m%d%H%M%S).bak

sudo tee -a /etc/spark/conf/spark-defaults.conf > /dev/null <<EOF

# AWS Glue Data Catalog + S3 Iceberg
spark.sql.catalog.glue_prod=org.apache.iceberg.spark.SparkCatalog
spark.sql.catalog.glue_prod.catalog-impl=org.apache.iceberg.aws.glue.GlueCatalog
spark.sql.catalog.glue_prod.io-impl=org.apache.iceberg.aws.s3.S3FileIO
spark.sql.catalog.glue_prod.warehouse=s3://${ICEBERG_BUCKET}/warehouse
spark.sql.catalog.glue_prod.client.region=${AWS_REGION}
EOF

ICEBERG_BUCKET と AWS_REGION は、先に設定した変数の値に展開されます。

既存の spark.driver.extraClassPath / spark.executor.extraClassPath が単一 jar だけを指定している場合は、AWS bundle も含めます。

例です。

spark.driver.extraClassPath=/opt/spark/extra-jars/iceberg-spark-runtime-3.5_2.12-1.10.1.jar:/opt/spark/extra-jars/iceberg-aws-bundle-1.10.1.jar
spark.executor.extraClassPath=/opt/spark/extra-jars/iceberg-spark-runtime-3.5_2.12-1.10.1.jar:/opt/spark/extra-jars/iceberg-aws-bundle-1.10.1.jar

本記事では、既存ラッパーは変更せず、AWS 用ラッパーを別名で作ります。
既存の自宅 HDFS Iceberg 用コマンドを壊さずに戻せるようにするためです。

AWS 用ラッパーは内部で spark ユーザーに切り替えてから Spark SQL を実行します。
そのため、Spark の local work directory は spark ユーザーが書き込める場所にします。

実行ユーザー: 通常ユーザー

sudo install -d -m 700 -o spark -g spark /var/lib/spark/work

sudo cp -a \
  /etc/spark/conf/spark-env.sh \
  /etc/spark/conf/spark-env.sh.$(date +%Y%m%d%H%M%S).bak

if grep -q '^export SPARK_LOCAL_DIRS=' /etc/spark/conf/spark-env.sh; then
  sudo sed -i \
    's#^export SPARK_LOCAL_DIRS=.*#export SPARK_LOCAL_DIRS="${SPARK_LOCAL_DIRS:-/var/lib/spark/work}"#' \
    /etc/spark/conf/spark-env.sh
else
  echo 'export SPARK_LOCAL_DIRS="${SPARK_LOCAL_DIRS:-/var/lib/spark/work}"' \
    | sudo tee -a /etc/spark/conf/spark-env.sh > /dev/null
fi

grep -n 'SPARK_LOCAL_DIRS' /etc/spark/conf/spark-env.sh

実行ユーザー: 通常ユーザー

sudo tee /usr/local/bin/spark-sql-iceberg-aws > /dev/null <<'EOF'
#!/usr/bin/env bash
set -euo pipefail

export JAVA_HOME=/usr/lib/jvm/java-17-openjdk-amd64
export PATH="${JAVA_HOME}/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
export SPARK_HOME=/opt/spark/current
export SPARK_CONF_DIR=/etc/spark/conf
export HADOOP_CONF_DIR=/etc/hadoop/conf
export HIVE_CONF_DIR=/etc/hive/conf
export AWS_REGION="${AWS_REGION:-ap-northeast-1}"
export AWS_PROFILE="${AWS_PROFILE:-home-aws}"
export AWS_SHARED_CREDENTIALS_FILE="${AWS_SHARED_CREDENTIALS_FILE:-${HOME}/.aws/credentials}"
export AWS_CONFIG_FILE="${AWS_CONFIG_FILE:-${HOME}/.aws/config}"
export SPARK_LOCAL_DIRS="${SPARK_LOCAL_DIRS:-/var/lib/spark/work}"

mkdir -p "${SPARK_LOCAL_DIRS}"
chmod 700 "${SPARK_LOCAL_DIRS}"

exec /opt/spark/current/bin/spark-sql \
  --driver-class-path /opt/spark/extra-jars/iceberg-spark-runtime-3.5_2.12-1.10.1.jar:/opt/spark/extra-jars/iceberg-aws-bundle-1.10.1.jar \
  --jars /opt/spark/extra-jars/iceberg-spark-runtime-3.5_2.12-1.10.1.jar,/opt/spark/extra-jars/iceberg-aws-bundle-1.10.1.jar \
  --conf spark.driver.memory=1g \
  --conf spark.executor.memory=512m \
  --conf spark.executor.cores=1 \
  --conf spark.cores.max=1 \
  --conf spark.local.dir="${SPARK_LOCAL_DIRS}" \
  --conf spark.driver.extraClassPath=/opt/spark/extra-jars/iceberg-spark-runtime-3.5_2.12-1.10.1.jar:/opt/spark/extra-jars/iceberg-aws-bundle-1.10.1.jar \
  --conf spark.executor.extraClassPath=/opt/spark/extra-jars/iceberg-spark-runtime-3.5_2.12-1.10.1.jar:/opt/spark/extra-jars/iceberg-aws-bundle-1.10.1.jar \
  --conf "spark.executorEnv.AWS_REGION=${AWS_REGION}" \
  --conf "spark.executorEnv.AWS_PROFILE=${AWS_PROFILE}" \
  --conf "spark.executorEnv.AWS_SHARED_CREDENTIALS_FILE=${AWS_SHARED_CREDENTIALS_FILE}" \
  --conf "spark.executorEnv.AWS_CONFIG_FILE=${AWS_CONFIG_FILE}" \
  --conf "spark.driver.extraJavaOptions=-Daws.profile=${AWS_PROFILE} -Daws.region=${AWS_REGION}" \
  --conf "spark.executor.extraJavaOptions=-Daws.profile=${AWS_PROFILE} -Daws.region=${AWS_REGION}" \
  --conf spark.sql.shuffle.partitions=1 \
  --conf spark.sql.parquet.enableDictionary=false \
  "$@"
EOF

sudo chmod 755 /usr/local/bin/spark-sql-iceberg-aws

以降は SPARK_SQL_BIN=/usr/local/bin/spark-sql-iceberg-aws として使います。

作成した wrapper が Iceberg runtime と AWS bundle の両方を参照していることを確認します。

grep -n 'iceberg-.*1.10.1.jar' /usr/local/bin/spark-sql-iceberg-aws

ここで iceberg-spark-runtime-3.5_2.12-1.10.1.jar と iceberg-aws-bundle-1.10.1.jar の両方が表示される必要があります。

GlueCatalog の初期化は Spark SQL CLI の driver 側で行われるため、--jars だけでなく --driver-class-path と spark.driver.extraClassPath にも同じ jar を明示します。
software.amazon.awssdk.services.glue.model.EntityNotFoundException が見つからない場合は、AWS bundle の jar 自体が正常でも driver classpath に載っていない可能性があります。

S3 への実データ書き込みは executor 側で行われるため、AWS profile と credential file の場所も spark.executorEnv.* で executor に渡します。
Unable to load credentials ... ProfileCredentialsProvider(profileName=default) が出る場合は、driver ではなく executor 側に AWS_PROFILE=home-aws が伝わっていない可能性があります。


Glue Namespace確認

実行ユーザー: spark

/usr/local/bin/spark-sql-iceberg-aws <<EOF
SHOW NAMESPACES IN glue_prod;
CREATE NAMESPACE IF NOT EXISTS glue_prod.${GLUE_DATABASE};
SHOW NAMESPACES IN glue_prod;
EOF

AWS CLI でも確認します。

実行ユーザー: spark

aws glue get-database \
  --region "${AWS_REGION}" \
  --name "${GLUE_DATABASE}" \
  --profile "${AWS_PROFILE}"

自宅側schema確認

まず自宅側の2テーブルを確認します。

実行ユーザー: spark

/usr/local/bin/spark-sql-iceberg <<'EOF'
DESCRIBE TABLE hive_prod.logs.syslog_iceberg;
DESCRIBE TABLE hive_prod.logs.authlog_iceberg;

SHOW CREATE TABLE hive_prod.logs.syslog_iceberg;
SHOW CREATE TABLE hive_prod.logs.authlog_iceberg;
EOF

ここで確認するポイントです。

  • カラム名
  • 型
  • dt の型
  • ts の型
  • partition spec
  • table properties
  • format-version

既存記事の例では、どちらも PARTITIONED BY (dt, host)、format-version=2、write.distribution-mode=hash です。


syslog_icebergをAWS側へ作成

まずは syslog_iceberg だけで疎通確認します。

実行ユーザー: spark

/usr/local/bin/spark-sql-iceberg-aws <<EOF
CREATE TABLE IF NOT EXISTS glue_prod.${GLUE_DATABASE}.syslog_iceberg (
  host       string,
  ts         TIMESTAMP_NTZ,
  severity   int,
  program    string,
  msg        string,
  dt         date,
  hr         int
)
USING iceberg
LOCATION 's3://${ICEBERG_BUCKET}/warehouse/${GLUE_DATABASE}/syslog_iceberg'
PARTITIONED BY (dt, host)
TBLPROPERTIES (
  'format-version'='2',
  'write.distribution-mode'='hash',
  'write.parquet.compression-codec'='zstd'
);
EOF

ここから先の例は、直前に確認した自宅側の定義が上記と同じである前提で進めます。
自宅側が TIMESTAMP_NTZ ではない場合やカラムが増えている場合は、この時点で AWS 側の CREATE TABLE を自宅側に合わせて修正してから進めます。


syslogを1日分コピー

ここでは 2026-08-18 を例にします。

実行ユーザー: spark

/usr/local/bin/spark-sql-iceberg-aws <<EOF
DELETE FROM glue_prod.${GLUE_DATABASE}.syslog_iceberg
WHERE dt = DATE '2026-08-18';

INSERT INTO glue_prod.${GLUE_DATABASE}.syslog_iceberg
SELECT *
FROM hive_prod.logs.syslog_iceberg
WHERE dt = DATE '2026-08-18';
EOF

DELETE してから INSERT することで、同じ日付を再実行しても二重登録されにくくなります。


syslog件数確認

実行ユーザー: spark

/usr/local/bin/spark-sql-iceberg-aws <<EOF
SELECT count(*) AS src_count
FROM hive_prod.logs.syslog_iceberg
WHERE dt = DATE '2026-08-18';

SELECT count(*) AS dest_count
FROM glue_prod.${GLUE_DATABASE}.syslog_iceberg
WHERE dt = DATE '2026-08-18';
EOF

src_count と dest_count が一致すれば、1日分のコピーは成功です。


syslogのS3確認

実行ユーザー: spark

aws s3 ls \
  s3://${ICEBERG_BUCKET}/warehouse/${GLUE_DATABASE}/syslog_iceberg/ \
  --recursive \
  --profile "${AWS_PROFILE}"

Iceberg テーブルとして、次のようなファイル群が存在することを確認します。

data/
metadata/

syslogのGlue確認

実行ユーザー: spark

aws glue get-table \
  --region "${AWS_REGION}" \
  --database-name "${GLUE_DATABASE}" \
  --name syslog_iceberg \
  --profile "${AWS_PROFILE}"

Glue 上に次の table が登録されていれば OK です。

Database: ${GLUE_DATABASE}
Table: syslog_iceberg

Athenaからsyslogを検索

Athena コンソールで ${ATHENA_WORKGROUP} を選択します。

この workgroup では、query result location として次を設定済みです。

s3://${ATHENA_RESULT_BUCKET}/athena-results/

Athena で実行します。

SELECT count(*)
FROM logs.syslog_iceberg;

以降の Athena SQL 例では GLUE_DATABASE=logs を前提にしています。GLUE_DATABASE を別名にした場合は、logs.syslog_iceberg や logs.authlog_iceberg の logs を実際の database 名に置き換えてください。

日付と host で集計します。

SELECT
    host,
    count(*) AS cnt
FROM logs.syslog_iceberg
WHERE dt = DATE '2026-08-18'
GROUP BY host
ORDER BY cnt DESC;

必要な列だけを選び、dt で絞り込むことでスキャン量を抑えます。


authlog_icebergをAWS側へ作成

syslog で確認できたら、次は authlog_iceberg です。

まず schema を確認します。

実行ユーザー: spark

/usr/local/bin/spark-sql-iceberg <<'EOF'
DESCRIBE TABLE hive_prod.logs.authlog_iceberg;
SHOW CREATE TABLE hive_prod.logs.authlog_iceberg;
EOF

AWS 側に table を作成します。

実行ユーザー: spark

/usr/local/bin/spark-sql-iceberg-aws <<EOF
CREATE TABLE IF NOT EXISTS glue_prod.${GLUE_DATABASE}.authlog_iceberg (
  host       string,
  ts         TIMESTAMP_NTZ,
  severity   int,
  program    string,
  msg        string,
  dt         date,
  hr         int
)
USING iceberg
LOCATION 's3://${ICEBERG_BUCKET}/warehouse/${GLUE_DATABASE}/authlog_iceberg'
PARTITIONED BY (dt, host)
TBLPROPERTIES (
  'format-version'='2',
  'write.distribution-mode'='hash',
  'write.parquet.compression-codec'='zstd'
);
EOF

ここでも、自宅側の schema / partition spec と差分がある場合は AWS 側定義を合わせます。


authlogを1日分コピー

実行ユーザー: spark

/usr/local/bin/spark-sql-iceberg-aws <<EOF
DELETE FROM glue_prod.${GLUE_DATABASE}.authlog_iceberg
WHERE dt = DATE '2026-08-18';

INSERT INTO glue_prod.${GLUE_DATABASE}.authlog_iceberg
SELECT *
FROM hive_prod.logs.authlog_iceberg
WHERE dt = DATE '2026-08-18';
EOF

authlog件数確認

実行ユーザー: spark

/usr/local/bin/spark-sql-iceberg-aws <<EOF
SELECT count(*) AS src_count
FROM hive_prod.logs.authlog_iceberg
WHERE dt = DATE '2026-08-18';

SELECT count(*) AS dest_count
FROM glue_prod.${GLUE_DATABASE}.authlog_iceberg
WHERE dt = DATE '2026-08-18';
EOF

authlogのS3確認

実行ユーザー: spark

aws s3 ls \
  s3://${ICEBERG_BUCKET}/warehouse/${GLUE_DATABASE}/authlog_iceberg/ \
  --recursive \
  --profile "${AWS_PROFILE}"

data/ と metadata/ が作成されていることを確認します。


authlogのGlue確認

実行ユーザー: spark

aws glue get-table \
  --region "${AWS_REGION}" \
  --database-name "${GLUE_DATABASE}" \
  --name authlog_iceberg \
  --profile "${AWS_PROFILE}"

Glue 上に次の table が登録されていれば OK です。

Database: ${GLUE_DATABASE}
Table: authlog_iceberg

Athenaからauthlogを検索

Athena コンソールで ${ATHENA_WORKGROUP} が選択されていることを確認します。

Athena で実行します。

SELECT count(*)
FROM logs.authlog_iceberg;

host 別に集計します。

SELECT
    host,
    count(*) AS cnt
FROM logs.authlog_iceberg
WHERE dt = DATE '2026-08-18'
GROUP BY host
ORDER BY cnt DESC;

program 別に集計します。

SELECT
    program,
    count(*) AS cnt
FROM logs.authlog_iceberg
WHERE dt = DATE '2026-08-18'
GROUP BY program
ORDER BY cnt DESC;

SSH ログが含まれる場合は、sshd を対象に確認します。

SELECT
    host,
    count(*) AS cnt
FROM logs.authlog_iceberg
WHERE dt = DATE '2026-08-18'
  AND program = 'sshd'
GROUP BY host
ORDER BY cnt DESC;

Athenaスキャン量確認

Athena は query 実行後に Data scanned と Execution time を確認できます。

比較用に、まず広いクエリを実行します。

SELECT *
FROM logs.syslog_iceberg;

次に、列と日付を絞ります。

SELECT host
FROM logs.syslog_iceberg
WHERE dt = DATE '2026-08-18';

確認する観点です。

  • Data scanned がどれくらい減ったか
  • Execution time がどう変わったか
  • dt による partition pruning が効いているか
  • 必要な列だけ読むことで Parquet の列指向が効いているか

authlog_iceberg でも同じ傾向を確認します。

SELECT *
FROM logs.authlog_iceberg;

SELECT host
FROM logs.authlog_iceberg
WHERE dt = DATE '2026-08-18';

自宅Trinoとの比較

同じ日付で、自宅 Trino と Athena を比較します。

自宅 Trino:

SELECT
    host,
    count(*) AS cnt
FROM iceberg.logs.syslog_iceberg
WHERE dt = DATE '2026-08-18'
GROUP BY host
ORDER BY cnt DESC;

Athena:

SELECT
    host,
    count(*) AS cnt
FROM logs.syslog_iceberg
WHERE dt = DATE '2026-08-18'
GROUP BY host
ORDER BY cnt DESC;

比較表です。

項目 自宅 HDFS + Iceberg + Trino AWS S3 + Iceberg + Athena
実行時間 自宅サーバー性能に依存 Athena の実行状況に依存
スキャン量表示 Trino 側の統計で確認 Athena の Data scanned で確認
CPU / I/O 自宅側で消費 AWS 側で消費
サーバー管理 Trino / JVM / OS 管理が必要 Athena はサーバーレス
料金 電気代 / 機材 / 運用負荷 S3 / Athena / Glue 料金
検索開始まで 自宅環境に接続が必要 AWS Console / Athena から実行可能
データ転送 不要 日次同期が必要

同期スクリプト

手動実行、日次実行、systemd timer からの実行を、1つの入口に統合します。

ファイル:

/usr/local/bin/export-iceberg-to-s3

実行ユーザー: 通常ユーザー

sudo tee /usr/local/bin/export-iceberg-to-s3 > /dev/null <<'EOF'
#!/usr/bin/env bash
set -euo pipefail

ENV_FILE=${ENV_FILE:-/etc/iceberg/aws.env}

if [ -r "${ENV_FILE}" ]; then
  set -a
  . "${ENV_FILE}"
  set +a
fi

SOURCE_CATALOG=${SOURCE_CATALOG:-hive_prod}
DEST_CATALOG=${DEST_CATALOG:-glue_prod}
SOURCE_DB=${SOURCE_DB:-logs}
DEST_DB=${DEST_DB:-${GLUE_DATABASE:-logs}}
DT=${DT:-$(date -d yesterday +%F)}
SPARK_SQL_BIN=${SPARK_SQL_BIN:-/usr/local/bin/spark-sql-iceberg-aws}
SPARK_RUN_USER=${SPARK_RUN_USER:-spark}
SPARK_RUN_LOCAL_DIRS=${SPARK_RUN_LOCAL_DIRS:-/var/lib/spark/work}

if [ "$(id -un)" != "${SPARK_RUN_USER}" ]; then
  export ENV_FILE
  export SOURCE_CATALOG
  export DEST_CATALOG
  export SOURCE_DB
  export DEST_DB
  export GLUE_DATABASE="${GLUE_DATABASE:-}"
  export TABLE="${TABLE:-}"
  export TABLES="${TABLES:-}"
  export DT
  export SPARK_SQL_BIN
  export AWS_REGION
  export AWS_PROFILE="${AWS_SYNC_PROFILE:-${AWS_PROFILE:-}}"
  export AWS_SHARED_CREDENTIALS_FILE="${AWS_SYNC_SHARED_CREDENTIALS_FILE:-${AWS_SHARED_CREDENTIALS_FILE:-}}"
  export AWS_CONFIG_FILE="${AWS_SYNC_CONFIG_FILE:-${AWS_CONFIG_FILE:-}}"
  export SPARK_LOCAL_DIRS="${SPARK_RUN_LOCAL_DIRS}"

  exec sudo -E -u "${SPARK_RUN_USER}" -H "$0" "$@"
fi

if [ -z "${AWS_PROFILE:-}" ] && [ -n "${AWS_SYNC_PROFILE:-}" ]; then
  AWS_PROFILE="${AWS_SYNC_PROFILE}"
fi

if [ -z "${AWS_SHARED_CREDENTIALS_FILE:-}" ] && [ -n "${AWS_SYNC_SHARED_CREDENTIALS_FILE:-}" ]; then
  AWS_SHARED_CREDENTIALS_FILE="${AWS_SYNC_SHARED_CREDENTIALS_FILE}"
fi

if [ -z "${AWS_CONFIG_FILE:-}" ] && [ -n "${AWS_SYNC_CONFIG_FILE:-}" ]; then
  AWS_CONFIG_FILE="${AWS_SYNC_CONFIG_FILE}"
fi

export AWS_PROFILE
export AWS_SHARED_CREDENTIALS_FILE
export AWS_CONFIG_FILE
export AWS_REGION

: "${AWS_PROFILE:?AWS_PROFILE is required}"
: "${AWS_SHARED_CREDENTIALS_FILE:?AWS_SHARED_CREDENTIALS_FILE is required}"
: "${AWS_CONFIG_FILE:?AWS_CONFIG_FILE is required}"
: "${AWS_REGION:?AWS_REGION is required}"

if [ -n "${TABLE:-}" ]; then
  TABLES=("${TABLE}")
else
  read -r -a TABLES <<< "${TABLES:-syslog_iceberg authlog_iceberg}"
fi

log() {
  echo "[INFO] $(date '+%F %T') $*"
}

err() {
  echo "[ERROR] $(date '+%F %T') $*" >&2
}

extract_last_integer() {
  awk '
    {
      gsub(/^[[:space:]]+|[[:space:]]+$/, "", $0)
      if ($0 ~ /^[0-9]+$/) val=$0
    }
    END {
      if (val == "") exit 1
      print val
    }
  '
}

run_count() {
  local sql="$1"
  local out

  out="$("${SPARK_SQL_BIN}" -S -e "${sql}")"
  printf '%s\n' "${out}" | extract_last_integer
}

export_table() {
  local table="$1"
  local src_table="${SOURCE_CATALOG}.${SOURCE_DB}.${table}"
  local dest_table="${DEST_CATALOG}.${DEST_DB}.${table}"
  local src_count
  local dest_count

  log "start export: TABLE=${table}, DT=${DT}"
  log "SOURCE=${src_table}"
  log "DEST=${dest_table}"

  src_count="$(run_count "SELECT count(*) FROM ${src_table} WHERE dt = DATE '${DT}'")"
  log "source count=${src_count}"

  "${SPARK_SQL_BIN}" <<SQL
DELETE FROM ${dest_table}
WHERE dt = DATE '${DT}';

INSERT INTO ${dest_table}
SELECT *
FROM ${src_table}
WHERE dt = DATE '${DT}';
SQL

  dest_count="$(run_count "SELECT count(*) FROM ${dest_table} WHERE dt = DATE '${DT}'")"
  log "dest count=${dest_count}"

  if [ "${src_count}" != "${dest_count}" ]; then
    err "count mismatch: TABLE=${table}, source=${src_count}, dest=${dest_count}"
    exit 1
  fi

  log "completed successfully: TABLE=${table}"
}

main() {
  local table

  log "daily export start: DT=${DT}"
  for table in "${TABLES[@]}"; do
    export_table "${table}"
  done
  log "daily export completed successfully: DT=${DT}"
}

main "$@"
EOF

sudo chmod 755 /usr/local/bin/export-iceberg-to-s3

この wrapper は通常ユーザーから起動できますが、内部で spark ユーザーとして自分自身を再実行します。
そのため、通常ユーザーからパスワードなしでこの wrapper だけを spark ユーザーとして実行できるように sudoers を設定します。

実行ユーザー: 通常ユーザー

sudo visudo -f /etc/sudoers.d/iceberg-export
your-user ALL=(spark) NOPASSWD: SETENV: /usr/local/bin/export-iceberg-to-s3

your-user は実際に手動実行する OS ユーザー名に置き換えます。
SETENV は wrapper から AWS_PROFILE や DT などを spark ユーザー側へ渡すために必要です。
systemd service から通常ユーザーで起動する場合も、この sudoers 設定を使います。

このスクリプトでは、エクスポート前に自宅側 Iceberg の対象日付件数を取得し、DELETE + INSERT 後に AWS 側 Iceberg の対象日付件数を取得します。
件数が一致しない場合は count mismatch として終了コード 1 で失敗させます。
対象テーブルは日次または手動バッチでのみ更新する前提です。
同じ DT に対する通常同期、手動再実行、復旧用リストアを同時に動かさないことで、DELETE + INSERT の競合を避けます。

確認している内容です。

source count = hive_prod.logs.<table> の対象 DT 件数
dest count   = glue_prod.<GLUE_DATABASE>.<table> の対象 DT 件数

実行ログでは次のように確認できます。

[INFO] source count=12345
[INFO] dest count=12345
[INFO] completed successfully

実行例です。

実行ユーザー: 通常ユーザー

DT=2026-08-18 export-iceberg-to-s3

DT 未指定時は前日を対象にします。

export-iceberg-to-s3

通常は syslog_iceberg と authlog_iceberg を順番に同期します。
片方だけ再実行したい場合は TABLE を指定します。

TABLE=authlog_iceberg DT=2026-08-18 export-iceberg-to-s3

手動実行は foreground の Spark SQL job として動くため、SSH セッションをログオフすると途中で停止する可能性があります。
長時間実行する場合やログオフ後も継続したい場合は、後続の systemd service / timer から実行します。


冪等性確認

同じ日付で2回実行します。

実行ユーザー: 通常ユーザー

TABLE=syslog_iceberg \
  DT=2026-08-18 \
  export-iceberg-to-s3

TABLE=syslog_iceberg \
  DT=2026-08-18 \
  export-iceberg-to-s3

再実行後も件数が増えていなければ OK です。

SELECT count(*)
FROM glue_prod.${GLUE_DATABASE}.syslog_iceberg
WHERE dt = DATE '2026-08-18';

authlog_iceberg も同じ方法で確認します。Spark SQL に直接貼り付ける場合は、${GLUE_DATABASE} を実際の database 名に置き換えてください。


失敗時の扱い

PoC では、各 table を独立して再実行できる構成にしています。

例えば次の状態になったとします。

syslog成功
authlog失敗

この場合は、失敗した authlog_iceberg だけを再実行します。

実行ユーザー: 通常ユーザー

TABLE=authlog_iceberg \
  DT=2026-08-18 \
  export-iceberg-to-s3

DELETE + INSERT 方式なので、同じ DT を指定して再実行できます。
ただし、同じ DT の同期 job を並列実行しないことが前提です。
手動再実行する場合は、systemd timer の実行中でないこと、また復旧用リストアを同時に実行していないことを確認します。


自動実行

既存環境への影響が少ないように、systemd timer で日次実行します。

実行ユーザー: 通常ユーザー

SERVICE_USER="$(id -un)"
SERVICE_GROUP="$(id -gn)"

sudo tee /etc/systemd/system/export-logs-to-s3.service > /dev/null <<EOF
[Unit]
Description=Export Iceberg logs to S3

[Service]
Type=oneshot
User=${SERVICE_USER}
Group=${SERVICE_GROUP}
EnvironmentFile=/etc/iceberg/aws.env
Environment=SPARK_SQL_BIN=/usr/local/bin/spark-sql-iceberg-aws
ExecStart=/usr/local/bin/export-iceberg-to-s3
EOF

sudo tee /etc/systemd/system/export-logs-to-s3.timer > /dev/null <<'EOF'
[Unit]
Description=Daily export Iceberg logs to S3

[Timer]
OnCalendar=*-*-* 03:30:00
Persistent=true

[Install]
WantedBy=timers.target
EOF

sudo systemctl daemon-reload
sudo systemctl enable --now export-logs-to-s3.timer

実行時刻 03:30 は例です。自宅側の curated / Iceberg 取り込みが完了した後の時刻に変更します。

確認します。

systemctl list-timers export-logs-to-s3.timer
sudo systemctl status export-logs-to-s3.timer --no-pager

systemd から起動した job はログインセッションに紐づかないため、SSH をログオフしても継続します。
また、User=${SERVICE_USER} として登録しているため、root や spark ユーザーではなく通常ユーザーの systemd job として実行されます。
一方で、export-iceberg-to-s3 を SSH 上で直接実行した場合は、その SSH セッションに紐づく foreground process になるため、ログオフで停止する可能性があります。

手動実行する場合です。

sudo systemctl start export-logs-to-s3.service
journalctl -u export-logs-to-s3.service -n 100 --no-pager

Athena / Iceberg特有の注意点

Athena の Iceberg 対応状況は、必ず AWS 公式ドキュメントで確認します。

記事執筆時点で確認したい観点です。

  • Athena が対応している Iceberg table format version
  • Athena で利用できる Iceberg の DML
  • Athena で利用できない Iceberg 機能
  • timestamp 型の扱い
  • TIMESTAMP_NTZ との互換性
  • Glue Catalog と Iceberg metadata の役割分担

記事執筆時点の AWS 公式ドキュメントでは、主に次の点に注意します。

項目 確認ポイント
Iceberg version support Athena 側でサポートされる Iceberg バージョンが明記されているため、Spark 側 Iceberg 1.10.1 で作成したテーブルを Athena で実際に読めるか確認する
Iceberg v2 Athena は Iceberg v2 テーブルを作成・操作するため、AWS 側テーブルは format-version=2 を基本にする
Catalog Athena から利用する Iceberg テーブルは Glue Catalog に登録されている必要がある
File format Athena engine version 3 では Iceberg の Parquet / ORC / Avro が対象。今回の data file は Parquet
timestamp Athena の timestamp はミリ秒精度の扱いに注意する
timestamp without time zone time / timestamp without time zone の表示やフィルタ条件で UTC として扱われる場面があるため、実データで時刻ずれを確認する
Unsupported operation ALTER TABLE SET LOCATION など、Athena で未対応の Iceberg 操作がある

Spark 側では TIMESTAMP_NTZ を使っていても、Athena 側では timestamp without time zone としてどう見えるかを確認します。

Athena から確認する SQL 例です。

SELECT
    ts,
    CAST(ts AS varchar) AS ts_text,
    dt,
    hr
FROM logs.syslog_iceberg
WHERE dt = DATE '2026-08-18'
ORDER BY ts DESC
LIMIT 20;

自宅側 Spark / Trino で見た時刻と Athena で見た時刻に差がないか確認します。

過去に Hive Parquet の TIMESTAMP と Spark の spark.sql.session.timeZone の組み合わせで時刻差異が出たことがあるため、ここは推測で済ませず、実データで確認します。

Iceberg の DELETE / UPDATE / MERGE は engine 側の対応状況にも依存します。Spark で書き込み、Athena で分析する構成では、更新系処理をどちらの engine が担当するかを明確にします。

今回の PoC では、AWS 側 Iceberg への書き込みは Spark が担当し、Athena は主に参照に使います。
また、syslog_iceberg / authlog_iceberg は日次または手動バッチ以外では更新しない前提にします。
Flink や別の常駐 Spark job などが同じテーブルへ継続的に書き込む構成にする場合は、DELETE + INSERT の同時実行制御や競合時の扱いを別途設計します。

参考:


Iceberg maintenance

AWS 側 Iceberg も、運用が続くと metadata や小さい file が増えます。

今後の運用課題として、次を検討します。

  • expire_snapshots
  • remove_orphan_files
  • rewrite_data_files
  • compaction

構築編では必須実装にしませんが、日次同期が安定した後に追加します。


コスト確認

Athena 実行後に Data scanned を確認します。

概算式です。

Athenaクエリ料金 = スキャン量 × Athenaの1TBあたり料金

料金単価は変わる可能性があるため、必ず AWS 公式料金ページを確認します。

S3 側の保存容量は次で確認できます。

実行ユーザー: 通常ユーザー

aws s3 ls \
  s3://${ICEBERG_BUCKET}/warehouse/ \
  --recursive \
  --summarize \
  --profile "${AWS_PROFILE}"

確認する料金項目です。

  • S3 保存料金
  • S3 API request
  • Athena Data scanned
  • Athena query result 保存容量
  • Glue Data Catalog

最新料金は AWS 公式料金ページを確認してください。

参考:


AWS側の最終確認

S3:

aws s3 ls \
  s3://${ICEBERG_BUCKET}/warehouse/${GLUE_DATABASE}/syslog_iceberg/ \
  --recursive \
  --profile "${AWS_PROFILE}"

aws s3 ls \
  s3://${ICEBERG_BUCKET}/warehouse/${GLUE_DATABASE}/authlog_iceberg/ \
  --recursive \
  --profile "${AWS_PROFILE}"

Glue:

aws glue get-table \
  --region "${AWS_REGION}" \
  --database-name "${GLUE_DATABASE}" \
  --name syslog_iceberg \
  --profile "${AWS_PROFILE}"

aws glue get-table \
  --region "${AWS_REGION}" \
  --database-name "${GLUE_DATABASE}" \
  --name authlog_iceberg \
  --profile "${AWS_PROFILE}"

Athena:

SELECT count(*) FROM logs.syslog_iceberg;
SELECT count(*) FROM logs.authlog_iceberg;

Workgroup:

aws athena get-work-group \
  --region "${AWS_REGION}" \
  --work-group "${ATHENA_WORKGROUP}" \
  --profile "${AWS_PROFILE}"

最終構成図


後片付け

検証終了後、不要な料金が継続しないように削除します。

以下の記事のAWS環境を削除する参考に不要なものを削除してください。


まとめ

これで、自宅 HDFS 上の Iceberg テーブルを AWS S3 側の Iceberg テーブルへ複製し、Glue Data Catalog と Athena から分析できるようになりました。

今回作った最終形です。

hive_prod.logs.syslog_iceberg
hive_prod.logs.authlog_iceberg
        ↓
Spark + Iceberg
        ↓
glue_prod.${GLUE_DATABASE}.syslog_iceberg
glue_prod.${GLUE_DATABASE}.authlog_iceberg
        ↓
Amazon S3
        ↓
AWS Glue Data Catalog
        ↓
Amazon Athena
        ↓
Dedicated Athena Workgroup

次の検証テーマは、同じログを対象にした比較です。

自宅 HDFS + Iceberg + Trino
vs
AWS S3 + Iceberg + Athena

性能、運用負荷、コスト、検索開始までの手軽さを比較すると、自宅ラボとクラウド分析環境の使い分けが見えてきそうです。

AWS 側 Iceberg コピーを継続利用する場合は、snapshot、orphan file、compaction、S3 Versioning、Athena スキャン量の運用が必要になります。

Athena で分析できるようになったログを QuickSight / Amazon Quick で可視化する発展編はこちらです。

AWS 側 Iceberg コピーから自宅 HDFS Iceberg へ特定日データを戻す復旧手順はこちらです。

1
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?