38.6 Release, debug symbol és reprodukálhatóság

Egy natív release nem „a bináris”, hanem egy artefaktumcsomag: a futtatható fájl, a hozzá tartozó hibakeresési adat, a build azonosítója, a függőségi leírás és a telepítési szerződés együtt. Ez a rész ezt a csomagot építi fel, abból a DevOps-szempontból, hogy üzem közben egy összeomlás után vissza kell tudni keresni a pontos forrásállapotot, a hibakeresési információt pedig nem szabad a termelési image-be tenni. A módszerek és a parancsok itt a tanuló laborjában futtathatók; a könyv nem állítja, hogy ezeket a példákat lefuttatta, és a konkrét kapcsolónevek, alapértelmezések a használt GCC/Clang, binutils és CMake kiadásától függenek — ezt minden lépésnél ellenőrizni kell a rögzített laborprofilhoz tartozó dokumentációban.

38.6.1 A release négy tulajdonsága

A release-szerződést négy, egymástól függetlenül ellenőrizhető tulajdonságra bontjuk. Reprodukálhatóság (reproducible build): ugyanaz a forrás, ugyanaz a toolchain-profil, ugyanazok a kapcsolók és ugyanaz a normalizált környezet bitre azonos kimenetet ad. Feltétele, hogy a buildben ne maradjon időbélyeg, abszolút útvonal, véletlen seed vagy nem determinisztikus sorrend. Visszakereshetőség (traceability): a leszállított fájlból egyértelműen eljutunk a forrásállapothoz, a fordítóhoz és a függőségekhez; ezt a build-id és a hozzá kapcsolt metaadat biztosítja. Telepíthetőség (deployability): a csomag tetszőleges prefixbe kicsomagolható, és a dinamikus loader a saját könyvtáraiból oldja fel a függőségeket. Hibakereshetőség (debuggability): a termelési binárison nincs szimbólum, de a hozzá tartozó külön debug fájl alapján a core dump teljes backtrace-re és forrássorra bontható.

A négy tulajdonság közül bármelyik kimarad, a hiány csak üzemzavar után derül ki: a visszakereshetetlen buildnél nem tudjuk, melyik commitot kell javítani; a szimbolizálhatatlan core dumpnál csak címeket látunk; a nem relocatable csomagnál a konténer másik útvonalon nem indul el.

38.6.2 A példaprojekt

A fejezet hátralevő részében egy kéttagú projektet használunk: a libgreet megosztott könyvtárat és a greetctl CLI-t. A kód szándékosan kicsi, hogy a build-szerződés legyen a nehéz rész.

// include/greet/greet.hpp
#pragma once
#include <string>
#include <string_view>

namespace greet {
std::string greeting(std::string_view name);
const char* version() noexcept;
}
// src/greet.cpp
#include "greet/greet.hpp"

namespace greet {
std::string greeting(std::string_view name) {
    return "hello, " + std::string{name};
}
const char* version() noexcept { return GREET_VERSION; }
}
// src/main.cpp
#include "greet/greet.hpp"
#include <cstdio>
#include <string_view>

int main(int argc, char** argv) {
    if (argc == 2 && std::string_view{argv[1]} == "--version") {
        std::printf("greetctl %s\n", greet::version());
        return 0;
    }
    const std::string out = greet::greeting(argc > 1 ? argv[1] : "world");
    std::printf("%s\n", out.c_str());
    return 0;
}

38.6.3 Install, export és relocatable CMake config

A target-alapú CMake telepítési modellje három külön dologból áll: a binárisok fizikai másolása (install(TARGETS ...)), az IMPORTED targeteket leíró export fájl (install(EXPORT ...)) és a csomag belépési pontja (greetConfig.cmake). A relocatable config feltétele, hogy a config fájl ne tartalmazzon abszolút build útvonalat, hanem a saját helyéhez képest számolja a prefixet. Ezt a configure_package_config_file generálja: a @PACKAGE_INIT@ blokk definiálja a PACKAGE_PREFIX_DIR változót a config fájl tényleges helyéből visszafelé számolva, így a csomag bármely prefixbe áthelyezhető.

cmake_minimum_required(VERSION 3.25)
project(greet VERSION 1.4.2 LANGUAGES CXX)

add_library(greet SHARED src/greet.cpp)
add_library(greet::greet ALIAS greet)
target_compile_features(greet PUBLIC cxx_std_20)
target_compile_definitions(greet PRIVATE GREET_VERSION="${PROJECT_VERSION}")
target_include_directories(greet PUBLIC
  $<BUILD_INTERFACE:${CMAKE_CURRENT_SOURCE_DIR}/include>
  $<INSTALL_INTERFACE:include>)
