Przewiń do głównej treści

Let's Encrypt w Azure Container Apps: dlaczego napisałem własnego klienta ACME

·1141 słów·6 min
Autor
Emil Grużalski
Infrastruktura & Automatyzacja Chmury

Odnawianie certyfikatów to jeden z tych problemów, które są całkowicie rozwiązane — dopóki nie przestają być. Na Kubernetesie instalujesz cert-managera i nigdy więcej o tym nie myślisz. W Azure Container Apps zaznaczasz opcję certyfikatu zarządzanego i — w typowym przypadku — też nigdy więcej o tym nie myślisz. Ten wpis jest o przypadku pomiędzy, i o acme-az-aca — małym serwisie w Go, którego napisanie okazało się najlepszym wyjściem.

README opisuje, jak to wdrożyć i skonfigurować. Nie będę tego powtarzał — ten wpis to część, której README nie umie opowiedzieć: dlaczego narzędzie wygląda tak, a nie inaczej.

Luka między certyfikatami zarządzanymi a cert-managerem
#

Dla jasności na wstępie: jeśli pojedyncza aplikacja obsługuje Twoją domenę bezpośrednio, certyfikaty zarządzane Azure są właściwą odpowiedzią. Darmowe, automatyczne, bezobsługowe. Zawsze polecam je w pierwszej kolejności.

W tym środowisku nie wchodziły w grę. Ruch wchodził przez reverse proxy nginx działające jako osobna aplikacja — standardowe obejście braku routingu po ścieżkach między aplikacjami w Container Apps — i to proxy było ingressem dla wszystkich domen. Certyfikaty zarządzane zakładają topologię dokładnie odwrotną: DigiCert waliduje aplikację bezpośrednio, DNS musi wskazywać wprost na wygenerowaną domenę aplikacji, a dokumentacja mówi wprost, że pośredni skok w DNS blokuje wystawienie i odnowienie certyfikatu. Do tego sama funkcja była wtedy młoda — spędziła blisko rok w preview i osiągnęła ogólną dostępność dopiero w marcu 2024. Jak tego nie obracać, darmowa ścieżka nie pasowała do tej topologii, a pozostałą odpowiedzią vendora było kupowanie certyfikatów per domena: cykliczny koszt za coś, co Let’s Encrypt rozdaje za darmo.

To, co Container Apps wspierało czysto, to własne certyfikaty konsumowane z Key Vault. I to przesądziło o kształcie rozwiązania: automatycznie dostarczać certyfikaty Let’s Encrypt do Key Vault, pozwolić Container Apps podpinać je pod domeny i utrzymywać je świeże bez człowieka w pętli.

Na Kubernetesie tym elementem jest cert-manager. Ale Container Apps to nie Kubernetes — to w końcu cały sens tej usługi — więc tamten ekosystem odpada. Ludowym rozwiązaniem jest kontener z cronem sklejający certbota albo acme.sh, az CLI i OpenSSL-a: trzy narzędzia, shell, zapisywalny filesystem i sekrety przechodzące przez pliki tymczasowe. Działa — i widziałem, jak działa źle. Chciałem tego samego efektu z jednym statycznym binarkiem i niczym więcej w obrazie.

Jedna decyzja, która robi porządek
#

