Start Debugging

Исправление: CERTIFICATE_VERIFY_FAILED: unable to get local issuer certificate в Docker-образе Dart 3.13

Dart 3.13 убрал из VM встроенные резервные корневые сертификаты. Добавьте CA-бандл в образ времени выполнения: COPY /runtime/ из dart:stable или установите ca-certificates.

Ваш Dockerfile не менялся. Изменился Dart. Начиная с Dart 3.13.0 (SDK во Flutter 3.47.0 и тот, что стоит за тегом dart:stable с августа 2026 года, сейчас это 3.13.5), автономная VM больше не поставляется со вшитым набором резервных корневых сертификатов. Если в образе, в котором работает ваше приложение, нет CA-бандла по одному из стандартных путей Linux, каждый HTTPS-вызов теперь завершается ошибкой рукопожатия. Исправление: дайте финальному этапу хранилище доверенных сертификатов. Оставьте COPY --from=build /runtime/ / на этапе FROM scratch или выполните apt-get install ca-certificates в облегчённом базовом образе. Если изменить образ нельзя, укажите VM файл PEM через DART_VM_OPTIONS=--root-certs-file=/path/to/cacert.pem.

Всё ниже основано на исходном коде Dart SDK по тегам 3.12.2, 3.13.0 и 3.13.5, на Dockerfile stable/trixie в dart-lang/dart-docker и на обсуждении dart-lang/sdk#64060, где команда Dart подтвердила, что такое поведение намеренное.

Ошибка в контексте

Приложение собирается и запускается без проблем. Первое же исходящее TLS-соединение, идёт ли оно через HttpClient, package:http, dio, канал gRPC или драйвер базы данных, выбрасывает исключение:

HandshakeException: Handshake error in client (OS Error:
	CERTIFICATE_VERIFY_FAILED: unable to get local issuer certificate(handshake.cc:320))

#0      _SecureFilterImpl._handshake (dart:io-patch/secure_socket_patch.dart:101)
#1      _SecureFilterImpl.handshake (dart:io-patch/secure_socket_patch.dart:146)
#2      _RawSecureSocket._secureHandshake (dart:io/secure_socket.dart:995)
#3      _RawSecureSocket._tryFilter (dart:io/secure_socket.dart:1127)
<asynchronous suspension>

Главный признак здесь момент появления. Тот же код, тот же Dockerfile и та же конечная точка работали на dart:3.12.2. Если вернуть этап сборки на dart:3.12.2, ошибка исчезает. Именно так поступил автор #64060, прежде чем нашёл причину. Конечные точки с абсолютно валидными публичными сертификатами (pub.dev, googleapis.com, ваш собственный API на Let’s Encrypt) падают так же, как и с самоподписанными, потому что проблема не в цепочке сервера. Клиент просто не доверяет вообще ничему.

Почему Dart 3.13 перестал доверять чему-либо в пустом образе

В Linux функция SSLCertContext::TrustBuiltinRoots() в runtime/bin/security_context_linux.cc ищет доверенные корневые сертификаты в фиксированном порядке:

  1. Параметр --root-certs-file или --root-certs-cache, если вы его передали.
  2. Первый существующий файл-бандл из /etc/ssl/certs/ca-certificates.crt, /etc/pki/tls/certs/ca-bundle.crt, /etc/ssl/ca-bundle.pem, /etc/pki/tls/cacert.pem и /etc/pki/ca-trust/extracted/pem/tls-ca-bundle.pem.
  3. Первый существующий каталог из /etc/ssl/certs, /system/etc/security/cacerts, /usr/local/share/certs, /etc/pki/tls/certs и /etc/openssl/certs.
  4. В крайнем случае AddCompiledInCerts(), которая загружает корневой бандл Mozilla, вшитый в бинарные файлы dart и dartaotruntime (а значит, и в каждый результат dart compile exe).