set_target_properties(greet PROPERTIES
  VERSION   ${PROJECT_VERSION}
  SOVERSION 1)

add_executable(greetctl src/main.cpp)
target_link_libraries(greetctl PRIVATE greet::greet)
set_target_properties(greetctl PROPERTIES
  INSTALL_RPATH "$ORIGIN/../lib")

include(GNUInstallDirs)
install(TARGETS greet greetctl
  EXPORT greetTargets
  LIBRARY DESTINATION ${CMAKE_INSTALL_LIBDIR}
  RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR})
install(DIRECTORY include/ DESTINATION ${CMAKE_INSTALL_INCLUDEDIR})
install(EXPORT greetTargets
  NAMESPACE greet::
  DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/greet)

include(CMakePackageConfigHelpers)
configure_package_config_file(cmake/greetConfig.cmake.in
  ${CMAKE_CURRENT_BINARY_DIR}/greetConfig.cmake
  INSTALL_DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/greet)
write_basic_package_version_file(
  ${CMAKE_CURRENT_BINARY_DIR}/greetConfigVersion.cmake
  COMPATIBILITY SameMajorVersion)
install(FILES
  ${CMAKE_CURRENT_BINARY_DIR}/greetConfig.cmake
  ${CMAKE_CURRENT_BINARY_DIR}/greetConfigVersion.cmake
  DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/greet)

A cmake/greetConfig.cmake.in tartalma a @PACKAGE_INIT@ után mindössze két hívás:

@PACKAGE_INIT@
include("${CMAKE_CURRENT_LIST_DIR}/greetTargets.cmake")
check_required_components(greet)

A CMAKE_CURRENT_LIST_DIR használata az, ami prefixfüggetlenné teszi a fájlt. Gyakori hiba, hogy valaki ilyen sort generál bele:

set(GREET_INCLUDE_DIR /home/fejleszto/greet/include)   # HIBA: abszolút útvonal

Ez a csomagot pontosan egy géphez köti.

38.6.4 SONAME, rpath/runpath és runtime függőségek

A SONAME a megosztott könyvtár dinamikus szekciójában tárolt név (DT_SONAME), amelyet a loader a függőségek feloldásához használ. Amikor a greetctl-t a libgreet.so-hoz linkeljük, a linker a DT_NEEDED bejegyzésbe a SONAME-et írja, ha van; a SOVERSION 1 beállítás tipikus hatása a libgreet.so.1 SONAME, a VERSION pedig a libgreet.so.1.4.2 fájlnevet és a symlinkeket állítja elő. A verziózás lényege, hogy egy ABI-kompatibilis javítás ugyanazt a SONAME-et kapja, egy inkompatibilis változás új SOVERSION-t.

A rpath (DT_RPATH) és a runpath (DT_RUNPATH) a binárisba írt könyvtárkeresési bejegyzés. Különbségük sorrendben és hatókörben van: a DT_RPATH-et a loader a LD_LIBRARY_PATH előtt, a DT_RUNPATH-et utána nézi, és a DT_RUNPATH nem tranzitív — csak annak az objektumnak a közvetlen függőségeire érvényes, amelyben szerepel. Ez a legtöbb „működött a gépen, elszállt a konténerben” hiba gyökere. A $ORIGIN tokent a loader annak az objektumnak a könyvtárára cseréli, amelyben a bejegyzés található, ezért a telepített CLI-nél a $ORIGIN/../lib alak a helyes, nem a build könyvtár abszolút útja. Hogy a linker DT_RPATH vagy DT_RUNPATH bejegyzést készít-e, az a --enable-new-dtags / --disable-new-dtags kapcsolótól és a binutils verziójától függ; ezt a laborprofilban rögzíteni kell.

Ellenőrzésre ne az ldd-t használjuk ismeretlen binárison: a glibc ldd implementációja a dynamic loader trace-módjában lefuttatja a programot, ami nem kívánt kódvégrehajtást jelenthet. Helyette readelf -d vagy objdump -p adja a NEEDED, RPATH/RUNPATH és SONAME bejegyzéseket.

38.6.5 Külön debug szimbólumok, build-id és symbolication

A build-id a linker által a kimenet tartalmából számolt azonosító a .note.gnu.build-id szekcióban. Erre épül a symbolication: a GDB a core dumpból kiolvasott build-id alapján keresi meg a hozzá tartozó debug fájlt a debug-könyvtárban vagy egy debuginfod szolgáltatásban. A build-id jelenléte nem automatikus minden platformon, ezért explicit kérés érdemes: -Wl,--build-id=sha1.

