さくらのクラウド オブジェクトストレージをAmazon S3互換APIで使う ~第2回:S3互換APIをプログラムから呼び出そう~

Amazon S3互換APIを備えた、さくらのクラウドのオブジェクトストレージ。前回の記事では、そのS3互換APIをAWS CLIから呼び出す方法を紹介しました。今回はcurlコマンドや、 AWS SDKを用いてJavaScriptのコードから呼び出す方法を紹介します。

curlコマンドからS3互換APIを呼ぶ

curlはコマンドラインツールの一種で、URLを指定してネットワーク上の場所に対していろいろなプロトコルでアクセスすることができます。

curlコマンドは次のように実行します。

$ curl [オプション] [URL]

ここでもAWS CLIのときと同じように、さくらのストレージであらかじめオブジェクトストレージを利用可能にして、バケットを作成しておきましょう。オブジェクトストレージの基本操作は、以下のマニュアルで確認できます。

オブジェクトの一覧を確認する

それでは、実際の操作を試してみましょう。パケットの一覧を確認するには、次のコマンドを実行します。

# curl -X GET \
 "https://s3.isk01.sakurastorage.jp/sob-sdktest-bucket?list-type=2"

オブジェクトをダウンロードする

オブジェクトをダウンロードするには、次のコマンドを実行します。

# curl -X GET \
 "https://s3.isk01.sakurastorage.jp/sob-sdktest-bucket/curl-test.txt" \
 -o object.txt

オブジェクトをアップロードする

オブジェクトをアップロードするには、次のコマンドを実行します。

# curl -X PUT -T ./localfile.txt \
 'https://s3.isk01.sakurastorage.jp/sob-sdktest-bucket/curl-test.txt?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=...&X-Amz-SignedHeaders=host&X-Amz-Signature=...'

なお、バケットのACLがデフォルトのPrivate(非公開)である場合、オブジェクトをPUTするにはSignature Version 4による署名が必要になります。そのため、ここでは事前に発行した署名付き(Presigned)URLに対してPUTしています。署名付きURLに関しては、記事末の関連記事などを参照してください。

AWS SDK for JavaScriptでS3互換APIを呼ぶ

続いてNode.jsベースのJavaScriptから、AWS SDKを用いてオブジェクトストレージを呼び出してみましょう。

まず、Node.jsが動作する環境に、AWSのオブジェクトストレージサービスであるS3を操作するSDK(ソフトウェア開発キット)をインストールします。ここではサンプルで署名付きURLを扱っているので、そのためのSDKもインストールしています。

# npm install @aws-sdk/client-s3 @aws-sdk/s3-request-presigner

環境変数に、さくらのクラウドのオブジェクトストレージにアクセスするための情報を設定します。

# export AWS_ACCESS_KEY_ID=あなたのアクセスキー
# export AWS_SECRET_ACCESS_KEY=あなたのシークレットキー

Node.jsによるサンプルコード

次のコードでは、オブジェクトストレージのS3互換APIそれぞれの操作に対応した関数を定義して、呼び出しています。エンドポンントのURLやバケット名を「基本設定」の定数として設定しています。それぞれ自身の環境に照らし合わせて設定してください。

// s3-test.mjs
import {
  S3Client,
  ListObjectsV2Command,
  GetObjectCommand,
  PutObjectCommand,
  DeleteObjectCommand,
} from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
import { SignatureV4 } from "@smithy/signature-v4";
import { HttpRequest } from "@smithy/protocol-http";
import { Sha256 } from "@aws-crypto/sha256-js";
import { NodeHttpHandler } from "@smithy/node-http-handler";
import fs from "fs";
import path from "path";
import os from "os";
import crypto from "crypto";
import { fileURLToPath } from "url";

/* ========= 基本設定 ========= */
const bucketName = "sob-sdktest-bucket";
const region = "jp-north-1";
const endpoint = "https://s3.isk01.sakurastorage.jp";

/* ========= __dirname 相当(ESM) ========= */
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const resolvePath = (p) => (path.isAbsolute(p) ? p : path.join(__dirname, p));

