AWS IoT Fleet Provisioningでローカルコンテナを自動登録してみた

AWS IoT Fleet Provisioningでローカルコンテナを自動登録してみた

AWS IoT Fleet Provisioningのfeat provisioning by claimを使い、共通のclaim証明書を持つコンテナから、デバイス固有の証明書を自動取得してAWS IoT Coreへ接続する仕組みを試してみました。
2026.08.28

はじめに

IoTデバイスをAWS IoT Coreへ接続するときは、デバイスごとに固有のidentityを持たせ、必要な操作だけを許可する形にすると管理しやすくなります。一方、Thingと証明書を1台ずつ手作業で用意する方法は、台数が増えるほど作業も増えます。

今回はAWS IoT Fleet ProvisioningのProvisioning by claimを使います。共通のclaim証明書を持つローカルLinuxコンテナを初回起動し、Thingとデバイス固有の証明書をAWS側で自動作成してみます。

検証環境

  • macOS Tahoe 26.6.2
  • Podman 5.7.0
  • Node.js 24.12.0
  • pnpm 10.23.0
  • コンテナイメージ: node:24-bookworm-slim
  • AWS IoT Core: ap-northeast-1(東京)

コンテナのbuildと実行にはPodmanを使いました。Containerfile は一般的なコンテナイメージの命令で構成しているため、実行オプションやvolumeの指定を読み替えればDockerでも利用できます。

今回試す構成

Fleet Provisioningでは、最初から各デバイスに固有の証明書を配る代わりに、登録専用のclaim証明書を使えます。デバイスは初回接続時にclaim証明書で認証し、その後の通常接続で使う固有の証明書を取得します。

AWS公式ドキュメントでは、Provisioning devices that don't have device certificates using fleet provisioningにProvisioning by claimの流れがまとまっています。

今回登場するリソースの関係は次のとおりです。

初回登録

[ローカルコンテナ]
  |
  +-- 使用 --> [claim証明書 + 秘密鍵]
  |                |
  |                +-- attach済み --> [claim用IoT Policy]
  |
  +-- CreateKeysAndCertificate --> [デバイス固有証明書 + 秘密鍵]
  |
  +-- RegisterThing --> [provisioning template]
                           |
                           +-- AWS IoT Coreがprovisioning IAM roleを利用
                           +-- Thingを作成
                           +-- デバイス固有証明書を有効化してThingへ関連付け
                           +-- runtime用IoT Policyをデバイス固有証明書へattach

通常通信

[/identityへ保存したデバイス固有証明書]
                 |
                 v
       [ローカルコンテナ] --MQTT Publish--> [AWS IoT Core]

ポイントは、claim用とruntime用のIoT Policyを分けることです。

  • claim証明書: 初回登録だけに使う共通のbootstrap identity
  • claim用IoT Policy: 証明書作成とRegisterThingに必要なMQTT操作だけを許可
  • provisioning template: Thing名、証明書の状態、関連付けるruntime用Policyを定義
  • provisioning IAM role: AWS IoT CoreがThingや証明書の関連付けを行うために利用
  • デバイス固有の証明書: 登録後の通常接続で使うidentity
  • runtime用IoT Policy: Thing名での接続と、そのThing専用TopicへのPublishを許可

claim証明書にruntime用Policyまでattachすると、共通のbootstrap identityで通常のtelemetryを送れるようになってしまいます。今回は2つを明確に分離しました。

AWS側を準備する

claim証明書を作成する

まず、AWS IoT Core > Security > Certificates からclaim用の証明書を作成しました。

証明書作成時の画面から、次の5ファイルを保存しました。

  • claim証明書
  • 公開鍵
  • 秘密鍵
  • AmazonRootCA1.pem
  • AmazonRootCA3.pem

今回のプログラムで直接使うのは、claim証明書、秘密鍵、AmazonRootCA1.pemの3つです。公開鍵とAmazonRootCA3.pemは、この構成では読み込んでいません。