A külön debug szimbólum előállítása három lépés: a teljes hibakeresési adat kiemelése, a bináris megfosztása a debug szekcióktól, majd egy .gnu_debuglink szekció beírása, amely a debug fájl nevét és egy CRC32 ellenőrzőértéket tartalmaz. A debug fájl a build-id note-ot is megtartja, így két úton is megtalálható.

f=stage/opt/greet/bin/greetctl
objcopy --only-keep-debug "$f" "$f.debug"
objcopy --strip-debug --add-gnu-debuglink="$f.debug" "$f"

A --strip-debug csak a .debug_* szekciókat távolítja el, a .symtab-ot nem; a strip ennél agresszívabb, és a lokális szimbólumneveket is elveszi, ami a backtrace minőségét rontja. Optimalizált buildben a -g megmarad, de a változók egy része <optimized out> lesz, és az inlineolt hívások több forrássort rendelnek egy frame-hez — ez nem hiba, hanem az optimalizálás következménye. A -gsplit-dwarf külön .dwo fájlokat eredményez, ami a tárolást csökkenti, de a disztribúciót bonyolítja; csak tudatos döntéssel használjuk.

38.6.6 SOURCE_DATE_EPOCH, path mapping és nondeterminism

A reprodukálhatóság azt jelenti, hogy a build bemenetei és a normalizált környezet rögzítése mellett a kimenet bitre azonos. A SOURCE_DATE_EPOCH egy szabványos környezeti változó, amelyet sok eszköz (például ar, tar, egyes fordítók a __DATE__/__TIME__ makróhoz) az időbélyegek helyett használ. A forrása tipikusan a commit időpontja:

export SOURCE_DATE_EPOCH=$(git log -1 --format=%ct)

A path mapping a debug info-ba és a __FILE__ makróba kerülő abszolút útvonalakat írja át. A -ffile-prefix-map=OLD=NEW kapcsoló a debug- és makróútvonalakat egyszerre kezeli; a GCC és a Clang elérhetősége és pontos neve kiadásfüggő (a Clangnál külön -fdebug-prefix-map és -fmacro-prefix-map is létezik). A tipikus beállítás:

-ffile-prefix-map=$PWD=.

Nondeterminizmus forrásai és kezelésük:

Forrás Tünet Kezelés
Abszolút build út a debug infóban két könyvtárból építve eltérő hash -ffile-prefix-map
__DATE__, __TIME__ minden build más SOURCE_DATE_EPOCH, vagy a makrók elhagyása
ar archívum időbélyeg .a fájl eltér determinisztikus mód, ellenőrizni kell
Fordító által generált belső nevek anonim névtér/lambda szimbólumok eltérnek -frandom-seed=<rögzített> (GCC)
LTO párhuzamosság partition-sorrend eltér rögzített -flto=1 vagy determinisztikus beállítás
Nem rendezett fájllista link-sorrend eltér explicit, rögzített forráslista

Az ellenőrzés két, különböző könyvtárból indított build hash-összehasonlítása; eltérés esetén a diffoscope jellegű eszközök mutatják meg a konkrét eltérő szekciót. Fontos: a build-id a tartalom hash-e, tehát ha a tartalom azonos, a build-id is azonos — a build-id önmagában nem nondeterminizmus-forrás.

38.6.7 SBOM és függőségi metaadat

Az SBOM (software bill of materials) géppel olvasható komponenslista: az artefaktumban ténylegesen felhasznált könyvtárak neve, verziója, beszállítója, hash-e és a köztük lévő függőségi élek. Formátuma lehet SPDX vagy CycloneDX; a kötelező mezők halmaza a formátum verziójától függ, ezért a generáló eszközt és annak verzióját a buildprofilban rögzíteni kell. Az SBOM-ot a build-id-hez kell kötni, hogy a leszállított fájl és a sor közötti kapcsolat ellenőrizhető legyen. Az SBOM nem bizonyítja a sebezhetetlenséget, és nem helyettesíti a függőségek tényleges verzióinak ellenőrzését — ezt a témakör határait tiszteletben tartva itt nem tárgyaljuk.

38.6.8 Minimális runtime image, libc baseline és smoke test