/* ========= 環境変数チェック ========= */
for (const k of ["AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY"]) {
  if (!process.env[k] || !process.env[k].trim()) {
    throw new Error(`環境変数 ${k} が未設定です。export してから実行してください。`);
  }
}
const baseCreds = {
  accessKeyId: process.env.AWS_ACCESS_KEY_ID.trim(),
  secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY.trim(),
  ...(process.env.AWS_SESSION_TOKEN?.trim()
    ? { sessionToken: process.env.AWS_SESSION_TOKEN.trim() }
    : {}),
};

/* ========= 共通:S3クライアント(List/Get/Delete/署名URL など) ========= */
const s3 = new S3Client({
  region,
  endpoint,
  forcePathStyle: true,               // S3互換で安定
  requestChecksumCalculation: "NEVER",// aws-chunked/CRCトレーラ無効
  requestHandler: new NodeHttpHandler({
    connectionTimeout: 15_000,       // 接続タイムアウト
    requestTimeout: 60_000,          // リクエスト全体のタイムアウト
  }),
});

/* ========= RAW: DeleteObjects(POST /{bucket}?delete) ========= */
/** keys: string[] を 1 リクエストで削除 */
async function deleteObjectsRaw(keys) {
  const escapeXml = (s) =>
    s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
  const xml =
    `<?xml version="1.0" encoding="UTF-8"?>` +
    `<Delete>` +
    keys.map((k) => `<Object><Key>${escapeXml(k)}</Key></Object>`).join("") +
    `</Delete>`;

  const bodyBuf = Buffer.from(xml, "utf8");
  const md5b64  = crypto.createHash("md5").update(bodyBuf).digest("base64");

  const url = new URL(endpoint);
  const req = new HttpRequest({
    protocol: url.protocol,
    hostname: url.hostname,
    port: url.port ? Number(url.port) : undefined,
    method: "POST",
    path: `/${bucketName}`,     // path-style
    query: { delete: "" },      // ?delete
    headers: {
      Host: url.hostname,
      "Content-Type": "text/xml",
      "Content-MD5": md5b64,
      "Content-Length": String(bodyBuf.length),
    },
    body: bodyBuf,
  });

  const signer = new SignatureV4({
    service: "s3",
    region,
    sha256: Sha256,
    credentials: baseCreds,
  });
  const signed = await signer.sign(req);
  const { response } = await s3.config.requestHandler.handle(signed);
  const status = response.statusCode || 0;

  let respBody = Buffer.alloc(0);
  await new Promise((resolve, reject) => {
    const chunks = [];
    response.body.on("data", (c) => chunks.push(Buffer.from(c)));
    response.body.on("end", () => { respBody = Buffer.concat(chunks); resolve(); });
    response.body.on("error", reject);
  });

  if (process.env.DEBUG_S3_MD5 === "1") {
    console.log(`[raw-delete] status=${status} body=${respBody.toString()}`);
  }
  if (status < 200 || status >= 300) {
    throw new Error(`Raw DeleteObjects failed: ${status} ${respBody.toString()}`);
  }
  console.log("DeleteObjects RAW result:", status);
}

/* ========= RAW: 署名付き PUT(Expect:100-continue を出さない) ========= */
async function putObjectRaw(key, filePath) {
  const abs = resolvePath(filePath);
  const data = await fs.promises.readFile(abs);
  const md5b64 = crypto.createHash("md5").update(data).digest("base64");

  // キーのエンコード(/ はそのまま)
  const encKey = key.split("/").map(encodeURIComponent).join("/");

  const url = new URL(endpoint);
  const req = new HttpRequest({
    protocol: url.protocol,
    hostname: url.hostname,
    port: url.port ? Number(url.port) : undefined,
    method: "PUT",
    path: `/${bucketName}/${encKey}`,   // path-style
    headers: {
      Host: url.hostname,
      "Content-Type": "text/plain",
      "Content-MD5": md5b64,
      "Content-Length": String(data.length),
      // "Expect" は付けない(ここで完全制御)
    },
    body: data,
  });

  const signer = new SignatureV4({
    service: "s3",
    region,
    sha256: Sha256,
    credentials: baseCreds,
  });
  const signed = await signer.sign(req);
  const { response } = await s3.config.requestHandler.handle(signed);
  const status = response.statusCode || 0;

  let respBody = Buffer.alloc(0);
  await new Promise((resolve, reject) => {
    const chunks = [];
    response.body.on("data", (c) => chunks.push(Buffer.from(c)));
    response.body.on("end", () => { respBody = Buffer.concat(chunks); resolve(); });
    response.body.on("error", reject);
  });

  if (process.env.DEBUG_S3_MD5 === "1") {
    console.log(`[raw-put] status=${status} body=${respBody.toString()}`);
  }
  if (status < 200 || status >= 300) {
    throw new Error(`Raw PutObject failed: ${status} ${respBody.toString()}`);
  }
  console.log("PutObject RAW result:", status);
}

