今年のAdvent Calendarの内容はSPIFFEが定義するFederation APIについて、SPIREを使ってどのようなものか試してみたいと思います。
SPIFFEについてやFederation APIについては昨年の記事を参考にしてください。
構成
今回の構成としては異なるトラストドメインを持つSPIRE Serverを別々のKubernetes Namespaceにデプロイします。また、それぞれのNamespaceのWorkloadは異なるNodeにデプロイされるようにし、最終的にフェデレーションの設定が上手くいけばお互いのWorkload間でSVIDが検証できるというものです。
※ 異なるNamespaceにSPIREをデプロイするような構成は一般的ではないと思います。今回はあくまでフェデレーションの機能を試してみることを目的として構成しています。

インストールと事前準備
まずは kind などを使って適当にk8s クラスタを作成します。
❯ cat cluster.yaml
kind: Cluster
apiVersion: kind.sigs.k8s.io/v1alpha3
kubeadmConfigPatches:
- |
apiVersion: kubeadm.k8s.io/v1beta2
kind: ClusterConfiguration
metadata:
name: config
apiServer:
extraArgs:
"service-account-key-file": "/etc/kubernetes/pki/sa.pub"
"service-account-signing-key-file": "/etc/kubernetes/pki/sa.key"
"service-account-issuer": "api"
"service-account-api-audiences": "api,spire-server"
nodes:
- role: control-plane
- role: worker
- role: worker
❯ kind create cluster --image kindest/node:v1.17.0 --name spire-federation --config cluster.yaml
Creating cluster "spire-federation" ...
✓ Ensuring node image (kindest/node:v1.17.0) 🖼
✓ Preparing nodes 📦📦📦📦
✓ Creating kubeadm config 📜
✓ Starting control-plane 🕹️
✓ Installing CNI 🔌
✓ Installing StorageClass 💾
✓ Joining worker nodes 🚜
Cluster creation complete. You can now use the cluster with:
export KUBECONFIG="$(kind get kubeconfig-path --name="spire-federation")"
kubectl cluster-info
❯ kubectl get nodes
NAME STATUS ROLES AGE VERSION
spire-federation-control-plane Ready master 88s v1.17.0
spire-federation-worker Ready <none> 57s v1.17.0
spire-federation-worker2 Ready <none> 57s v1.17.0
今回の検証用に一方のWorkerにはNamespace=foo用のPodをデプロイするためのTaintの設定を、もう一方にはNamespace=bar用の設定をします。
❯ kubectl taint nodes spire-federation-worker app=foo:NoSchedule
node/spire-federation-worker tainted
❯ kubectl taint nodes spire-federation-worker2 app=bar:NoSchedule
node/spire-federation-worker2 tainted
また、今回はSPIREのUpstreamCAとして静的なルートCA証明書を使う(disk
pluginを利用する)ため、以下を参考にそれぞれのトラストドメイン用のCA証明書を発行します。
今回はNamecpace=fooにデプロイするSPIREにはfoo.example.com
を、Namespace=barにはbar.example.com
をトラストドメインとして設定したいのでそれぞれ異なるルートCA証明書を発行します。
❯ cat <<EOF > foo-ca.json
{
"CN": "foo root",
"key": {
"algo": "ecdsa",
"size": 256
},
"names": [
{
"C": "JP",
"L": "Tokyo",
"O": "SPIRE",
"OU": "CA"
}
]
}
❯ cfssl gencert -initca foo-ca.csr | cfssljson -bare foo-ca
SPIRE Serverのデプロイ
次にSPIRE Serverを以下のような設定ファイルでk8sクラスタにデプロイしていきます。設定ファイルについてはserver.experimental
の部分がフェデレーションのキモになります。自身のbundleエンドポイントの設定と、フェデレーション先のbundleエンドポイントの情報を設定しています。
k8sにデプロイするためのマニフェストファイルやその他設定については spire-examples を参考にしてください。また、その際には先程設定したTaintsに対するTolerationsをPodのspecに設定します。
Namespace=fooにデプロイするSPIREのPodに設定するTolerationsの例
spec:
tolerations:
- key: "app"
operator: "Equal"
value: "foo"
effect: "NoSchedule"
Namespace=fooにデプロイするSPIREの設定ファイルの例
server {
bind_address = "0.0.0.0"
bind_port = "8081"
trust_domain = "foo.example.com"
data_dir = "/run/spire/data"
log_level = "DEBUG"
svid_ttl = "1h"
upstream_bundle = true
experimental {
bundle_endpoint_enabled = true
bundle_endpoint_port = 8443
federates_with "bar.example.com" {
bundle_endpoint_address = "spire-server.bar.svc.cluster.local"
bundle_endpoint_port = 8443
bundle_endpoint_spiffe_id = "spiffe://bar.example.com/spire/server"
}
}
}
plugins {
...省略...
UpstreamCA "disk" {
plugin_data {
ttl = "12h"
key_file_path = "/run/spire/secrets/bootstrap.key" #先程発行したfoo用の秘密鍵
cert_file_path = "/run/spire/config/bootstrap.crt" #先程発行したfoo用のCA証明書
}
}
}
フェデレーションの設定
フェデレーションするにあたって、現状は初回のみ相手のbundleエンドポイントにhttpsで接続するためのCA証明書を設定する必要があります。CA証明書はSPIRE ServerのUpstreamCAの設定によりますが、先程記載したSPIRE Serverの設定例のように事前に発行したCA証明書を設定している場合、設定するCA証明書はそれと同じ証明書となります。このとき、セットするのは相手のCA証明書(Namespace=fooのSPIREに設定するのはNamespace=barのSPIREに設定したCA証明書)であることに注意です。一度接続が成功すると以降は自動でローテーションしてくれるようです。
❯ kubectl exec -it -n foo spire-server-0 sh
# export BUNDLE_FILE=$(mktemp)
# cat <<EOF > $BUNDLE_FILE
-----BEGIN CERTIFICATE-----
...省略...
-----END CERTIFICATE-----
EOF
# /opt/spire/bin/spire-server bundle set --id spiffe://bar.example.com --path $BUNDLE_FILE
無事にフェデレーションが成功すると以下のコマンドでフェデレーション先の証明書バンドルが取得できます。
# /opt/spire/bin/spire-server experimental bundle list
****************************************
* spiffe://bar.example.com
****************************************
{
"keys": [
{
"use": "x509-svid",
"kty": "EC",
"crv": "P-256",
"x": "f1uyyj6...省略...TyBlL6pxwg0k",
"y": "uJXLJ58...省略...LcyiMOgjP4S4",
"x5c": [
"MIIB3j...省略...ojQAtjC8EMUtPg="
]
},
{
"use": "jwt-svid",
"kty": "EC",
"kid": "quGwcMt...省略...gJFZBaCkU9vL",
"crv": "P-256",
"x": "bY1k4N...省略...lsH13JTGzslJe00",
"y": "TIMoI8...省略...UAqftiisZ6IISAQ"
}
],
"spiffe_refresh_hint": 15768000
}
SPIRE Serverでは以下のようなログが出力されるようです。
time="2019-12-17T00:29:32Z" level=info msg="Bundle refreshed" subsystem_name=bundle_client trust_domain=bar.example.com
time="2019-12-17T00:29:32Z" level=debug msg="Scheduling next bundle refresh" at="2020-01-31T15:29:32Z" subsystem_name=bundle_client trust_domain=bar.example.com
Namespace=fooとbarの両方でフェデレーションの設定が正しくできたことを確認できたらServerの設定は一旦終わりです。
ここからはフェデレーションによって実際に異なるトラストドメイン間でSVIDの検証が行えることを確認していきます。
Agentのデプロイ
SPIRE AgentのデプロイについてもServerと同様に spire-example を参考にして foo
, bar
それぞれのNamespaceにデプロイします。Tolerationsも忘れずに設定します。
❯ kubectl get pods -n foo -o wide
NAME READY STATUS RESTARTS AGE IP NODE NOMINATED NODE READINESS GATES
spire-agent-64s95 1/1 Running 0 3m9s 172.17.0.3 spire-federation-worker <none> <none>
spire-server-0 1/1 Running 0 67m 10.244.1.3 spire-federation-worker <none> <none>
❯ kubectl get pods -n bar -o wide
NAME READY STATUS RESTARTS AGE IP NODE NOMINATED NODE READINESS GATES
spire-agent-xvlbj 1/1 Running 0 12s 172.17.0.2 spire-federation-worker2 <none> <none>
spire-server-0 1/1 Running 0 5m47s 10.244.2.4 spire-federation-worker2 <none> <none>
ワークロードのデプロイ
テスト用のワークロードとしてNamespace=fooには簡単なHTTP Serverをデプロイし、Namespace=barのPodからアクセスしてみたいと思います。Namespace=fooにはHTTP ServerのSidecar Proxyとしてghostunnelを利用します。ghostunnelは起動時にSPIREのWorkload APIと通信してPodに割り当てられたSVIDを取得します。Namespace=barのワークローも同様にclientモードのghostunnelをSidecar Proxyにした適当なPodを作成します。
フェデレーションを利用する場合、ワークロード用のRegistration Entryを作成する際のポイントとしては federatesWith
でワークロードがフェデレーションを許可するトラストドメインを指定することが必要になります。
Namespace=fooのRegistration Entry登録例
(foo spire)# /opt/spire/bin/spire-server entry create \
--spiffeID spiffe://foo.example.com/spire-agent \
--selector k8s_psat:agent_ns:foo \
--node
(foo spire)# /opt/spire/bin/spire-server entry create \
--parentID spiffe://foo.example.com/spire-agent \
--spiffeID spiffe://foo.example.com/http-server \
--selector k8s:pod-label:app:http-server \
--dns http-server \
--dns http-server.foo.svc.cluster.local \
--federatesWith spiffe://bar.example.com
Namespace=barのRegistration Entry登録例
(bar spire)# /opt/spire/bin/spire-server entry create \
--spiffeID spiffe://bar.example.com/spire-agent \
--selector k8s_psat:agent_ns:bar \
--node
(bar spire)# /opt/spire/bin/spire-server entry create \
--parentID spiffe://bar.example.com/spire-agent \
--spiffeID spiffe://bar.example.com/client \
--selector k8s:pod-label:app:client \
--federatesWith spiffe://foo.example.com
Namespace=fooにデプロイするマニフェスト例
apiVersion: apps/v1
kind: Deployment
metadata:
name: http-server
namespace: foo
labels:
app: http-servrer
spec:
replicas: 1
selector:
matchLabels:
app: http-server
template:
metadata:
labels:
app: http-server
spec:
tolerations:
- key: "app"
operator: "Equal"
value: "foo"
effect: "NoSchedule"
containers:
- name: ghostunnel
image: squareup/ghostunnel:v1.5.2
args:
- "server"
- "--listen=0.0.0.0:8443"
- "--target=127.0.0.1:8080"
- "--use-workload-api-addr=unix:///run/spire/sockets/agent.sock"
- "--allow-uri=spiffe://bar.example.com/client"
ports:
- containerPort: 8443
volumeMounts:
- name: spire-agent-socket
mountPath: /run/spire/sockets
readOnly: false
- name: http-server
image: python:3.8.0-alpine
args: ["python", "-m", "http.server", "8080"]
ports:
- containerPort: 8080
volumes:
- name: spire-agent-socket
hostPath:
path: /run/spire/sockets
type: DirectoryOrCreate
---
apiVersion: v1
kind: Service
metadata:
name: http-server
namespace: foo
spec:
type: NodePort
ports:
- name: https
port: 18443
targetPort: 8443
protocol: TCP
selector:
app: http-server
Namespace=barにデプロイするマニフェスト例
apiVersion: apps/v1
kind: Deployment
metadata:
name: client
namespace: bar
labels:
app: client
spec:
replicas: 1
selector:
matchLabels:
app: client
template:
metadata:
labels:
app: client
spec:
tolerations:
- key: "app"
operator: "Equal"
value: "bar"
effect: "NoSchedule"
containers:
- name: ghostunnel
image: squareup/ghostunnel:v1.5.2
args:
- "client"
- "--listen=0.0.0.0:8080"
- "--target=http-server.foo.svc.cluster.local:18443"
- "--use-workload-api-addr=unix:///run/spire/sockets/agent.sock"
- "--verify-uri=spiffe://foo.example.com/http-server"
- "--unsafe-listen"
volumeMounts:
- name: spire-agent-socket
mountPath: /run/spire/sockets
readOnly: false
- name: sleep
image: alpine
command: ['sh', '-c', 'echo Hello Kubernetes! && sleep 3600']
volumes:
- name: spire-agent-socket
hostPath:
path: /run/spire/sockets
type: DirectoryOrCreate
異なるトラストドメイン間での認証
Namespace=barにデプロイしたPodからNamespace=fooのHTTP Serverにアクセスしてみます。今回の構成ではクライアントはghostunnelを経由してアクセスします。ghostunnelはお互いにmTLSで通信し、その際にSPIREから発行されたSVIDを使って認証しています。
Namespace=fooとbarは異なるトラストドメインのため、通常はお互いの証明書を検証できずに通信が失敗します。今回はフェデレーションを有効にしているため、お互いのトラストドメインのCA証明書バンドルを交換済みであり、ghostunnelはWorkload API経由でSVIDを取得する際にフェデレーションが許可されたトラストドメインの証明書バンドルを手にいれることができます。
$ kubectl exec -it -n bar ${POD_NAME} sh
# curl --silent http://localhost:8080/ | head
<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01//EN" "http://www.w3.org/TR/html4/strict.dtd">
<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
<title>Directory listing for /</title>
</head>
<body>
<h1>Directory listing for /</h1>
<hr>
<ul>
フェデレーションしていない場合
念のためフェデレーションしていない場合に通信が失敗することを確認してみます。
先程作成したRegistration Entryを一度削除し、以下のようにフェデレーションの設定をせずに再登録します。
登録された結果に FederatesWith
の値が含まれていない点を確認してください。
# /opt/spire/bin/spire-server entry show
Found 2 entries
Entry ID : cdb445de-e14d-44d0-af7b-b9aecb677331
SPIFFE ID : spiffe://foo.example.com/http-server
Parent ID : spiffe://foo.example.com/spire-agent
TTL : 3600
Selector : k8s:pod-label:app:http-server
DNS name : http-server
DNS name : http-server.foo.svc.cluster.local
Entry ID : 06f327bd-cdc5-464c-b315-fef71dc5726b
SPIFFE ID : spiffe://foo.example.com/spire-agent
Parent ID : spiffe://foo.example.com/spire/server
TTL : 3600
Selector : k8s_psat:agent_ns:foo
# /opt/spire/bin/spire-server entry show
Found 2 entries
Entry ID : 0b260337-da76-49b3-98bb-1c169a4e4711
SPIFFE ID : spiffe://bar.example.com/client
Parent ID : spiffe://bar.example.com/spire-agent
TTL : 3600
Selector : k8s:pod-label:app:client
Entry ID : ec192d5d-b6b5-4d7a-b368-934e4b7773f7
SPIFFE ID : spiffe://bar.example.com/spire-agent
Parent ID : spiffe://bar.example.com/spire/server
TTL : 3600
Selector : k8s_psat:agent_ns:bar
そのうえで先程と同じワークロードのマニフェストをそれぞれのNamespaceにデプロイし、クライアントから接続してみます。
❯ kubectl exec -it -n bar ${POD_NAME} sh
# curl http://localhost:8080/
curl: (56) Recv failure: Connection reset by peer
このようにフェデレーションをしていない場合は接続が失敗するようになりました。
Namespace=barのghostunnelでは以下のようにfoo.example.com
のCA証明書が見つからない旨のエラーがでています。
❯ kubectl logs -n bar ${POD_NAME} ghostunnel
...省略...
[1] 2019/12/17 05:26:35.997469 error on dial: no roots for peer trust domain "spiffe://foo.example.com"
Namesapce=fooのghostunnelでは証明書エラーのログが出ています。
❯ kubectl logs -n foo ${POD_NAME} ghostunnel
...省略...
[1] 2019/12/17 05:26:41.381518 error on TLS handshake from 10.244.2.15:33568: remote error: tls: bad certificate
まとめ
今回の検証手順はちょっと長くなってしまいしたが、SPIREでフェデレーションする際にはServerの設定でフェデレーションを有効にすることと、Registraion Entryで受け入れるトラストドメインを指定することで、異なるトラストドメインであってもお互いのSVIDを検証することができるようになります。
今年は念願のSPIFFE Meetup Tokyoも開催できたり、SPIFFE/SPIREを少しは知ってもらえる機会を作れたのかなと思っています。Meetup開催に協力して頂いた方、ご参加頂いた方々には感謝しています。来年も引き続き盛り上げていきたいと思いますのでよろしくおねがいします。