Istio Deep Dive — Why It Flows That Way
Mint SPIFFE certificates and build STRICT and PERMISSIVE chains
한국어 원문으로 표시합니다.
목표
PeerAuthentication 을 쓰고, openssl 로 메시 CA 와 SPIFFE 인증서를 만들고, 각 모드가 되는 Envoy 필터 체인을 손으로 세워 평문과 mTLS 가 어떻게 갈리는지 확인한 뒤, 인증서의 SAN 에서 AuthorizationPolicy 의 principal 을 끌어낸다.
왜 중요한가
STRICT 로 바꾼 날 끊기는 호출, 포트 하나만 평문으로 남기려다 조용히 무시되는 정책, principal 을 잘못 적어 모두가 막히는 인가 정책 — 셋 다 istioctl validate 는 통과한다. 모드가 Envoy 의 어떤 체인이 되고 신원이 인증서의 어디에서 오는지 알면 통계 한 줄과 인증서 한 장으로 원인을 짚을 수 있다.
단계
/root/ist2-mtls/pa.yaml에 PeerAuthentication 을 쓰세요 —apiVersion: security.istio.io/v1, 이름reviews-strict, 네임스페이스default,selector.matchLabels.app: reviews,mtls.mode: STRICT.istioctl validate -f pa.yaml의 출력과 종료 코드를/root/ist2-mtls/01-validate.txt에 담으세요(마지막 줄rc=)./root/ist2-mtls에서 openssl 로 자체 서명 CA 한 장(ca.crt·ca.key)을 만들고, 그 CA 로 서명한 인증서 세 장을 만드세요 —reviews.crt/reviews.key(서버),orders.crt/orders.key(허용할 클라이언트),intruder.crt/intruder.key(같은 CA 가 서명했지만 허용하지 않을 클라이언트). 각 인증서의 SAN 은 URI 하나,spiffe://cluster.local/ns/default/sa/<이름>입니다. 그다음 인증서에서 실제로 읽은 SAN 을/root/ist2-mtls/02-ids.txt에reviews.crt=…,orders.crt=…,intruder.crt=…세 줄로 적으세요./root/ist2-mtls/strict.yaml에 Envoy 설정을 쓰세요 — 관리 포트9986, 리스너virtualInbound가127.0.0.1:10086에서 듣고 리스너 필터로envoy.filters.listener.tls_inspector를 둡니다. 필터 체인은 하나,filter_chain_match.transport_protocol: tls이고DownstreamTlsContext에require_client_certificate: true, 서버 인증서/root/ist2-mtls/reviews.crt·/root/ist2-mtls/reviews.key, 신뢰 CA/root/ist2-mtls/ca.crt를 줍니다(SAN 매처는 아직 넣지 않습니다). 그 체인은 모든 경로를 클러스터inbound|8109||(127.0.0.1:8109)로 보냅니다. 업스트림을8109에ok로 띄우고 Envoy 를 띄운 뒤/root/ist2-mtls/03-strict.txt에 네 줄을 적으세요 —plain=(평문http://localhost:10086/strict의 코드),mtls=(orders 인증서로https://localhost:10086/strict의 코드),mtls_body=(그 응답 본문),no_filter_chain_match=(통계listener.127.0.0.1_10086.no_filter_chain_match의 값)./root/ist2-mtls/strict.yaml을/root/ist2-mtls/strict-san.yaml로 복사한 뒤 그 사본에만validation_context에match_typed_subject_alt_names를 하나 더하세요 —san_type: URI,matcher.exact는 2단계에서 읽은orders.crt의 SAN 그대로. 그 설정으로 Envoy 를 다시 띄우고/root/ist2-mtls/04-san.txt에 세 줄을 적으세요 —orders=(orders 인증서로https://localhost:10086/san의 코드),intruder=(intruder 인증서로 같은 요청의 코드),fail_verify_san=(통계listener.127.0.0.1_10086.ssl.fail_verify_san의 값)./root/ist2-mtls/strict.yaml을 바탕으로/root/ist2-mtls/permissive.yaml을 만드세요 — 3단계의 TLS 체인은 그대로 두고,filter_chain_match도transport_socket도 없는 평문 체인을 하나 더합니다(같은 클러스터inbound|8109||로). 그 설정으로 Envoy 를 다시 띄우고/root/ist2-mtls/05-permissive.txt에 세 줄을 적으세요 —plain=(평문http://localhost:10086/permissive의 코드),plain_body=(그 본문),mtls=(orders 인증서로https://localhost:10086/permissive의 코드)./root/ist2-mtls/pa-port.yaml에 문서 두 개를 쓰세요 — (1) Servicereviews(네임스페이스default, selectorapp: reviews,port: 80→targetPort: 10096), (2) PeerAuthenticationreviews-port(같은 selector,mtls.mode: STRICT,portLevelMtls로 한 포트만DISABLE). 그 포트 번호를 무엇으로 적어야 하는지는 힌트를 보세요. 그다음/root/ist2-mtls/portlevel.yaml에 Envoy 설정을 쓰세요 — 3단계와 같은 리스너(10086)에additional_addresses로127.0.0.1:10096를 더 듣게 하고,filter_chain_match.destination_port: 10096인 평문 체인 하나와 3단계의 TLS 체인 하나를 둡니다. Envoy 를 다시 띄우고/root/ist2-mtls/06-portlevel.txt에 네 줄을 적으세요 —validate_rc=(istioctl validate -f pa-port.yaml의 종료 코드),plain_10096=,plain_10086=(각 포트로 평문/port의 코드),mtls_10086=(orders 인증서로https://localhost:10086/port의 코드)./root/ist2-mtls/orders.crt의 SAN 을 openssl 로 읽어/root/ist2-mtls/07-principal.txt에 두 줄을 적으세요 —san=(읽은 URI 그대로),principal=(AuthorizationPolicy 의principals에 넣을 문자열). 그리고/root/ist2-mtls/authz.yaml에 AuthorizationPolicy 를 쓰세요 —apiVersion: security.istio.io/v1, 이름reviews-from-orders, 네임스페이스default,selector.matchLabels.app: reviews,action: ALLOW, 규칙 하나의from[0].source.principals에 그 principal 하나만.istioctl validate -f authz.yaml이 통과해야 합니다./root/ist2-mtls/08-report.md에default_mode=(PeerAuthentication 이 하나도 없을 때의 모드),strict_plain=(3단계에서 평문 요청이 받은 코드),permissive_chains=(5단계 리스너의 필터 체인 수),orders_principal=(7단계의 principal) 네 줄을 적고, 그 아래-로 시작하는 설명을 네 줄 이상 적으세요.
참고
- 이 파드에는 진짜 istiod 도 진짜 사이드카도 없습니다. 그래서
istioctl proxy-config로 실제 생성물을 볼 수 없고, 번역 규칙을 알고 손으로 등가 Envoy 설정을 만들어 동작을 확인합니다. 같은 규칙이 운영 클러스터의proxy-config출력에 그대로 보입니다. - 인증서는 진짜 istiod 대신 여러분이 만든 CA 가 서명합니다. 모양(SAN 의 SPIFFE URI)은 istiod 가 서명해 주는 것과 같습니다.
- curl 은 평문 연결이 끊기면 코드로
000을 찍습니다. 코드는curl -s -o /dev/null -w '%{http_code}'로 받으세요. - 3·4·5·6단계는 설정 파일 이름이 서로 다릅니다. 앞 단계 파일을 고치지 말고 새 이름으로 만드세요.
- Envoy 를 띄울 때는
setsid --fork nohup envoy -c <파일> --log-level warn > <로그> 2>&1 </dev/null로 셸에서 완전히 떼어 놓으세요. 다시 띄우기 전에는pkill -x envoy로 정리합니다 (pkill -f 'envoy -c'는 그 문자열이 든 셸 자신까지 죽입니다). - 업스트림 흉내용 서버가 이미지에 있습니다:
python3 /opt/lab/envoy/upstream.py <포트> ok|fail|slow. 응답 본문은<모드>:<포트> <경로>입니다. - 설정을 고친 뒤에는 띄우기 전에
envoy --mode validate -c <파일>로 먼저 거르세요. 클러스터 이름에|가 들어가므로 YAML 에서는 반드시 따옴표로 감쌉니다.
STRICT PeerAuthentication 을 쓰고 오프라인으로 거른다
/root/ist2-mtls/pa.yaml 에 PeerAuthentication 을 쓰세요 — apiVersion: security.istio.io/v1, 이름 reviews-strict, 네임스페이스 default, selector.matchLabels.app: reviews, mtls.mode: STRICT. istioctl validate -f pa.yaml 의 출력과 종료 코드를 /root/ist2-mtls/01-validate.txt 에 담으세요(마지막 줄 rc=).
PeerAuthentication 은 받는 쪽 워크로드의 설정입니다. reviews 를 부르는 쪽이 아니라 reviews 자신에게 '평문은 받지 말라' 고 거는 것이므로 selector 는 받는 쪽의 라벨이어야 합니다. selector 를 빼면 네임스페이스 전체, 루트 네임스페이스(istio-system)에 두면 메시 전체가 대상이 됩니다. istioctl validate 는 클러스터 없이 스키마만 봅니다 — 모드 값을 오타 내면 여기서 걸립니다.
메시 CA 와 SPIFFE 인증서 세 장을 만든다
/root/ist2-mtls 에서 openssl 로 자체 서명 CA 한 장(ca.crt·ca.key)을 만들고, 그 CA 로 서명한 인증서 세 장을 만드세요 — reviews.crt/reviews.key(서버), orders.crt/orders.key(허용할 클라이언트), intruder.crt/intruder.key(같은 CA 가 서명했지만 허용하지 않을 클라이언트). 각 인증서의 SAN 은 URI 하나, spiffe://cluster.local/ns/default/sa/<이름> 입니다. 그다음 인증서에서 실제로 읽은 SAN 을 /root/ist2-mtls/02-ids.txt 에 reviews.crt=…, orders.crt=…, intruder.crt=… 세 줄로 적으세요.
Istio 의 신원은 이름(CN)이 아니라 SAN 의 URI 입니다 — spiffe://<신뢰 도메인>/ns/<네임스페이스>/sa/<서비스 계정>. istiod 는 파드의 서비스 계정 토큰을 확인한 뒤 바로 이 모양의 인증서를 서명해 줍니다. CSR 에 -addext "subjectAltName=URI:…" 로 SAN 을 넣어도, 서명할 때 openssl x509 -req 에 -copy_extensions copyall 을 주지 않으면 확장이 버려집니다. 서명 뒤에 openssl x509 -noout -ext subjectAltName 으로 꼭 확인하고, openssl verify -CAfile ca.crt 로 사슬도 보세요.
STRICT 는 TLS 체인 하나뿐인 리스너가 된다
/root/ist2-mtls/strict.yaml 에 Envoy 설정을 쓰세요 — 관리 포트 9986, 리스너 virtualInbound 가 127.0.0.1:10086 에서 듣고 리스너 필터로 envoy.filters.listener.tls_inspector 를 둡니다. 필터 체인은 하나, filter_chain_match.transport_protocol: tls 이고 DownstreamTlsContext 에 require_client_certificate: true, 서버 인증서 /root/ist2-mtls/reviews.crt·/root/ist2-mtls/reviews.key, 신뢰 CA /root/ist2-mtls/ca.crt 를 줍니다(SAN 매처는 아직 넣지 않습니다). 그 체인은 모든 경로를 클러스터 inbound|8109||(127.0.0.1:8109)로 보냅니다. 업스트림을 8109 에 ok 로 띄우고 Envoy 를 띄운 뒤 /root/ist2-mtls/03-strict.txt 에 네 줄을 적으세요 — plain=(평문 http://localhost:10086/strict 의 코드), mtls=(orders 인증서로 https://localhost:10086/strict 의 코드), mtls_body=(그 응답 본문), no_filter_chain_match=(통계 listener.127.0.0.1_10086.no_filter_chain_match 의 값).
istiod 가 STRICT 를 받으면 들어오는 리스너에서 평문 체인을 없앱니다. 남는 것은 tls_inspector 가 'TLS 다' 라고 판정한 연결만 받는 체인이고, 그 체인은 클라이언트 인증서를 요구해 메시 CA 로 검증합니다. 평문 연결은 맞는 체인이 없어 Envoy 가 그냥 닫습니다 — HTTP 응답조차 없으므로 curl 의 코드는 000 이고, 그 흔적이 no_filter_chain_match 통계입니다. 서버 인증서의 SAN 은 URI 뿐이라 curl 의 호스트 이름 검사가 통하지 않으니 -k 로 서버 확인만 끄고 --cert·--key 로 클라이언트 인증서는 내세요. (Istio 의 클라이언트는 호스트 이름 대신 서버 SAN 의 SPIFFE ID 를 확인합니다.)
같은 CA 가 서명해도 SAN 이 다르면 거절한다
/root/ist2-mtls/strict.yaml 을 /root/ist2-mtls/strict-san.yaml 로 복사한 뒤 그 사본에만 validation_context 에 match_typed_subject_alt_names 를 하나 더하세요 — san_type: URI, matcher.exact 는 2단계에서 읽은 orders.crt 의 SAN 그대로. 그 설정으로 Envoy 를 다시 띄우고 /root/ist2-mtls/04-san.txt 에 세 줄을 적으세요 — orders=(orders 인증서로 https://localhost:10086/san 의 코드), intruder=(intruder 인증서로 같은 요청의 코드), fail_verify_san=(통계 listener.127.0.0.1_10086.ssl.fail_verify_san 의 값).
3단계의 체인은 '메시 CA 가 서명했는가' 만 봅니다. 그래서 같은 CA 에서 나온 intruder 도 통과했습니다. SAN 매처를 걸면 서명에 더해 '누구의 인증서인가' 까지 봅니다. 다만 Istio 가 이 매처를 쓰는 곳은 주로 클라이언트 쪽입니다 — 부르는 쪽이 '상대가 정말 reviews 의 신원인가' 를 확인하는 것(secure naming)이고, 받는 쪽에서 '누가 부를 수 있는가' 는 7단계의 AuthorizationPolicy 가 맡습니다. 여기서는 같은 원리를 받는 쪽에 걸어 Envoy 가 SAN 을 어떻게 대조하는지 봅니다. 거절은 TLS 핸드셰이크에서 일어나니 코드는 000 입니다.
PERMISSIVE 는 체인 두 개다
/root/ist2-mtls/strict.yaml 을 바탕으로 /root/ist2-mtls/permissive.yaml 을 만드세요 — 3단계의 TLS 체인은 그대로 두고, filter_chain_match 도 transport_socket 도 없는 평문 체인을 하나 더합니다(같은 클러스터 inbound|8109|| 로). 그 설정으로 Envoy 를 다시 띄우고 /root/ist2-mtls/05-permissive.txt 에 세 줄을 적으세요 — plain=(평문 http://localhost:10086/permissive 의 코드), plain_body=(그 본문), mtls=(orders 인증서로 https://localhost:10086/permissive 의 코드).
PERMISSIVE 는 '둘 다 받는다' 입니다. Envoy 에서는 체인을 하나 더하는 것으로 표현됩니다. tls_inspector 가 첫 바이트를 보고 TLS 면 transport_protocol: tls 체인으로, 아니면 매치 조건이 없는 체인으로 보냅니다. Istio 가 실제로 만드는 체인에는 ALPN(istio-peer-exchange, istio) 조건도 붙지만 원리는 같습니다. 평문 체인에 transport_socket 을 붙이면 그 체인도 TLS 를 기다리게 되어 평문이 다시 막힙니다.
portLevelMtls 는 워크로드 포트에 거는 체인 하나다
/root/ist2-mtls/pa-port.yaml 에 문서 두 개를 쓰세요 — (1) Service reviews(네임스페이스 default, selector app: reviews, port: 80 → targetPort: 10096), (2) PeerAuthentication reviews-port(같은 selector, mtls.mode: STRICT, portLevelMtls 로 한 포트만 DISABLE). 그 포트 번호를 무엇으로 적어야 하는지는 힌트를 보세요. 그다음 /root/ist2-mtls/portlevel.yaml 에 Envoy 설정을 쓰세요 — 3단계와 같은 리스너(10086)에 additional_addresses 로 127.0.0.1:10096 를 더 듣게 하고, filter_chain_match.destination_port: 10096 인 평문 체인 하나와 3단계의 TLS 체인 하나를 둡니다. Envoy 를 다시 띄우고 /root/ist2-mtls/06-portlevel.txt 에 네 줄을 적으세요 — validate_rc=(istioctl validate -f pa-port.yaml 의 종료 코드), plain_10096=, plain_10086=(각 포트로 평문 /port 의 코드), mtls_10086=(orders 인증서로 https://localhost:10086/port 의 코드).
iptables 는 파드로 오는 연결을 15006 으로 꺾을 때 원래 목적지 포트를 보존합니다. 그 포트는 Service 의 port 가 아니라 컨테이너가 실제로 듣는 targetPort 이고, istiod 는 portLevelMtls 의 번호를 그대로 destination_port 로 거는 체인으로 만듭니다. 그래서 문서도 '워크로드 포트' 라고 적습니다. 또 portLevelMtls 는 selector 가 있는 정책에서만 받아 줍니다 — 빼 보면 istioctl validate 가 거절합니다. 이 파드에서는 iptables 대신 리스너 하나가 두 포트를 듣게 해 같은 모양을 만듭니다.
SAN 에서 spiffe:// 를 떼면 principal 이 된다
/root/ist2-mtls/orders.crt 의 SAN 을 openssl 로 읽어 /root/ist2-mtls/07-principal.txt 에 두 줄을 적으세요 — san=(읽은 URI 그대로), principal=(AuthorizationPolicy 의 principals 에 넣을 문자열). 그리고 /root/ist2-mtls/authz.yaml 에 AuthorizationPolicy 를 쓰세요 — apiVersion: security.istio.io/v1, 이름 reviews-from-orders, 네임스페이스 default, selector.matchLabels.app: reviews, action: ALLOW, 규칙 하나의 from[0].source.principals 에 그 principal 하나만. istioctl validate -f authz.yaml 이 통과해야 합니다.
mTLS 가 끝나면 받는 쪽 Envoy 는 상대 인증서의 URI SAN 을 연결의 신원으로 기억합니다. AuthorizationPolicy 의 source.principals 는 그 신원과 대조되는데, 표기에서 스킴(spiffe://)을 뗀 <신뢰 도메인>/ns/<네임스페이스>/sa/<서비스 계정> 꼴을 씁니다. 셸에서는 ${san#spiffe://} 로 뗄 수 있습니다. 주의 — 스킴을 붙인 채 적어도 istioctl validate 는 통과합니다. 그 정책은 아무와도 맞지 않아 ALLOW 정책이면 모두를 막습니다. 그리고 principal 은 mTLS 로 들어온 연결에만 생기므로 평문을 허용하는 PERMISSIVE 포트에서는 이 규칙에 걸리는 요청이 없습니다.
모드마다 Envoy 에 생기는 것을 한 장으로 정리한다
/root/ist2-mtls/08-report.md 에 default_mode=(PeerAuthentication 이 하나도 없을 때의 모드), strict_plain=(3단계에서 평문 요청이 받은 코드), permissive_chains=(5단계 리스너의 필터 체인 수), orders_principal=(7단계의 principal) 네 줄을 적고, 그 아래 - 로 시작하는 설명을 네 줄 이상 적으세요.
값은 앞 단계 파일에서 옮기세요. 설명에는 '모드 → Envoy 에 생기는 것 → 운영에서 보이는 증상' 을 짝지어 적으면 좋습니다. 기본 모드가 왜 그 값인지는, 메시에 사이드카를 차례로 넣어 가는 도중을 떠올려 보세요.