/* ========== 1. List Objects ========== */
export async function listObjects() {
  const res = await s3.send(new ListObjectsV2Command({ Bucket: bucketName }));
  console.log("ListObjects:", res.Contents);
  return res;
}

/* ========== 2. Get Object ========== */
export async function getObject(key, destPath) {
  // 保存先ディレクトリ作成(権限NGなら /tmp にフォールバック)
  let abs = resolvePath(destPath);
  const ensureDir = (dir) => {
    try {
      fs.mkdirSync(dir, { recursive: true });
      return dir;
    } catch (e) {
      if (e.code === "EACCES") {
        const tmpDir = path.join(os.tmpdir(), "s3-downloads");
        fs.mkdirSync(tmpDir, { recursive: true });
        console.warn(`WARN: ${dir} へ書き込めないため、${tmpDir} に保存します。`);
        return tmpDir;
      }
      throw e;
    }
  };
  const targetDir = ensureDir(path.dirname(abs));
  abs = path.join(targetDir, path.basename(destPath));

  const res = await s3.send(new GetObjectCommand({ Bucket: bucketName, Key: key }));
  await new Promise((resolve, reject) => {
    const out = fs.createWriteStream(abs);
    res.Body.pipe(out);
    res.Body.on("error", reject);
    out.on("finish", resolve);
  });
  console.log(`Downloaded: ${abs}`);
  return abs;
}

/* ========== 3. Put Object(SDK:Buffer一括+ContentMD5) ========== */
export async function putObject(key, filePath) {
  const abs = resolvePath(filePath);
  const data = await fs.promises.readFile(abs);           // Buffer 送信
  const md5b64 = crypto.createHash("md5").update(data).digest("base64");

  const res = await s3.send(
    new PutObjectCommand({
      Bucket: bucketName,
      Key: key,
      Body: data,
      ContentType: "text/plain",
      ContentLength: data.length,                          // 明示
      ContentMD5: md5b64,                                  // 明示
    })
  );
  console.log("PutObject (SDK) result:", res.ETag || res.$metadata?.httpStatusCode);
  return res;
}

/* ========== 4. Delete Object(単体) ========== */
export async function deleteObject(key) {
  await s3.send(new DeleteObjectCommand({ Bucket: bucketName, Key: key }));
  console.log(`Deleted: ${key}`);
}

/* ========== 5. Delete Multiple Objects(RAW を常用) ========== */
export async function deleteObjects(keys) {
  await deleteObjectsRaw(keys);
}

/* ========== 6. Generate PreSigned URL ========== */
export async function generatePresignedUrl(key, expiresInSeconds) {
  const url = await getSignedUrl(
    s3,
    new GetObjectCommand({ Bucket: bucketName, Key: key }),
    { expiresIn: expiresInSeconds }
  );
  console.log("PreSigned URL:", url);
  return url;
}