A libc baseline az a legrégebbi glibc-verzió, amelyen a bináris hajlandó elindulni. Nem a fordító verziója, hanem a felhasznált szimbólumverziók halmaza határozza meg: a dinamikus loader a legmagasabb GLIBC_x.y igényt ellenőrzi, és ha a célrendszer régebbi, version 'GLIBC_2.34' not found hibával áll le. Ugyanez vonatkozik a libstdc++ GLIBCXX_ és CXXABI_ szimbólumaira, valamint a _GLIBCXX_USE_CXX11_ABI beállításra, amely a std::string ABI-ját befolyásolja. A minimumot így olvassuk ki:

readelf --version-info stage/opt/greet/bin/greetctl \
  | grep -o 'GLIBC_[0-9.]*' | sort -Vu | tail -1

A minimális runtime image csak a szükséges SONAME-eket tartalmazza; a listát a readelf -d NEEDED bejegyzései adják, nem az ldd kimenete. A teljesen statikus linkelés bizonyos libc-funkciók (névfeloldás, dlopen) esetén külön figyelmeztetéseket hordoz, ezért nem alapértelmezett választás.

A smoke test a telepítési kapu: a cél-image-ben lefuttatott minimális parancs, amelynek nem nulla kilépési kódja megbuktatja a deployt. Feltétele, hogy a teszt a valódi image-en fusson, ne a build gépen.

#!/usr/bin/env bash
set -euo pipefail
img="$1"
docker run --rm "$img" /usr/local/bin/greetctl --version
docker run --rm "$img" /usr/local/bin/greetctl Ada

38.6.9 Kidolgozott példa: a teljes pipeline

A példaprojekt buildje a fenti szerződés mind a négy elemét lefedi. A build és a telepítés staged módon történik, hogy a prefix később szabadon mozgatható legyen:

export SOURCE_DATE_EPOCH=$(git log -1 --format=%ct)
cmake -S . -B build -G Ninja \
  -DCMAKE_BUILD_TYPE=RelWithDebInfo \
  -DCMAKE_CXX_FLAGS="-ffile-prefix-map=$PWD=." \
  -DCMAKE_INSTALL_PREFIX=/opt/greet
cmake --build build
DESTDIR="$PWD/stage" cmake --install build

A RelWithDebInfo konfiguráció jellemzően -O2 -g kapcsolókat ad, de ez a CMake verziójától függ, ezért a laborprofilban ellenőrizni kell. A telepítés után a binárist debug szétválasztással látjuk el, majd ellenőrizzük a dinamikus bejegyzéseket:

f=stage/opt/greet/bin/greetctl
objcopy --only-keep-debug "$f" "$f.debug"
objcopy --strip-debug --add-gnu-debuglink="$f.debug" "$f"
readelf -d "$f" | grep -E 'NEEDED|RPATH|RUNPATH'
readelf -n "$f" | grep 'Build ID'

A várt értelmezés: a NEEDED között libgreet.so.1 és a C++ runtime könyvtárai szerepelnek, a RUNPATH pedig $ORIGIN/../lib alakú, nem abszolút. A debug szétválasztás után a greetctl mérete csökken, a greetctl.debug megtartja a build-id note-ot, és a core dump szimbolizálásához a debug fájlt a bináris mellé vagy a debug-könyvtárba kell tenni.

38.6.10 Tipikus hibák

A leggyakoribb, hogy a release pipeline strip-pel „tisztítja” a binárist, és a debug fájlt nem őrzi meg — így a későbbi core dump csak címeket mutat. A második, hogy a CMAKE_INSTALL_PREFIX abszolút értéke bekerül a config fájlba, és a csomag áthelyezés után nem találja magát. A harmadik, hogy a RUNPATH-tól tranzitív feloldást várunk: a libgreet.so.1 saját függőségeit a saját RUNPATH-ja vagy a rendszerkönyvtárak alapján keresi a loader. A negyedik, hogy a SOURCE_DATE_EPOCH-ot beállítjuk, de a kódban marad __TIME__, vagy a verziósztringbe build idő kerül. Az ötödik, hogy ldd-vel ellenőrzünk egy nem megbízható binárist.

38.6.11 Összefoglaló

A release-szerződés négy, külön ellenőrizhető tulajdonságból áll: reprodukálhatóság, visszakereshetőség, telepíthetőség és hibakereshetőség. A CMake export és a configure_package_config_file a prefixfüggetlenséget, a SONAME és a $ORIGIN-alapú RUNPATH a dinamikus feloldást, a build-id és a .gnu_debuglink a symbolicationt, a SOURCE_DATE_EPOCH és a -ffile-prefix-map a determinizmust biztosítja. A deploy kapuja a cél-image-en futtatott smoke test és a libc-baseline ellenőrzése.

38.6.12 Feladatok