秘密鍵は証明書作成時にしか取得できないため、Gitやコンテナイメージには含めず、安全な場所に保存します。AmazonRootCA1.pemは公開情報なので、手元にない場合はAmazon Trust Servicesのリポジトリから再取得できます。

claim証明書は有効化しましたが、Thingにはattachしていません。claim用IoT Policyとの関連付けは、後のprovisioning template作成画面で行います。

1

2

runtime用IoT Policyを作成する

次に、AWS IoT Core > Security > Policies > Create policy から、登録後のデバイスが使うPolicyを作成しました。

Policy名は lab-iot-device-runtime にします。許可するのは、Thing名と同じClient IDでの接続と、そのThing専用TopicへのPublishだけです。

JSON編集画面に次のPolicyをコピーします。<ACCOUNT_ID>は利用するAWSアカウントIDに置き換えます。

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": "iot:Connect",
      "Resource": "arn:aws:iot:ap-northeast-1:<ACCOUNT_ID>:client/${iot:Connection.Thing.ThingName}"
    },
    {
      "Effect": "Allow",
      "Action": "iot:Publish",
      "Resource": "arn:aws:iot:ap-northeast-1:<ACCOUNT_ID>:topic/factory/line-a/${iot:Connection.Thing.ThingName}/telemetry"
    }
  ]
}

${iot:Connection.Thing.ThingName}を使うことで、デバイスごとにPolicyを作らず、同じPolicyを再利用できます。今回のThing名は lab-iot-machine-02 なので、Client IDにも同じ値を使います。

このPolicyはclaim証明書にはattachしません。provisioning templateが生成後のデバイス証明書へattachします。

3
4

claim用IoT Policyを作成する

同じ Security > Policies からclaim証明書専用のPolicyを作成し、Policy名は lab-iot-fleet-claim にします。