/* ========== 動作例 ========== */
async function main() {
  try {
    console.log("== List ==");
    await listObjects();

    const local = resolvePath("./localfile.txt");
    if (!fs.existsSync(local)) {
      fs.writeFileSync(local, "hello from localfile\n");
    }

    console.log("== Put (RAW) ==");
    await putObjectRaw("object_node.txt", "./localfile.txt");   // 推奨:RAW

    // SDKで試したい場合は下を有効化し、上をコメントアウト
    // console.log("== Put (SDK Buffer) ==");
    // await putObject("object_node.txt", "./localfile.txt");

    console.log("== Get ==");
    await getObject("object_node.txt", "./downloads/downloaded.txt");

    console.log("== Delete single ==");
    await deleteObject("object_node.txt");

    console.log("== Delete multiple ==");
    await deleteObjects(["object1.txt", "object2.txt"]);

    console.log("== Presigned URL ==");
    await generatePresignedUrl("object_node2.txt", 3600);
  } catch (err) {
    console.error(err);
    process.exitCode = 1;
  }
}
main();

サンプルコードの簡単な解説

上記のサンプルコードでは、オブジェクトのlist・get・put・delete(単体)・delete(複数)・署名付きURLの生成の関数をそれぞれ定義し、main()で順に実行しています。オブジェクトのputでは、ローカル環境からlocalfile.txtを読み込んでいます。

また、複数オブジェクトの削除や署名付きURLの生成を実行するため、コード中で生成していないいくつかのオブジェクト(object1.txt、object2.txt、object_node2.txt)を事前にコントロールパネルから作成しておくとよいでしょう。

このサンプルを実行した際の結果は、例えば以下のようになります。自身でコードを確認・修正しながら試してみることをお勧めします。

$ node s3-test.mjs
== List ==
ListObjects: [
  {
    Key: 'object1.txt',
    LastModified: 2025-11-28T05:54:07.652Z,
    ETag: '"d41d8cd98f00b204e9800998ecf8427e"',
    Size: 0,
    StorageClass: 'STANDARD'
  },
  {
    Key: 'object2.txt',
    LastModified: 2025-11-28T05:54:08.232Z,
    ETag: '"d41d8cd98f00b204e9800998ecf8427e"',
    Size: 0,
    StorageClass: 'STANDARD'
  },
  {
    Key: 'object_node2.txt',
    LastModified: 2025-11-28T05:54:52.061Z,
    ETag: '"d41d8cd98f00b204e9800998ecf8427e"',
    Size: 0,
    StorageClass: 'STANDARD'
  }
]
== Put (RAW) ==
PutObject RAW result: 200
== Get ==
Downloaded: /work/downloads/downloaded.txt
== Delete single ==
Deleted: object_node.txt
== Delete multiple ==
DeleteObjects RAW result: 200
== Presigned URL ==
PreSigned URL: https://s3.isk01.sakurastorage.jp/sob-sdktest-bucket/object_node2.txt?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Content-Sha256=UNSIGNED-PAYLOAD&X-Amz-Credential=AH9YGJNA3WJDLZ817B5I%2F20251128%2Fjp-north-1%2Fs3%2Faws4_request&X-Amz-Date=20251128T060000Z&X-Amz-Expires=3600&X-Amz-Signature=c171cefdb396e5a4701e2cc502fe94c87451568f2272cee7b5d6b107463d6e7f&X-Amz-SignedHeaders=host&x-id=GetObject

利用上の注意

一部バージョンのAWS CLIおよびAWS SDKにおいて、オブジェクトストレージが正常に利用できないことを確認しています。詳細は、以下のさくらのクラウドニュースをご確認ください。

まとめ

この記事ではcurlコマンドとJavaScriptのコード例を紹介しました。このようにオブジェクトストレージは、プログラムからも簡単に操作できます。

これを機会に、ぜひオブジェクトストレージを試してみてください。

参考情報

さくらのクラウドのオブジェクトストレージに関する情報は以下を参照してください。

JavaScript用のAWS SDKについては次のドキュメントなどを参照してください。

書名付きURLについてはAmazon S3のドキュメントなどを参照してください。

関連記事

「さくらのクラウド オブジェクトストレージをAmazon S3互換APIで使う」シリーズの他の記事です。

変更履歴

  • 2025年12月2日:curlによるオブジェクトのPUTで署名付きURLの説明を追記しました。