Изменился именно шаг 4. В журнале изменений Dart 3.13.0 это сказано одной строкой в разделе “Dart Runtime”: встроенные резервные корневые сертификаты “are no longer included”. Коммит в SDK: 7e5b075680, “Reland [standalone] Remove the fallback root certificates”, попал в код 2026-06-01 после первой попытки в мае, которую откатили. Функция в 3.13 всё ещё существует, но runtime/bin/BUILD.gn больше не подключает third_party/fallback_root_certificates и безусловно определяет DART_IO_ROOT_CERTS_DISABLED, поэтому root_certificates_pem равен null, и функция завершается, ничего не добавив.

Годами этот резерв молча выручал образы времени выполнения без CA-бандла. Этап FROM scratch, в который копировался только скомпилированный бинарный файл, работал, потому что бинарный файл нёс свои корневые сертификаты. База debian:trixie-slim без ca-certificates работала по той же причине. В 3.13 такие образы получают пустой X509_STORE, и BoringSSL сообщает, что у первого сертификата в любой цепочке неизвестный издатель.

Две детали делают ситуацию запутаннее, чем нужно:

Минимальный пример воспроизведения

Программа на Dart, выполняющая один HTTPS-запрос:

// Dart 3.13.5, bin/server.dart
import 'dart:io';

Future<void> main() async {
  final client = HttpClient();
  try {
    final request = await client.getUrl(Uri.parse('https://pub.dev/api/packages/http'));
    final response = await request.close();
    print('status: ${response.statusCode}');
    await response.drain<void>();
  } finally {
    client.close();
  }
}

И многоэтапный Dockerfile, который копирует в финальный этап только исполняемый файл. Такая схема часто встречается в самописных Dockerfile и в шаблонах CI, появившихся раньше официального:

# Dart 3.13.5 (dart:stable, September 2026)
FROM dart:stable AS build
WORKDIR /app
COPY pubspec.* ./
RUN dart pub get
COPY . .
RUN dart compile exe bin/server.dart -o bin/server

FROM debian:trixie-slim
COPY --from=build /app/bin/server /app/bin/server
CMD ["/app/bin/server"]

При сборке на dart:3.12.2 программа выводит status: 200 благодаря вшитым корневым сертификатам. При сборке на dart:3.13.0 или новее она выбрасывает HandshakeException, показанный выше, потому что в debian:trixie-slim нет пакета ca-certificates.

Исправление подробно

Выберите первый подходящий для вашего образа вариант. Все они дают VM настоящее хранилище доверенных сертификатов, и ни один не отключает проверку.

1. Оставьте официальное копирование /runtime/ на этапе FROM scratch

Эту схему рекомендует документация официального образа dart, и сертификаты в ней уже есть:

# Dart 3.13.5 (dart:stable)
FROM dart:stable AS build
WORKDIR /app
COPY pubspec.* ./
RUN dart pub get
COPY . .
RUN dart compile exe bin/server.dart -o bin/server

FROM scratch
COPY --from=build /runtime/ /
COPY --from=build /app/bin/server /app/bin/
CMD ["/app/bin/server"]

Если ваш финальный этап это FROM scratch, а единственный COPY копирует бинарный файл, добавьте строку с /runtime/. Помимо сертификатов она приносит загрузчик glibc, libnss_dns и /etc/nsswitch.conf, так что заодно исправит проблемы с разрешением DNS, с которыми вы, возможно, ещё не сталкивались.

2. Установите ca-certificates в облегчённый образ времени выполнения

Если вы работаете на debian:*-slim или ubuntu:*, установите пакет в финальном этапе:

