В этой статье я расскажу про практический случай одной конфигурационной ошибки, которая привела к неожиданному эффекту, заняла меня на пару часов исследований и показала как важно понимать, что скрывает под собой тотальная автоматизация. Я подумал, что процесс отлова был достаточно интересным, чтобы им поделиться.

Началось все со следующей задачи: в k8s инфраструктуре был развернут minio кластер с публичным ингрессом для s3 api (на всякий случай уточню, minio - это S3 хранилище на самообслуживании). И требовалось перевести взаимодействие компонентов внутри кластера на приватную сеть. В деталях это означало завести внутренний CA, выписать сертификат на внутри-кластерное имя (вида minio.minio.svc.cluster.local), отдать его напрямую TLS серверу minio и разложить CA сертификат в доверительные хранилища сертификатов на стороне приложений. Задача была выполнена, все заработало, трафик перебросился на внутренние сетевые интерфейсы, сертификат был не самоподписанный, всё выглядело красиво.

Однако логи приложений стали бросать периодические ошибки вида:

sun.security.validator.ValidatorException: 
PKIX path building failed: 
sun.security.provider.certpath.SunCertPathBuilderException: 
unable to find valid certification path to requested target

При этом приложение почти полностью работало, и проявлялись ошибки в случайных непрогруженных картинках то там, то тут. Поведение было нестабильным. Переключение minio клиента назад на публичный ендпоинт ошибки убирало. Было непонятно, в первую очередь, отчего такое непостоянное поведение? Азарт возрастал.

Моя первая мысль: подозрение падает на сторону клиента, как-то некорректно он работает с доверяемыми CA. Чтобы проверить это за рамками приложения, воспользовался инструментом SSLPoke и, в общем-то, получил искомое:

# for i in {1..20}; do $JAVA_HOME/bin/java SSLPoke minio.minio.svc.cluster.local 443; done                        
Successfully connected
Successfully connected
Successfully connected
Successfully connected
Successfully connected
Successfully connected
Successfully connected
Successfully connected
Successfully connected
Successfully connected
Successfully connected
Successfully connected
Successfully connected
Successfully connected
Successfully connected
Successfully connected
Successfully connected
Successfully connected
sun.security.validator.ValidatorException: PKIX path building failed: sun.security.provider.certpath.SunCertPathBuilderException: unable to find valid certification path to requested target
	at java.base/sun.security.validator.PKIXValidator.doBuild(Unknown Source)
	at java.base/sun.security.validator.PKIXValidator.engineValidate(Unknown Source)
	at java.base/sun.security.validator.Validator.validate(Unknown Source)
	at java.base/sun.security.ssl.X509TrustManagerImpl.validate(Unknown Source)
	at java.base/sun.security.ssl.X509TrustManagerImpl.checkTrusted(Unknown Source)
	at java.base/sun.security.ssl.X509TrustManagerImpl.checkServerTrusted(Unknown Source)
	at java.base/sun.security.ssl.CertificateMessage$T13CertificateConsumer.checkServerCerts(Unknown Source)
	at java.base/sun.security.ssl.CertificateMessage$T13CertificateConsumer.onConsumeCertificate(Unknown Source)
	at java.base/sun.security.ssl.CertificateMessage$T13CertificateConsumer.consume(Unknown Source)
	at java.base/sun.security.ssl.SSLHandshake.consume(Unknown Source)
	at java.base/sun.security.ssl.HandshakeContext.dispatch(Unknown Source)
	at java.base/sun.security.ssl.HandshakeContext.dispatch(Unknown Source)
	at java.base/sun.security.ssl.TransportContext.dispatch(Unknown Source)
	at java.base/sun.security.ssl.SSLTransport.decode(Unknown Source)
	at java.base/sun.security.ssl.SSLSocketImpl.decode(Unknown Source)
	at java.base/sun.security.ssl.SSLSocketImpl.readHandshakeRecord(Unknown Source)
	at java.base/sun.security.ssl.SSLSocketImpl.startHandshake(Unknown Source)
	at java.base/sun.security.ssl.SSLSocketImpl.ensureNegotiated(Unknown Source)
	at java.base/sun.security.ssl.SSLSocketImpl$AppOutputStream.write(Unknown Source)
	at java.base/sun.security.ssl.SSLSocketImpl$AppOutputStream.write(Unknown Source)
	at SSLPoke.main(SSLPoke.java:31)
Caused by: sun.security.provider.certpath.SunCertPathBuilderException: unable to find valid certification path to requested target
	at java.base/sun.security.provider.certpath.SunCertPathBuilder.build(Unknown Source)
	at java.base/sun.security.provider.certpath.SunCertPathBuilder.engineBuild(Unknown Source)
	at java.base/java.security.cert.CertPathBuilder.build(Unknown Source)
	... 21 more
Successfully connected

Итак, мы видим, что один из двадцати вызовов к нашему серверу падает. Повторяя эксперимент, это случайное поведение оставалось стабильным в своей случайности.

Следующий шаг: исключим из уравнения Java и перейдем на уровень чистого Linux. А именно, произведем тот же эксперимент с чистым curl, что увидим?

# for i in {1..20}; do curl https://minio.minio.svc.cluster.local; done                        

