AWS IoT Fleet Provisioningでローカルコンテナを自動登録してみた
はじめに
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.pemAmazonRootCA3.pem
今回のプログラムで直接使うのは、claim証明書、秘密鍵、AmazonRootCA1.pemの3つです。公開鍵とAmazonRootCA3.pemは、この構成では読み込んでいません。
秘密鍵は証明書作成時にしか取得できないため、Gitやコンテナイメージには含めず、安全な場所に保存します。AmazonRootCA1.pemは公開情報なので、手元にない場合はAmazon Trust Servicesのリポジトリから再取得できます。
claim証明書は有効化しましたが、Thingにはattachしていません。claim用IoT Policyとの関連付けは、後のprovisioning template作成画面で行います。


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します。


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:Publishとiot:Receiveではtopic/、iot:Subscribeではtopicfilter/を指定しています。また、template名は後で作成する lab-iot-fleet-provisioning と完全に一致させます。
このPolicyには通常のtelemetry Topicを含めていません。

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を追加する余地があります。






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://は付けません。

ローカル側を準備する
ソースコードを取得する
今回使用した最小構成のコードは、次のリポジトリへ置いています。
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.crtとprivate.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に合わせます。今回はSerialNumberの02から、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もログには出していません。



同じ.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が完了しました。新しい証明書も作成されていません。

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



コードでは何をしていたか
実行結果を確認した後で、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名が.envのTHING_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は公開リポジトリへ含めないようにします。