# Dart 3.13.5 AOT binary on debian:trixie-slim
FROM debian:trixie-slim
RUN apt-get update \
 && apt-get install -y --no-install-recommends ca-certificates \
 && rm -rf /var/lib/apt/lists/*
COPY --from=build /app/bin/server /app/bin/server
CMD ["/app/bin/server"]

Скрипт пакета после установки создаёт /etc/ssl/certs/ca-certificates.crt, и это первый путь, который проверяет VM. Отдельный вызов update-ca-certificates не нужен, если только вы не добавляете собственные сертификаты (см. ниже). В образах RHEL, UBI или Fedora эквивалентный пакет тоже называется ca-certificates, и он создаёт /etc/pki/tls/certs/ca-bundle.crt, который также есть в списке.

Distroless тоже подходит: gcr.io/distroless/cc-debian12 содержит /etc/ssl/certs/ca-certificates.crt и glibc, нужную AOT-бинарному файлу Dart.

3. Обновите старый базовый образ

Если в образе времени выполнения есть ca-certificates, но он многолетней давности, бандл старше корневых сертификатов, к которым привязываются новые центры сертификации. Именно такая ситуация была в #64060. Исправление: перейти на актуальный базовый образ. Обновление пакета на месте (apt-get update && apt-get install --only-upgrade ca-certificates) работает лишь пока дистрибутив продолжает публиковать обновления.

4. Укажите VM бандл через DART_VM_OPTIONS

Когда трогать образ нельзя, но можно смонтировать файл или задать переменную окружения (спецификация пода Kubernetes, управляемая среда выполнения), используйте --root-certs-file. Для JIT (dart run, dart bin/server.dart) это обычная опция VM:

dart --root-certs-file=/certs/cacert.pem bin/server.dart

Бинарный файл dart compile exe игнорирует собственную командную строку для опций VM, поскольку все аргументы передаются в ваш main. Зато он читает DART_VM_OPTIONS, список через запятую, который разбирается в main_impl.cc только для исполняемых файлов с присоединённым снимком, и --root-certs-file входит в число принимаемых им опций:

# Dart 3.13.5 AOT binary, CA bundle supplied explicitly
FROM debian:trixie-slim
COPY cacert.pem /certs/cacert.pem
COPY --from=build /app/bin/server /app/bin/server
ENV DART_VM_OPTIONS=--root-certs-file=/certs/cacert.pem
CMD ["/app/bin/server"]

--root-certs-cache=<dir> делает то же для каталога с хешированными файлами сертификатов в стиле c_rehash. Любая из этих опций полностью заменяет системный поиск: шаги 2 и 3 пропускаются, поэтому переданный вами файл и есть всё хранилище доверия. Используйте поддерживаемый бандл, например построенный на данных Mozilla cacert.pem, публикуемый curl, и поддерживайте его в актуальном состоянии.

5. Загрузите бандл из кода

Если вы хотите сделать программу самодостаточной, добавьте корневые сертификаты в контекст по умолчанию при запуске. SecurityContext.defaultContext используют HttpClient, IOClient из package:http и большинство других клиентов, когда вы не передаёте контекст:

// Dart 3.13.5
import 'dart:io';

void main() {
  const bundle = '/certs/cacert.pem';
  if (File(bundle).existsSync()) {
    SecurityContext.defaultContext.setTrustedCertificates(bundle);
  }
  // ... start the server, create clients afterwards
}

Для полностью изолированной настройки создайте контекст, который доверяет только вашему бандлу, и передайте его клиенту:

// Dart 3.13.5, package:http 1.x
import 'dart:io';
import 'package:http/io_client.dart';

IOClient buildClient(List<int> pemBytes) {
  final context = SecurityContext(withTrustedRoots: false)
    ..setTrustedCertificatesBytes(pemBytes);
  return IOClient(HttpClient(context: context));
}

Это правильный инструмент для закрепления частного центра сертификации для одного сервиса. Как общее решение для публичных конечных точек он лишь воссоздаёт утраченный резервный бандл, только теперь за его обновление отвечаете вы, поэтому предпочитайте варианты 1 или 2.

Как убедиться, что в образе нет хранилища доверия

В образах FROM scratch и distroless нет оболочки, поэтому docker run ... ls не сработает. Вместо этого скопируйте путь из остановленного контейнера:

docker create --name probe my-dart-app:latest
docker cp probe:/etc/ssl/certs/ca-certificates.crt - | tar -tv
docker rm probe

Если docker cp сообщает Could not find the file, проверьте остальные четыре пути бандлов из списка выше. Если ни одного нет, а /etc/ssl/certs отсутствует или пуст, причина найдена. Для образов с оболочкой достаточно ls -la /etc/ssl/certs | head.

Подводные камни и похожие случаи

Связанные материалы

Источники

Comments

Sign in with GitHub to comment. Reactions and replies thread back to the comments repo.

< Назад