1. feladat (magyarázó). Egy kolléga a libgreet.so-t ezzel a kapcsolóval linkeli:

-Wl,-rpath,/home/fejleszto/greet/build

A konténerben a CLI nem indul. Magyarázd meg, miért nem működik, és miért nem orvosolja a problémát az, ha a RUNPATH-ot $ORIGIN-ra cseréljük a greetctl-n, miközben a libgreet.so saját függőségei a build könyvtárra mutatnak.

2. feladat (kódolási). Egészítsd ki a CMakeLists.txt-t úgy, hogy a telepített greetctl $ORIGIN/../lib RUNPATH-ot kapjon, a libgreet SONAME-je libgreet.so.1 legyen, és a debug szétválasztás egy külön telepítési komponensbe kerüljön. Add meg a hozzá tartozó objcopy parancsokat és az ellenőrző readelf hívásokat.

3. feladat (diagnosztika). A readelf -d kivonatban a greetctl NEEDED listája libgreet.so.1, libstdc++.so.6, libc.so.6, RUNPATH értéke $ORIGIN/../lib. A libgreet.so.1 NEEDED listája libm.so.6, libc.so.6, RUNPATH nélkül. Melyik függőség oldódik fel, és melyik nem, ha a libm.so.6 csak a csomag lib/ alkönyvtárában van, a rendszerkönyvtárban nem?

4. feladat (ellenőrzés). Tervezz olyan ellenőrző lépéssort, amely két különböző könyvtárból indított build kimenetéről eldönti, hogy reprodukálható-e, és fogalmazd meg, mit teszel eltérés esetén.

38.6.13 Megoldások

1. megoldás. Az abszolút rpath a build gép könyvtárára mutat, amely a konténerben nem létezik, ezért a loader nem találja a libgreet.so.1-et, és cannot open shared object file hibával leáll. A $ORIGIN-ra cserélés a greetctl-n csak a greetctl közvetlen függőségeit oldja fel, mert a DT_RUNPATH nem tranzitív: a libgreet.so saját függőségeit a saját bejegyzései alapján keresi a loader. Ezért a libgreet.so-t is $ORIGIN-alapú RUNPATH-tal kell linkelni (vagy a függőségeit a rendszerkönyvtárba telepíteni). Ellenőrzési feltétel: a readelf -d mindkét objektumban $ORIGIN-t tartalmazó RUNPATH-ot mutat, és a lib/ könyvtárban minden NEEDED fájl megtalálható.

2. megoldás. A CMakeLists.txt-be a set_target_properties(greetctl PROPERTIES INSTALL_RPATH "$ORIGIN/../lib") és a set_target_properties(greet PROPERTIES SOVERSION 1 VERSION ${PROJECT_VERSION}) sorok kerülnek. A debug szétválasztáshoz a telepítés után futtatjuk az objcopy --only-keep-debug, objcopy --strip-debug --add-gnu-debuglink hármast, majd a .debug fájlt egy külön komponensbe telepítjük. Ellenőrzés: readelf -d a SONAME-ben libgreet.so.1-et, readelf -n build-id note-ot mutat, a .debug fájl pedig a greetctl mellett vagy a debug-könyvtárban elérhető.

3. megoldás. A greetctl közvetlen függőségei (libgreet.so.1, libstdc++.so.6, libc.so.6) a RUNPATH és a rendszerkönyvtárak alapján feloldódnak, ha a libgreet.so.1 a ../lib-ben van. A libm.so.6 viszont a libgreet.so.1 függősége, és mivel annak nincs RUNPATH-ja, a loader csak a rendszerkönyvtárakban keresi — ott pedig nincs, ezért a betöltés meghiúsul. Javítás: a libm.so.6-ot a rendszerkönyvtárba tenni, vagy a libgreet.so.1-et $ORIGIN-alapú RUNPATH-tal újralinkelni.

4. megoldás. Két külön build könyvtárból (build-a, build-b), azonos SOURCE_DATE_EPOCH, azonos prefix-map és azonos toolchain-profil mellett lefordítjuk a projektet, majd sha256sum-mal összehasonlítjuk a leszállított binárist, a könyvtárat és a debug fájlt. Eltérés esetén először a readelf -p .comment és a debug szekciók útvonalai alapján keresünk be nem normalizált abszolút utat, majd a __DATE__/__TIME__ előfordulásokat, végül az archívumok és LTO partitionök sorrendjét vizsgáljuk. Az ellenőrzés akkor tekinthető sikeresnek, ha a hash-ek bitre egyeznek, és a build-id is azonos.