Результат оказался тем же - примерно один раз из десяти получаю ошибку сертификата:

curl: (60) SSL certificate problem: unable to get local issuer certificate
More details here: https://curl.se/docs/sslcerts.html

curl failed to verify the legitimacy of the server and therefore could not
establish a secure connection to it. To learn more about this situation and
how to fix it, please visit the web page mentioned above.

В этот момент в голову полезли стандартные мысли: это точно какие-то глюки сети kubernetes, ведь всем известно, что там могут происходить невероятные вещи...

Идем дальше, можем смело отбросить сеть, если произведем тот же эксперимент локально с ноды в контейнер и внутри самого контейнера. Результат остался тем же, сеть как таковая не причем. Проблема находится внутри самого minio server.

Следующая мысль: раз у нас ошибка сертификата, надо уже наконец посмотреть, что за сертификат мы получаем. Смотрим на хороший и плохой случай с помощью openssl:

  • сертификат здорового человека

depth=1 CN = cluster-ca
verify return:1
depth=0 CN = s3.example.com
verify return:1
  • сертификат курильщика

depth=0 O = system:nodes, CN = system:node:*.example-1-hl.minio.svc.cluster.local
verify error:num=20:unable to get local issuer certificate
verify return:1
depth=0 O = system:nodes, CN = system:node:*.example-1-hl.minio.svc.cluster.local
verify error:num=21:unable to verify the first certificate
verify return:1
depth=0 O = system:nodes, CN = system:node:*.example-1-hl.minio.svc.cluster.local
verify return:1

Видим необычное имя system:node:*.example-1-hl.minio.svc.cluster.local и при этом группа system:nodes намекает на сертификат, выписанный самим k8s.

Не очень люблю сидеть и гадать, откуда что появляется, поэтому расчехлил старое ружье на стене обратился к strace, он меня никогда не подводил. Натравив его на процесс minio во время того же цикла curl, увидел, что он действительно обращается в разный момент времени к разным файлам:

# strace -vvvtTfs1024 -o /tmp/strace.log -p 1
... запускаем for-loop с командой curl ...
CTRL+C

# grep crt /tmp/strace.log
2523784 09:14:44.028695 openat(AT_FDCWD, "/tmp/certs/public.crt", O_RDONLY|O_CLOEXEC) = 14 <0.000031>
2543764 09:14:46.847463 openat(AT_FDCWD, "/tmp/certs/public.crt", O_RDONLY|O_CLOEXEC) = 14 <0.000030>
2561965 09:14:47.028695 openat(AT_FDCWD, "/tmp/certs/public.crt", O_RDONLY|O_CLOEXEC) = 14 <0.000031>
2635591 09:14:47.029252 openat(AT_FDCWD, "/tmp/certs/hostname-1/public.crt", O_RDONLY|O_CLOEXEC <unfinished ...>

Дальше меня насторожил один нюанс, что при старте minio получает не точный путь до конкретных файлов сертификата, а просто директорию, в которой они лежат:

  - args:
    - server
    - --certs-dir
    - /tmp/certs
    - --console-address
    - :9443

И внутри по какой-то причине лежат два разных сертификата. Исходники minio как бы поясняют, что это скорее фича, а не баг, чтобы упростить работу с предоставляемыми сертификатами.

	// MinIO has support for multiple certificates. It expects the following structure:
	//  certs/
	//   │
	//   ├─ public.crt
	//   ├─ private.key
	//   │
	//   ├─ example.com/
	//   │   │
	//   │   ├─ public.crt
	//   │   └─ private.key
	//   └─ foobar.org/
	//      │
	//      ├─ public.crt
	//      └─ private.key
	//   ...
	//
	// Therefore, we read all filenames in the cert directory and check
	// for each directory whether it contains a public.crt and private.key.
	// If so, we try to add it to certificate manager.

Развязка

Когда стало понятно, что все идет по плану и так и было задумано, после очередного прочтения tls.md я уже стал догадываться, что в нашу картину вмешивается автоматизированный процесс, и так как он описан в секции Automatic TLS я наконец осознал, что именно пошло не так.

Once you enable requestAutoCert field and create the Tenant, MinIO Operator creates a CSR for this instance and sends to the Kubernetes API server.

Когда я переходил к своему сертификату, в настройках объекта Minio Tenant я убрал ключ requestAutoCert и добавил externalCertSecret, и ожидал, что я таким образом выключил автогенерацию. Но я просто оставил значение true по умолчанию. Если побродить по их прочим инструкциям этот момент проскальзывает, например так:

MinIO Operator can automatically generate TLS secrets and mount these secrets to the MinIO, Console, and/or KES pods (enabled by default). To disable this, set the requestAutoCert field to false.

Но когда я шел по тем шагам, что нужны были мне, этот момент был упущен.

Получается, minio operator производил одновременно два процесса: брал мой сертификат из секрета, а также автоматически запрашивал сертификат у k8s и добавлял в этот же секрет. Для полноты всей этой картины, выяснилось, что я дал имя секрету ровно такое, какое зашито в автогенерации. Занавес!

Для успокоения своей совести и защиты от дурака в будущем, я оформил PR на то, чтобы улучшить этот момент в документации: https://github.com/minio/operator/pull/1184.

Благодарю за внимание!

Комментарии (0)