このPolicyは、claim用Client IDでの接続と、次の2種類のFleet Provisioning Topicだけを許可します。

  • $aws/certificates/create/*
  • $aws/provisioning-templates/lab-iot-fleet-provisioning/provision/*

JSON編集画面に次のPolicyをコピーします。ここでも<ACCOUNT_ID>は利用するAWSアカウントIDに置き換えます。

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": "iot:Connect",
      "Resource": "arn:aws:iot:ap-northeast-1:<ACCOUNT_ID>:client/lab-iot-provision-*"
    },
    {
      "Effect": "Allow",
      "Action": [
        "iot:Publish",
        "iot:Receive"
      ],
      "Resource": [
        "arn:aws:iot:ap-northeast-1:<ACCOUNT_ID>:topic/$aws/certificates/create/*",
        "arn:aws:iot:ap-northeast-1:<ACCOUNT_ID>:topic/$aws/provisioning-templates/lab-iot-fleet-provisioning/provision/*"
      ]
    },
    {
      "Effect": "Allow",
      "Action": "iot:Subscribe",
      "Resource": [
        "arn:aws:iot:ap-northeast-1:<ACCOUNT_ID>:topicfilter/$aws/certificates/create/*",
        "arn:aws:iot:ap-northeast-1:<ACCOUNT_ID>:topicfilter/$aws/provisioning-templates/lab-iot-fleet-provisioning/provision/*"
      ]
    }
  ]
}

iot:Connectではclient/iot:Publishiot:Receiveではtopic/iot:Subscribeではtopicfilter/を指定しています。また、template名は後で作成する lab-iot-fleet-provisioning と完全に一致させます。

このPolicyには通常のtelemetry Topicを含めていません。

5

Fleet Provisioning templateを作成する

AWS IoT Core > Connect many devices > Provisioning templates から、デバイス固有の証明書を持たないシナリオ向けのFleet Provisioning templateを作成しました。

template名は lab-iot-fleet-provisioning にします。主な設定は次のとおりです。

  • templateのStatus: Active
  • Claim certificate policy: lab-iot-fleet-claim
  • Claim certificate: 先ほど作成した有効な証明書
  • Automatically create a thing resource: 有効
  • Thing name prefix: lab-iot-machine-
  • Device permissions: lab-iot-device-runtimeだけを選択
  • Pre-provisioning Lambda: 使用しない
  • Thing typeなどのoptional設定: 設定しない

Claim certificate policyではclaim用の lab-iot-fleet-claim を選び、Set device permissionsではruntime用の lab-iot-device-runtime だけを選びます。同じ画面に2つのPolicyが出てきますが、役割は別です。

provisioning用IAM roleには、wizardで作成した lab-iot-fleet-provisioning-role を使いました。このroleはiot.amazonaws.comから信頼され、AWSIoTThingsRegistrationを持ちます。コンテナへ渡すroleではないため、アクセスキーなどはローカルへ置きません。また、AWSIoTFullAccessも付与していません。

今回は1台の動作確認が目的なので、Pre-provisioning Lambdaは使いませんでした。実運用では、serial numberや登録対象リストを検証するhookを追加する余地があります。

7

8

9

10

role

11

AWS IoT endpointを確認する

接続先には、AWS IoT Core > Connect > Domain configurations に表示された、デフォルトの iot:Data-ATS domain nameを使いました。

値は次のような形式です。

xxxxxxxxxxxxxx-ats.iot.ap-northeast-1.amazonaws.com

アカウント固有のendpointは記事やGitへ含めず、ローカルの.envからコンテナへ渡します。https://は付けません。

12

ローカル側を準備する

ソースコードを取得する

今回使用した最小構成のコードは、次のリポジトリへ置いています。

git clone https://github.com/cm-obuchi-hugo-examples/iot-fleet-provisioning-device.git
cd iot-fleet-provisioning-device

主なファイルだけを見ると、次の構成です。

iot-fleet-provisioning-device/
├── src/
│   └── index.ts
├── .env.example
├── Containerfile
├── package.json
├── pnpm-lock.yaml
└── tsconfig.json

証明書、秘密鍵、生成後のデバイスidentityはリポジトリに含めていません。

credentialと永続volumeを用意する

コンテナからは、次の3種類のstorageが見えるようにします。

/bootstrap/                         # read-only
├── claim.pem.crt
└── claim.private.pem.key

/trust/                             # read-only
└── AmazonRootCA1.pem

/identity/                          # persistent and writable
└── initially empty

/bootstrapは初回登録でだけ使用します。/identityは空のvolumeまたはbind mountから始め、生成されたdevice.pem.crtprivate.pem.keyを保存します。

host側の配置場所やvolume名は実行環境に合わせて選べます。重要なのは、claim credentialsをread-onlyで渡すことと、/identityをコンテナ削除後も残るwritableなstorageにすることです。

.envを設定する

.env.exampleをもとに、追跡対象外の.envを作成しました。

AWS_IOT_ENDPOINT=xxxxxxxxxxxxxx-ats.iot.ap-northeast-1.amazonaws.com
THING_NAME=lab-iot-machine-02
FLEET_TEMPLATE_NAME=lab-iot-fleet-provisioning
FLEET_TEMPLATE_PARAMETERS_JSON={"SerialNumber":"02"}

IDENTITY_DIR=/identity
CLAIM_CERT_PATH=/bootstrap/claim.pem.crt
CLAIM_KEY_PATH=/bootstrap/claim.private.pem.key
ROOT_CA_PATH=/trust/AmazonRootCA1.pem

FLEET_TEMPLATE_NAMEへ設定するのはtemplate ARNではなく、template名です。FLEET_TEMPLATE_PARAMETERS_JSONは、active template versionが要求するparameterに合わせます。今回はSerialNumber02から、lab-iot-machine-02が作られる設定です。

コンテナイメージをbuildする

Containerfileでは、TypeScriptをcompileするbuild stageと、生成済みJavaScriptだけを実行するruntime stageを分けています。主要部分だけを抜粋すると、次のようになっています。

# Build stage
FROM node:24-bookworm-slim AS build
WORKDIR /app
RUN corepack enable
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
RUN pnpm install --frozen-lockfile
COPY tsconfig.json ./
COPY src ./src
RUN pnpm build && pnpm prune --prod

# Runtime stage
FROM node:24-bookworm-slim AS runtime
WORKDIR /app
COPY --from=build --chown=node:node /app/package.json ./
COPY --from=build --chown=node:node /app/node_modules ./node_modules
COPY --from=build --chown=node:node /app/dist ./dist
USER node
CMD ["node", "dist/index.js"]

claim証明書や秘密鍵をCOPYしていないことも確認できます。credentialはイメージではなく、実行時のmountから読み込みます。

podman buildを実行すると、build stageで必要なpackageがinstallされ、TypeScriptがdist/index.jsへcompileされます。作成されたイメージには実行に必要なファイルが含まれます。

また、CMD ["node", "dist/index.js"]を設定しているため、podman runではコンテナ内で追加のinstall操作をせず、index.jsが自動で実行されます。

今回はローカル側でも型checkとcompileを確認してから、イメージをbuildしました。

corepack enable
pnpm install
pnpm typecheck
pnpm build
podman build -t iot-fleet-provisioning-device .

コンテナの詳しい作り方が今回の主題ではないため、ここではFleet Provisioningに関係する部分だけ扱います。

コンテナを初回起動する

AWS IoT CoreのMQTT test clientでは、先に次のTopicをSubscribeしておきました。

factory/line-a/+/telemetry

実行時には、claim credentialsとRoot CAをread-only、デバイスidentityの保存先をread-writeでmountします。次の<...>は、それぞれの環境で用意したpathまたはvolumeへ置き換えます。

podman run --rm \
  --name lab-iot-machine-02 \
  --user "$(id -u):$(id -g)" \
  --env-file .env \
  -v "<bootstrap-directory>:/bootstrap:ro" \
  -v "<root-ca-file>:/trust/AmazonRootCA1.pem:ro" \
  -v "<persistent-identity-storage>:/identity:rw" \
  iot-fleet-provisioning-device

実行結果

実際のコンテナ出力は次のとおりです。

Provisioning lab-iot-machine-02 with the claim certificate...
Provisioned lab-iot-machine-02; production credentials saved.
Connecting as lab-iot-machine-02 with production credentials...
Published one message to factory/line-a/lab-iot-machine-02/telemetry.

AWS IoT Coreの All devices > Things では、事前に存在しなかった lab-iot-machine-02 が作成されていることを確認できました。

このログから、claim証明書を使ったprovisioning接続、RegisterThingの完了、生成credentialの保存、デバイス固有の証明書を使った再接続、telemetryの1回のPublishまで進んだことが分かります。

コンテナへIAM credentialは渡していません。また、秘密鍵やcertificate ownership tokenもログには出していません。

13

15

16

同じ.env/identityを使い、claim証明書と秘密鍵のmountを外して新しいコンテナを起動すると、次の結果になりました。

Existing production credentials found; skipping provisioning.
Connecting as lab-iot-machine-02 with production credentials...
Published one message to factory/line-a/lab-iot-machine-02/telemetry.
Device finished.

Provisioningは実行されず、保存済みのデバイス証明書で再接続とPublishが完了しました。新しい証明書も作成されていません。

14

同じ手順でもう一台を登録

同じ手順で、新しいThingを無事に登録できることも確認できました。
17
18
19

コードでは何をしていたか

実行結果を確認した後で、src/index.tsの主要処理を見てみます。

claim証明書で固有credentialを取得する

初回起動では、claim証明書でMQTT5接続した後にCreateKeysAndCertificateを呼びます。AWSから返される証明書、秘密鍵、短時間だけ有効なownership tokenはログへ出しません。

続けてRegisterThingへtemplate名、ownership token、template parameterを渡します。

// Step 1: ask AWS to mint this device's unique certificate and private key.
// Never log the response because it contains private credential material.
const created = await identity.createKeysAndCertificate({});
if (
  !created.certificatePem ||
  !created.privateKey ||
  !created.certificateOwnershipToken
) {
  throw new Error("AWS returned an incomplete certificate response");
}

// Step 2: exchange the short-lived ownership token through the provisioning
// template. AWS creates the Thing and attaches the runtime policy.
const registered = await identity.registerThing({
  templateName,
  certificateOwnershipToken: created.certificateOwnershipToken,
  parameters: templateParameters,
});

// Step 3: persist the generated production identity outside the container.
await saveDeviceCredentials(created.certificatePem, created.privateKey);

RegisterThingの結果に含まれるThing名が.envTHING_NAMEと違う場合は、そこで停止するようにしました。

credentialを永続化する

生成された証明書と秘密鍵は、一時ファイルへ書き込んでからrenameしています。保存先はコンテナ外へ永続化した/identityです。

const temporary = `${path}.${randomUUID()}.tmp`;
const handle = await open(temporary, "wx", 0o600);
try {
  await handle.writeFile(content, "utf8");
  await handle.sync();
} finally {
  await handle.close();
}
try {
  await rename(temporary, path);
  await chmod(path, 0o600);
} catch (error) {
  await rm(temporary, { force: true });
  throw error;
}

この最小構成のサンプルでは、証明書作成後から2ファイルの保存完了までにprocessが停止した場合のreconciliationまでは実装していません。そのため、学習用のhappy pathを確認するコードとして扱っています。

デバイス固有の証明書でPublishする

claim接続を閉じた後は、保存したデバイス証明書と秘密鍵で接続し直します。Client IDにはThing名を使い、そのThing専用のTopicへtelemetryを1件Publishします。

const client = await connect(deviceCert, deviceKey, thingName);

await client.publish({
  topicName: `factory/line-a/${thingName}/telemetry`,
  qos: mqtt5.QoS.AtLeastOnce,
  payload: JSON.stringify({
    thingName,
    observedAt: new Date().toISOString(),
    message: "hello from local container",
  }),
});

QoS 1(At Least Once)を指定しているため、Publishの完了を待ってから接続を閉じます。

2回目以降はclaim証明書を使わない

起動時には、/identityに証明書と秘密鍵の両方があるかを確認します。両方あればprovisioningをskipし、そのままruntime接続へ進む構成です。

const [hasCert, hasKey] = await Promise.all([
  exists(deviceCert),
  exists(deviceKey),
]);

if (hasCert !== hasKey) {
  throw new Error(
    "Only one production credential file exists; stop and investigate",
  );
}

if (!hasCert) {
  await provision();
} else {
  console.log("Existing production credentials found; skipping provisioning.");
}

await publish();

片方だけがある状態では、別の証明書を重ねて発行せず停止します。今回の再起動テストでは両方のファイルが見つかり、claim mountなしでprovisioningをskipすることを確認できました。

おわりに

ローカルのLinuxコンテナを起動し、事前にThingやデバイス固有証明書を手作業で用意せずに、lab-iot-machine-02を登録できました。再起動時には保存済みのidentityが再利用され、claim証明書なしでもPublishできました。

一番分かりにくかったのは、claim用IoT Policy、runtime用IoT Policy、provisioning IAM roleの違いです。それぞれを「初回登録」「通常通信」「AWS IoT Coreによるリソース作成」に分けて考えると、設定を整理しやすくなりました。

コンテナを使わず、.envのcredential pathをローカル向けに変更してNode.jsから直接起動する構成にもできます。ただし、今回はPodmanコンテナからの実行だけを確認しています。

検証後に追加のdevice provisioningを行わない場合は、共通のclaim証明書を無効化します。秘密鍵、生成されたデバイスcredential、アカウント固有endpointは公開リポジトリへ含めないようにします。

この記事をシェアする

AWSのお困り事はクラスメソッドへ

関連記事