Sedno pomysłu: challenge ACME jest obsługiwany od środka środowiska, które certyfikujemy. Proxy nginx, które i tak jest ingressem i routuje po ścieżkach, dostaje jedną regułę więcej: /.well-known/acme-challenge/* trafia do kontenera ACME, a każde inne żądanie — jak dotąd do aplikacji. Kiedy Let’s Encrypt waliduje domenę, rozmawia z tym samym hostem, tym samym load balancerem i tym samym wejściem co prawdziwy ruch użytkowników. Nie ma żadnego bocznego kanału do zbudowania ani niczego dodatkowego wystawionego do internetu — proxy, które zdyskwalifikowało certyfikaty zarządzane, jest dokładnie tym, co czyni własny ACME trywialnym.

To zarazem odpowiedź na pytanie “czemu HTTP-01, a nie DNS-01”. DNS-01 oznaczałby danie serwisowi prawa zapisu do strefy DNS — a tożsamość, która umie przepisywać rekordy DNS, ma dużo większy promień rażenia niż taka, która umie importować certyfikaty do jednego vaulta. Przy HTTP-01 zestaw uprawnień pozostaje minimalny: jeden Key Vault, import certyfikatów, nic więcej.

HTTP-01 ma swoją cenę i warto ją nazwać uczciwie: tokeny challenge’a trzymane są w pamięci, więc serwis musi działać w dokładnie jednej replice — druga instancja odpowiadałaby na challenge 404-ką. Mogłem dodać współdzielony stan i znieść to ograniczenie. Nie dodałem: pomocnik certyfikatów, który odnawia coś raz na miesiąc, nie potrzebuje warstwy koordynacji, tylko mniejszej liczby trybów awarii. Udokumentowane ograniczenie bije obejście inżynierskie, kiedy w praktyce nic nie kosztuje.

Decyzje, których broniłbym na review
#

PEM do PFX w pamięci. Key Vault importuje certyfikaty jako PFX, a oczywista droga do PFX prowadzi przez wywołanie OpenSSL-a. Ta jedna decyzja wciągnęłaby do obrazu shell, menedżer pakietów i zapisywalny filesystem — a materiał klucza prywatnego w pliki tymczasowe. Konwersja w procesie (dla kluczy RSA i ECDSA) to właśnie to, co pozwala obrazowi zostać distroless i działać jako nonroot, z kluczem, który nigdy nie istnieje poza pamięcią procesu.

Klucz konta ACME jest utrwalany. Naiwny klient rejestruje świeże konto Let’s Encrypt przy każdym restarcie. Nic widocznie się nie psuje — ale Let’s Encrypt limituje nowe rejestracje per IP, a ten tryb awarii ujawnia się w najgorszym możliwym momencie: podczas pętli restartów wywołanej zupełnie innym incydentem. Klucz konta leży w Key Vault jako sekret, więc restarty pozostają nudne — tak jak powinny być.

Nieudany cykl przerywa pracę; nigdy nie “asekuruje się” ponownym wystawieniem. Jeśli Key Vault rzuci przejściowym błędem, ruch z pozoru bezpieczny — “nie mogłem potwierdzić certyfikatu, to wystawię nowy” — jest w rzeczywistości tym niebezpiecznym: Let’s Encrypt pozwala na pięć duplikatów certyfikatu tygodniowo, a niestabilna zależność potrafiłaby wydać ten budżet w jedno popołudnie. Nieudany cykl ponawia próbę za godzinę. Limity Let’s Encrypt są zależnością produkcyjną i projekt musi je tak traktować.

Status to dane, nie przeczucia. Odnawianie to wolna pętla — domyślnie budzi się raz na dobę i zwykle stwierdza, że nie ma nic do zrobienia. Sonda liveness nie odpowie na pytanie “czy ostatnie odnowienie faktycznie się udało?”, więc proces żyje pod /healthz, ale rozlicza się pod /status, który zwraca wynik ostatniego cyklu jako JSON. Cokolwiek — dashboard, harmonogramowany check, kolega z curlem — sprawdzi to bez stawiania stosu metryk.

Czego świadomie nie robi
#

Żadnych certyfikatów wildcard (to terytorium DNS-01, z uprawnieniami do strefy DNS, które za nim idą). Żadnego HA na wielu replikach. Żadnych innych chmur. Każda z tych rzeczy to więcej kodu stojącego między Let’s Encrypt a Twoim bindingiem TLS — a narzędzie certyfikatowe zdobywa zaufanie tym, że da się je zaudytować w jeden wieczór. Robi jedną ścieżkę — Let’s Encrypt → PFX → Key Vault → binding w Container Apps — i robi ją przewidywalnie.

Uwagi operacyjne
#

Kilka realiów pracy z produkcyjnym Let’s Encrypt, dziś zakodowanych w sekcji troubleshootingu README:

  • Walidację zaczyna się od staging CA. Produkcyjny limit pięciu nieudanych walidacji na domenę na godzinę nie zostawia miejsca na iterowanie po konfiguracji ingressu. Endpoint stagingowy przechodzi identyczny przepływ, nie wydając tego budżetu.
  • Trasa challenge’a jest warunkiem wstępnym, nie krokiem “na potem”. Brakujący routing nie tylko opóźnia wystawienie — każdy timeout liczy się do limitu walidacji. Procedura wdrożenia traktuje więc routing jako twardy warunek pierwszego uruchomienia.
  • Import i binding to osobne zdarzenia. Odnowiony certyfikat jest widoczny w Key Vault od razu; binding domeny w Container Apps odświeża się w ciągu kilku minut. Monitoring powinien traktować to okno jako normalną propagację, a nie awarię do zbadania.

Zasada ogólniejsza: narzędzie trzymające klucze do TLS powinno samo spełniać standard łańcucha dostaw, który pomaga egzekwować. Wydania to distrolessowe obrazy multi-arch z SBOM-em i proweniencją, podpisane cosignem, a cotygodniowy job ponownie skanuje opublikowany obraz, żeby CVE odkryte po wydaniu wypłynęło bez czekania na kolejny commit.

Jeśli masz tę samą lukę
#

Projekt jest open source na licencji Apache 2.0: github.com/emilgruzalski/acme-az-aca. README przeprowadzi Cię od zera do odnowionego certyfikatu; issues i PR-y mile widziane. A jeśli Twój przypadek mieści się w certyfikatach zarządzanych — serio, użyj ich.