Compare commits
@@ -104,3 +104,95 @@
|
|||||||
[ 1134809ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
|
[ 1134809ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
|
||||||
[ 1136553ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
|
[ 1136553ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
|
||||||
[ 1141191ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
|
[ 1141191ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
|
||||||
|
[ 1158221ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
|
||||||
|
[ 1173507ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
|
||||||
|
[ 1190834ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
|
||||||
|
[ 1198506ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
|
||||||
|
[ 1217761ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
|
||||||
|
[ 1231342ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
|
||||||
|
[ 1233528ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
|
||||||
|
[ 1243017ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
|
||||||
|
[ 1253381ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
|
||||||
|
[ 1256056ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
|
||||||
|
[ 1258513ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
|
||||||
|
[ 1271317ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
|
||||||
|
[ 1284530ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
|
||||||
|
[ 1301830ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
|
||||||
|
[ 1307562ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
|
||||||
|
[ 1310330ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
|
||||||
|
[ 1317970ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
|
||||||
|
[ 1328668ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
|
||||||
|
[ 1336338ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
|
||||||
|
[ 1340155ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
|
||||||
|
[ 1346989ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
|
||||||
|
[ 1348836ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
|
||||||
|
[ 1352113ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
|
||||||
|
[ 1367871ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
|
||||||
|
[ 1371158ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
|
||||||
|
[ 1390720ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
|
||||||
|
[ 1405259ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
|
||||||
|
[ 1420920ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
|
||||||
|
[ 1433518ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
|
||||||
|
[ 1450513ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
|
||||||
|
[ 1453685ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
|
||||||
|
[ 1460654ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
|
||||||
|
[ 1470589ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
|
||||||
|
[ 1489330ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
|
||||||
|
[ 1503666ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
|
||||||
|
[ 1521587ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
|
||||||
|
[ 1537970ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
|
||||||
|
[ 1540426ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
|
||||||
|
[ 1548105ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
|
||||||
|
[ 1549441ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
|
||||||
|
[ 1567671ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
|
||||||
|
[ 1585493ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
|
||||||
|
[ 1597054ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
|
||||||
|
[ 1602669ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
|
||||||
|
[ 1607866ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
|
||||||
|
[ 1623290ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
|
||||||
|
[ 1631460ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
|
||||||
|
[ 1640086ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
|
||||||
|
[ 1645979ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
|
||||||
|
[ 1650742ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
|
||||||
|
[ 1652863ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
|
||||||
|
[ 1663908ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
|
||||||
|
[ 1666587ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
|
||||||
|
[ 1668427ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
|
||||||
|
[ 1676825ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
|
||||||
|
[ 1687882ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
|
||||||
|
[ 1691062ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
|
||||||
|
[ 1698532ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
|
||||||
|
[ 1709597ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
|
||||||
|
[ 1725669ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
|
||||||
|
[ 1739294ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
|
||||||
|
[ 1744412ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
|
||||||
|
[ 1753319ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
|
||||||
|
[ 1761792ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
|
||||||
|
[ 1765298ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
|
||||||
|
[ 1784381ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
|
||||||
|
[ 1786396ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
|
||||||
|
[ 1798982ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
|
||||||
|
[ 1807180ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
|
||||||
|
[ 1823569ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
|
||||||
|
[ 1837292ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
|
||||||
|
[ 1838720ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
|
||||||
|
[ 1848387ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
|
||||||
|
[ 1856847ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
|
||||||
|
[ 1866742ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
|
||||||
|
[ 1879249ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
|
||||||
|
[ 1884700ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
|
||||||
|
[ 1900774ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
|
||||||
|
[ 1912256ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
|
||||||
|
[ 1931088ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
|
||||||
|
[ 1937844ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
|
||||||
|
[ 1948191ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
|
||||||
|
[ 1951360ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
|
||||||
|
[ 1957512ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
|
||||||
|
[ 1958836ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
|
||||||
|
[ 1979011ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
|
||||||
|
[ 1993551ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
|
||||||
|
[ 2011369ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
|
||||||
|
[ 2024782ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
|
||||||
|
[ 2040763ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
|
||||||
|
[ 2044650ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
|
||||||
|
[ 2061337ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 302 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
[ 12202ms] [ERROR] Failed to load resource: the server responded with a status of 401 () @ https://app2.hyeonworks.com/favicon.ico:0
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
[ 451ms] [ERROR] Failed to load resource: the server responded with a status of 401 () @ https://app2.hyeonworks.com/favicon.ico:0
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
[ 14441ms] [ERROR] Failed to load resource: the server responded with a status of 401 () @ chrome-error://chromewebdata/:0
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
[ 296ms] [ERROR] Failed to load resource: the server responded with a status of 401 () @ chrome-error://chromewebdata/:0
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
[ 158ms] [ERROR] Failed to load resource: the server responded with a status of 401 () @ https://app2.hyeonworks.com/favicon.ico:0
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
[ 67ms] [ERROR] Failed to load resource: the server responded with a status of 401 () @ https://app2.hyeonworks.com/favicon.ico:0
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
[ 40ms] [ERROR] Failed to load resource: the server responded with a status of 401 () @ https://app2.hyeonworks.com/oauth2/userinfo:0
|
||||||
|
[ 162ms] [ERROR] Failed to load resource: the server responded with a status of 401 () @ https://app2.hyeonworks.com/favicon.ico:0
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
[ 117ms] [ERROR] Failed to load resource: the server responded with a status of 401 () @ chrome-error://chromewebdata/:0
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
- generic [ref=f68e3]:
|
||||||
|
- banner [ref=f68e4]:
|
||||||
|
- generic [ref=f68e5]: keycloak-patterns
|
||||||
|
- main [ref=f68e6]:
|
||||||
|
- heading "Sign in to your account" [level=1] [ref=f68e8]
|
||||||
|
- generic [ref=f68e12]:
|
||||||
|
- generic [ref=f68e13]:
|
||||||
|
- generic [ref=f68e14]: Username or email
|
||||||
|
- textbox "Username or email" [ref=f68e17]
|
||||||
|
- generic [ref=f68e18]:
|
||||||
|
- generic [ref=f68e19]: Password
|
||||||
|
- generic [ref=f68e21]:
|
||||||
|
- textbox "Password" [ref=f68e24]
|
||||||
|
- button "Show password" [ref=f68e26] [cursor=pointer]:
|
||||||
|
- generic [aria-hidden] [ref=f68e27]:
|
||||||
|
- button "Sign In" [ref=f68e30] [cursor=pointer]
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
- generic [active] [ref=f69e1]: "{ \"headers\" : { \"host\" : [ \"app2.hyeonworks.com\" ], \"user-agent\" : [ \"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/152.0.0.0 Safari/537.36\" ], \"accept\" : [ \"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7\" ], \"accept-encoding\" : [ \"gzip, deflate, br, zstd\" ], \"accept-language\" : [ \"en-US,en;q=0.9\" ], \"cache-control\" : [ \"max-age=0\" ], \"cookie\" : [ \"grafana_session=bfb5f2c2406b3f163ea01063ac1b8b83; grafana_session_expiry=1788508427; _oauth2_proxy=djIuWDI5aGRYUm9NbDl3Y205NGVTMHhNamhoWXprNFpHSmxORGRoT0RObU16UmlNemMxTTJJd016ZGlZakpqTWcuQVVUYldxYkFlejYxNXozUUZBcGh1dw==|1788508274|biz4o9E4vZXX07LnF32LU7tIEMrvfwxwCIBsZkxpwbY=\" ], \"priority\" : [ \"u=0, i\" ], \"sec-ch-ua\" : [ \"\\\"Chromium\\\";v=\\\"152\\\", \\\"Not?A_Brand\\\";v=\\\"24\\\", \\\"Google Chrome\\\";v=\\\"152\\\"\" ], \"sec-ch-ua-mobile\" : [ \"?0\" ], \"sec-ch-ua-platform\" : [ \"\\\"Linux\\\"\" ], \"sec-fetch-dest\" : [ \"document\" ], \"sec-fetch-mode\" : [ \"navigate\" ], \"sec-fetch-site\" : [ \"same-site\" ], \"sec-fetch-user\" : [ \"?1\" ], \"upgrade-insecure-requests\" : [ \"1\" ], \"x-forwarded-email\" : [ \"labuser@example.com\" ], \"x-forwarded-host\" : [ \"app2.hyeonworks.com\" ], \"x-forwarded-port\" : [ \"443\" ], \"x-forwarded-preferred-username\" : [ \"labuser\" ], \"x-forwarded-proto\" : [ \"https\" ], \"x-forwarded-server\" : [ \"traefik-5d6fcf895-wpfhr\" ], \"x-forwarded-user\" : [ \"27df5ea9-8703-4ec5-badd-d972c583e1ff\" ], \"x-real-ip\" : [ \"100.123.124.30\" ] }, \"remoteAddr\" : \"100.123.124.30\", \"localAddr\" : \"10.42.1.132\", \"scheme\" : \"https\", \"secure\" : true, \"serverName\" : \"app2.hyeonworks.com\", \"serverPort\" : 443, \"requestUrl\" : \"https://app2.hyeonworks.com/api/echo\" }"
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
- generic [ref=f70e1]: "{ \"headers\" : { \"host\" : [ \"app2.hyeonworks.com\" ], \"user-agent\" : [ \"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/152.0.0.0 Safari/537.36\" ], \"accept\" : [ \"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7\" ], \"accept-encoding\" : [ \"gzip, deflate, br, zstd\" ], \"accept-language\" : [ \"en-US,en;q=0.9\" ], \"cookie\" : [ \"grafana_session=bfb5f2c2406b3f163ea01063ac1b8b83; grafana_session_expiry=1788508427; _oauth2_proxy=djIuWDI5aGRYUm9NbDl3Y205NGVTMWhOakZqTlRjM1ptSTVORFF5TVdWbU1qQmpaak5pWXpka1pXRTFPR1F6WWcuOXF3TmRyQzJIOFlrSDh6T1pTMl91QQ==|1788508374|Ehcd4hjNk0noEy2MSPI-C5jg84JZmilWcKhuj8OH3uw=\" ], \"priority\" : [ \"u=0, i\" ], \"sec-ch-ua\" : [ \"\\\"Chromium\\\";v=\\\"152\\\", \\\"Not?A_Brand\\\";v=\\\"24\\\", \\\"Google Chrome\\\";v=\\\"152\\\"\" ], \"sec-ch-ua-mobile\" : [ \"?0\" ], \"sec-ch-ua-platform\" : [ \"\\\"Linux\\\"\" ], \"sec-fetch-dest\" : [ \"document\" ], \"sec-fetch-mode\" : [ \"navigate\" ], \"sec-fetch-site\" : [ \"none\" ], \"sec-fetch-user\" : [ \"?1\" ], \"upgrade-insecure-requests\" : [ \"1\" ], \"x-forwarded-email\" : [ \"changed-labuser@example.com\" ], \"x-forwarded-host\" : [ \"app2.hyeonworks.com\" ], \"x-forwarded-port\" : [ \"443\" ], \"x-forwarded-preferred-username\" : [ \"labuser\" ], \"x-forwarded-proto\" : [ \"https\" ], \"x-forwarded-server\" : [ \"traefik-5d6fcf895-wpfhr\" ], \"x-forwarded-user\" : [ \"27df5ea9-8703-4ec5-badd-d972c583e1ff\" ], \"x-real-ip\" : [ \"100.123.124.30\" ] }, \"remoteAddr\" : \"100.123.124.30\", \"localAddr\" : \"10.42.0.53\", \"scheme\" : \"https\", \"secure\" : true, \"serverName\" : \"app2.hyeonworks.com\", \"serverPort\" : 443, \"requestUrl\" : \"https://app2.hyeonworks.com/api/echo\" }"
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
- generic [ref=e3]:
|
||||||
|
- banner [ref=e4]:
|
||||||
|
- generic [ref=e5]: keycloak-patterns
|
||||||
|
- main [ref=e6]:
|
||||||
|
- heading "Sign in to your account" [level=1] [ref=e8]
|
||||||
|
- generic [ref=e12]:
|
||||||
|
- generic [ref=e13]:
|
||||||
|
- generic [ref=e14]: Username or email
|
||||||
|
- textbox "Username or email" [active] [ref=e17]
|
||||||
|
- generic [ref=e18]:
|
||||||
|
- generic [ref=e19]: Password
|
||||||
|
- generic [ref=e21]:
|
||||||
|
- textbox "Password" [ref=e24]
|
||||||
|
- button "Show password" [ref=e26] [cursor=pointer]:
|
||||||
|
- generic [aria-hidden] [ref=e27]:
|
||||||
|
- button "Sign In" [ref=e30] [cursor=pointer]
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
- generic [ref=f1e3]:
|
||||||
|
- generic [ref=f1e6]:
|
||||||
|
- heading "This page isn’t working" [level=1] [ref=f1e7]
|
||||||
|
- paragraph [ref=f1e8]: If the problem continues, contact the site owner.
|
||||||
|
- generic [ref=f1e9]: HTTP ERROR 401
|
||||||
|
- button "Reload" [ref=f1e12] [cursor=pointer]
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
- generic [active] [ref=f3e1]: "{\"user\":\"27df5ea9-8703-4ec5-badd-d972c583e1ff\",\"email\":\"labuser@example.com\",\"preferredUsername\":\"labuser\"}"
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
- generic [active] [ref=f4e1]: "{\"user\":\"27df5ea9-8703-4ec5-badd-d972c583e1ff\",\"email\":\"labuser@example.com\",\"preferredUsername\":\"labuser\"}"
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
- generic [active] [ref=f5e1]: Unauthorized
|
||||||
@@ -18,15 +18,23 @@
|
|||||||
브라우저 / SSH (tailnet)
|
브라우저 / SSH (tailnet)
|
||||||
│ https://{auth,app1,app2}.hyeonworks.com → 100.83.212.4
|
│ https://{auth,app1,app2}.hyeonworks.com → 100.83.212.4
|
||||||
▼
|
▼
|
||||||
lab host ── nginx :443 TLS 종료 · X-Forwarded-* 주입
|
lab host ── nftables DNAT :80,:443 → 192.168.122.10
|
||||||
│ nginx :80 301 → https
|
│ (물리 호스트가 실험대를 위해 하는 일의 전부)
|
||||||
│
|
│
|
||||||
│ virbr0 192.168.122.0/24 (libvirt NAT)
|
│ virbr0 192.168.122.0/24 (libvirt NAT)
|
||||||
├──▶ kc-lab-1 .11 k3s server Traefik :80
|
├──▶ kc-lab-edge .10 nginx :443 TLS 종료 · X-Forwarded-* 주입
|
||||||
└──▶ kc-lab-2 .12 k3s agent Traefik :80
|
│ │ nginx :80 301 → https
|
||||||
└──▶ Pod
|
│ │ certbot · 갱신 타이머 · deploy 훅
|
||||||
|
│ ├──▶ kc-lab-1 .11 Traefik :80 ──▶ Pod
|
||||||
|
│ └──▶ kc-lab-2 .12 Traefik :80 ──▶ Pod
|
||||||
|
├──▶ kc-lab-1 .11 k3s server
|
||||||
|
└──▶ kc-lab-2 .12 k3s agent
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**L7 홉은 두 겹 그대로다**(엣지 nginx → Traefik). 앞에 늘어난 것은 커널이
|
||||||
|
하는 L4 전달 한 번뿐이고, 그 대가로 **인증서·nginx 설정·certbot 이 전부
|
||||||
|
일회용 게스트 안**으로 들어갔다.
|
||||||
|
|
||||||
`nginx → Traefik` **2홉**이 운영 구조와 같다는 점이 이 배치의 핵심이다.
|
`nginx → Traefik` **2홉**이 운영 구조와 같다는 점이 이 배치의 핵심이다.
|
||||||
L7 프록시가 두 겹인 이유는 역할이 다르기 때문이다 — nginx는 바깥세상과의
|
L7 프록시가 두 겹인 이유는 역할이 다르기 때문이다 — nginx는 바깥세상과의
|
||||||
접점(TLS·인증서·헤더)을, Traefik은 클러스터 내부의 동적 라우팅을 맡는다.
|
접점(TLS·인증서·헤더)을, Traefik은 클러스터 내부의 동적 라우팅을 맡는다.
|
||||||
@@ -36,7 +44,11 @@ L7 프록시가 두 겹인 이유는 역할이 다르기 때문이다 — nginx
|
|||||||
| 경로 | 역할 |
|
| 경로 | 역할 |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `cloud-init/kc-lab.yaml.example` | 게스트 부트스트랩 템플릿 |
|
| `cloud-init/kc-lab.yaml.example` | 게스트 부트스트랩 템플릿 |
|
||||||
| `host/nginx-keycloak-lab.conf` | lab host의 `sites-available/keycloak-lab` |
|
| `edge/nginx-keycloak-lab.conf` | `kc-lab-edge` 의 `sites-available/keycloak-lab` |
|
||||||
|
| `edge/reload-nginx.sh` | certbot deploy 훅. 없으면 갱신이 서빙에 반영되지 않는다 (D-4) |
|
||||||
|
| `edge/lab-edge-dnat.nft` | 물리 호스트의 유일한 트래픽 규칙 |
|
||||||
|
| `edge/lab-edge-dnat.service` | 위 규칙을 부팅 때 적용 |
|
||||||
|
| `scripts/migrate-to-edge.sh` | 엣지 계층을 호스트에서 게스트로 옮긴다 |
|
||||||
| `k8s/echo.yaml` | 2홉 헤더 계약 측정용 워크로드 |
|
| `k8s/echo.yaml` | 2홉 헤더 계약 측정용 워크로드 |
|
||||||
| `scripts/rebuild-seed.sh` | cloud-init 시드 ISO 재생성 + 풀 업로드 |
|
| `scripts/rebuild-seed.sh` | cloud-init 시드 ISO 재생성 + 풀 업로드 |
|
||||||
| `scripts/build-and-import.sh` | 이미지 빌드 → 각 노드 containerd 반입 |
|
| `scripts/build-and-import.sh` | 이미지 빌드 → 각 노드 containerd 반입 |
|
||||||
|
|||||||
@@ -17,7 +17,12 @@ users:
|
|||||||
shell: /bin/bash
|
shell: /bin/bash
|
||||||
# NOPASSWD is required: the k3s installer and the fault-injection scripts
|
# NOPASSWD is required: the k3s installer and the fault-injection scripts
|
||||||
# run non-interactively and would block on a password prompt.
|
# run non-interactively and would block on a password prompt.
|
||||||
sudo: ['ALL=(ALL) NOPASSWD:ALL']
|
#
|
||||||
|
# A string, not a list. The list form still boots, but `cloud-init schema -c`
|
||||||
|
# (22.4.2 on the guests) rejects it and prints the whole users.0 block with
|
||||||
|
# "is not valid under any of the given schemas" — naming no key. That makes
|
||||||
|
# the guide's own validation step look broken when it is not.
|
||||||
|
sudo: "ALL=(ALL) NOPASSWD:ALL"
|
||||||
# Console-only escape hatch. Without it, a cloud-init failure leaves a guest
|
# Console-only escape hatch. Without it, a cloud-init failure leaves a guest
|
||||||
# that cannot be logged into at all, so its own failure log is unreadable.
|
# that cannot be logged into at all, so its own failure log is unreadable.
|
||||||
# ssh_pwauth stays false, so this never widens SSH exposure.
|
# ssh_pwauth stays false, so this never widens SSH exposure.
|
||||||
@@ -35,3 +40,9 @@ package_update: true
|
|||||||
packages:
|
packages:
|
||||||
- curl
|
- curl
|
||||||
- nftables
|
- nftables
|
||||||
|
# kc-lab-edge only. The k3s nodes do not need these, and the edge does not need
|
||||||
|
# anything else — nginx terminates TLS and certbot renews the certificate, both
|
||||||
|
# inside this disposable guest.
|
||||||
|
# - nginx
|
||||||
|
# - certbot
|
||||||
|
# - python3-certbot-dns-cloudflare
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
#!/usr/sbin/nft -f
|
||||||
|
# Forward the tailnet entry point to the edge guest.
|
||||||
|
#
|
||||||
|
# This is the ONLY lab traffic rule the physical host carries. Everything else
|
||||||
|
# that used to live here — nginx config, certificates, certbot, the deploy hook
|
||||||
|
# — now lives on kc-lab-edge and is destroyed with it.
|
||||||
|
#
|
||||||
|
# DNAT only, never SNAT. The guests' default route is the host, so replies come
|
||||||
|
# back through here and conntrack reverses the translation on its own. Adding a
|
||||||
|
# masquerade would rewrite the source and the edge would see 192.168.122.1 for
|
||||||
|
# every client — which would silently invalidate the X-Forwarded-For contract
|
||||||
|
# that this lab measures.
|
||||||
|
#
|
||||||
|
# PREROUTING nat runs before the routing decision, so this wins over any local
|
||||||
|
# socket on :80/:443. That makes the cutover atomic and the rollback a single
|
||||||
|
# `nft delete table ip lab_edge`.
|
||||||
|
|
||||||
|
table ip lab_edge
|
||||||
|
delete table ip lab_edge
|
||||||
|
|
||||||
|
table ip lab_edge {
|
||||||
|
chain prerouting {
|
||||||
|
type nat hook prerouting priority dstnat; policy accept;
|
||||||
|
iifname "tailscale0" tcp dport { 80, 443 } dnat to 192.168.122.10
|
||||||
|
}
|
||||||
|
|
||||||
|
# libvirt's own forward rules accept RELATED,ESTABLISHED into the guest
|
||||||
|
# subnet but not a NEW inbound connection. This runs ahead of them.
|
||||||
|
chain forward {
|
||||||
|
type filter hook forward priority filter - 10; policy accept;
|
||||||
|
ip daddr 192.168.122.10 tcp dport { 80, 443 } ct state new accept
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Lab edge DNAT (tailnet :80/:443 -> kc-lab-edge)
|
||||||
|
After=network-online.target libvirtd.service
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
RemainAfterExit=yes
|
||||||
|
ExecStart=/usr/sbin/nft -f /etc/nftables.d/lab-edge-dnat.nft
|
||||||
|
ExecStop=/usr/sbin/nft delete table ip lab_edge
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
@@ -25,8 +25,10 @@ server {
|
|||||||
}
|
}
|
||||||
|
|
||||||
server {
|
server {
|
||||||
listen 443 ssl default_server;
|
# The http2 parameter of listen, not the separate `http2 on;` directive:
|
||||||
http2 on;
|
# that directive needs nginx >= 1.25.1 and the edge guest is Debian 12
|
||||||
|
# (nginx 1.22). This form works on both and is what the lab actually runs.
|
||||||
|
listen 443 ssl http2 default_server;
|
||||||
server_name _;
|
server_name _;
|
||||||
|
|
||||||
# fullchain.pem, never cert.pem: omitting the intermediates passes on
|
# fullchain.pem, never cert.pem: omitting the intermediates passes on
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# certbot deploy hook. Install as
|
||||||
|
# /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh (chmod +x)
|
||||||
|
#
|
||||||
|
# deploy/ runs only when a certificate was actually renewed (RENEWED_LINEAGE is
|
||||||
|
# set). post/ would run twice a day whether or not anything changed, reloading
|
||||||
|
# nginx for nothing.
|
||||||
|
#
|
||||||
|
# Without this, D-4 measured the failure exactly: the renewal succeeds, the
|
||||||
|
# timer reports SUCCESS, and the old certificate keeps being served for 38m25s
|
||||||
|
# — with no error anywhere.
|
||||||
|
nginx -t && nginx -s reload
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Remove the lab's host layer from test-server. Packages stay.
|
||||||
|
#
|
||||||
|
# sudo bash deploy/lab/host/teardown-host.sh
|
||||||
|
#
|
||||||
|
# The host's sudo asks for a password, so run this in a terminal — not over a
|
||||||
|
# non-interactive ssh, where sudo fails silently into an empty result.
|
||||||
|
#
|
||||||
|
# ★ Certificates are BACKED UP, NOT DELETED. Let's Encrypt allows 5 duplicate
|
||||||
|
# certificates per week for the same name set, and this lab's names resolve to
|
||||||
|
# a tailnet address (100.64.0.0/10, not routable from the public internet), so
|
||||||
|
# an HTTP-01 reissue cannot be validated from here. Deleting the files turns a
|
||||||
|
# free restore into a problem that has to be solved first. Restoring is:
|
||||||
|
#
|
||||||
|
# sudo tar xzf ~/letsencrypt-backup-<stamp>.tgz -C /etc
|
||||||
|
set -u
|
||||||
|
|
||||||
|
STAMP="$(date +%Y%m%d-%H%M%S)"
|
||||||
|
HOME_DIR="${SUDO_USER:+/home/$SUDO_USER}"
|
||||||
|
HOME_DIR="${HOME_DIR:-$HOME}"
|
||||||
|
|
||||||
|
echo "===== 1) 인증서 백업 (지우지 않는다) ====="
|
||||||
|
if [ -d /etc/letsencrypt ]; then
|
||||||
|
out="$HOME_DIR/letsencrypt-backup-$STAMP.tgz"
|
||||||
|
tar czf "$out" -C /etc letsencrypt
|
||||||
|
chown "${SUDO_USER:-root}" "$out"
|
||||||
|
echo "백업: $out ($(du -h "$out" | cut -f1))"
|
||||||
|
echo "현재 인증서:"
|
||||||
|
certbot certificates 2>/dev/null | grep -E "Certificate Name|Domains|Expiry Date" || true
|
||||||
|
echo "검증 방식 (재발급이 되는지의 답):"
|
||||||
|
grep -H authenticator /etc/letsencrypt/renewal/*.conf 2>/dev/null || echo " (renewal 설정 없음)"
|
||||||
|
else
|
||||||
|
echo "/etc/letsencrypt 없음 — 건너뜀"
|
||||||
|
fi
|
||||||
|
echo
|
||||||
|
|
||||||
|
echo "===== 2) nginx 실험대 설정 제거 ====="
|
||||||
|
if [ -f /etc/nginx/sites-available/keycloak-lab ]; then
|
||||||
|
cp /etc/nginx/sites-available/keycloak-lab "$HOME_DIR/keycloak-lab.nginx.$STAMP.bak"
|
||||||
|
echo "백업: $HOME_DIR/keycloak-lab.nginx.$STAMP.bak"
|
||||||
|
fi
|
||||||
|
rm -fv /etc/nginx/sites-enabled/keycloak-lab
|
||||||
|
rm -fv /etc/nginx/sites-available/keycloak-lab
|
||||||
|
systemctl disable --now nginx
|
||||||
|
echo
|
||||||
|
|
||||||
|
echo "===== 3) certbot 갱신 타이머 정지 ====="
|
||||||
|
# 인증서 파일은 남기지만, 갱신 시도는 멈춘다. 지금 DNS 로는 HTTP-01 검증이
|
||||||
|
# 실패하고, 실패가 로그에만 쌓이면서 「왜 안 되지」의 원인이 된다.
|
||||||
|
systemctl disable --now certbot-renew.timer 2>/dev/null || true
|
||||||
|
echo
|
||||||
|
|
||||||
|
echo "===== 4) 엣지 DNAT (있으면) ====="
|
||||||
|
systemctl disable --now lab-edge-dnat.service 2>/dev/null || true
|
||||||
|
rm -fv /etc/systemd/system/lab-edge-dnat.service /etc/nftables.d/lab-edge-dnat.nft
|
||||||
|
systemctl daemon-reload
|
||||||
|
nft delete table ip lab_edge 2>/dev/null || true
|
||||||
|
echo
|
||||||
|
|
||||||
|
echo "===== 5) 확인 ====="
|
||||||
|
echo "-- nginx: $(systemctl is-active nginx) / $(systemctl is-enabled nginx 2>&1)"
|
||||||
|
echo "-- certbot timer: $(systemctl is-active certbot-renew.timer 2>&1) / $(systemctl is-enabled certbot-renew.timer 2>&1)"
|
||||||
|
echo "-- 80/443 리스너:"; ss -tlnp | grep -E ':(80|443) ' || echo " (없음 — 정상)"
|
||||||
|
echo "-- sites-enabled:"; ls -A /etc/nginx/sites-enabled 2>/dev/null || echo " (비었음 — 정상)"
|
||||||
|
echo "-- letsencrypt:"; ls /etc/letsencrypt/live 2>/dev/null || echo " (없음)"
|
||||||
|
echo "-- libvirt 도메인:"; virsh list --all 2>/dev/null | tail -n +3 | grep -v '^$' || echo " (없음 — 정상)"
|
||||||
|
echo
|
||||||
|
echo "완료. 패키지(nginx · libvirt · qemu · certbot · kubectl)와 base.qcow2 는 남아 있다."
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 860 400" font-family="-apple-system,BlinkMacSystemFont,Segoe UI,Helvetica,sans-serif">
|
||||||
|
<style>
|
||||||
|
.box{fill:#fff;stroke:#8c959f;stroke-width:1.4;rx:6}
|
||||||
|
.dead{fill:#fff5f5;stroke:#cf222e;stroke-width:1.6;stroke-dasharray:5 3;rx:6}
|
||||||
|
.ok{fill:#f6fdf6;stroke:#1a7f37;stroke-width:1.6;rx:6}
|
||||||
|
.warn{fill:#fff8f0;stroke:#bf8700;stroke-width:1.5;rx:6}
|
||||||
|
.t{font-size:12px;fill:#1f2328}
|
||||||
|
.tb{font-size:12.5px;fill:#1f2328;font-weight:600}
|
||||||
|
.s{font-size:10.5px;fill:#656d76}
|
||||||
|
.ttl{font-size:14px;fill:#1f2328;font-weight:600}
|
||||||
|
.r{stroke:#cf222e;stroke-width:1.6;fill:none}
|
||||||
|
.g{stroke:#1a7f37;stroke-width:1.6;fill:none}
|
||||||
|
.k{stroke:#8c959f;stroke-width:1.3;fill:none}
|
||||||
|
</style>
|
||||||
|
<defs>
|
||||||
|
<marker id="a" markerWidth="8" markerHeight="8" refX="7" refY="3" orient="auto"><path d="M0,0 L7,3 L0,6 z" fill="#8c959f"/></marker>
|
||||||
|
<marker id="ar" markerWidth="8" markerHeight="8" refX="7" refY="3" orient="auto"><path d="M0,0 L7,3 L0,6 z" fill="#cf222e"/></marker>
|
||||||
|
<marker id="ag" markerWidth="8" markerHeight="8" refX="7" refY="3" orient="auto"><path d="M0,0 L7,3 L0,6 z" fill="#1a7f37"/></marker>
|
||||||
|
</defs>
|
||||||
|
<text x="20" y="26" class="ttl">A-0 — 세션을 공유하는 것은 Infinispan 이 아니라 PostgreSQL 이다</text>
|
||||||
|
<text x="20" y="44" class="s">클러스터가 형성됐다는 것과 세션이 복제된다는 것은 다른 얘기였다</text>
|
||||||
|
<rect x="30" y="76" width="330" height="120" class="box"/>
|
||||||
|
<text x="46" y="98" class="tb">keycloak-0 (kc-lab-2)</text>
|
||||||
|
<rect x="46" y="112" width="298" height="34" class="box"/>
|
||||||
|
<text x="58" y="134" class="t">Infinispan sessions 캐시</text>
|
||||||
|
<text x="46" y="166" class="s">자기가 처리한 로그인만 들어 있다</text>
|
||||||
|
<text x="46" y="184" class="s">entries_unique 는 노드마다 다르다</text>
|
||||||
|
<rect x="500" y="76" width="330" height="120" class="box"/>
|
||||||
|
<text x="516" y="98" class="tb">keycloak-1 (kc-lab-1)</text>
|
||||||
|
<rect x="516" y="112" width="298" height="34" class="box"/>
|
||||||
|
<text x="528" y="134" class="t">Infinispan sessions 캐시</text>
|
||||||
|
<text x="516" y="166" class="s">여기에도 자기 것만 있다</text>
|
||||||
|
<text x="516" y="184" class="s">상대 세션은 들어오지 않는다</text>
|
||||||
|
<path d="M362 130 L498 130" class="r" stroke-dasharray="5 3" marker-end="url(#ar)"/>
|
||||||
|
<path d="M498 146 L362 146" class="r" stroke-dasharray="5 3" marker-end="url(#ar)"/>
|
||||||
|
<text x="430" y="122" class="tb" text-anchor="middle" fill="#cf222e">복제 없음</text>
|
||||||
|
<text x="430" y="170" class="s" text-anchor="middle" fill="#cf222e">세션 엔트리는 노드 사이를 건너가지 않는다</text>
|
||||||
|
<rect x="300" y="250" width="260" height="76" class="ok"/>
|
||||||
|
<text x="316" y="272" class="tb">PostgreSQL</text>
|
||||||
|
<text x="316" y="292" class="s">OFFLINE_USER_SESSION (offline_flag='0')</text>
|
||||||
|
<text x="316" y="310" class="s">두 노드가 같은 행을 본다</text>
|
||||||
|
<path d="M150 198 L330 246" class="g" marker-end="url(#ag)"/>
|
||||||
|
<path d="M710 198 L530 246" class="g" marker-end="url(#ag)"/>
|
||||||
|
<text x="180" y="232" class="s">SELECT / INSERT</text>
|
||||||
|
<text x="600" y="232" class="s">SELECT / INSERT</text>
|
||||||
|
<rect x="30" y="346" width="800" height="40" class="box"/>
|
||||||
|
<text x="46" y="370" class="s">근거 — 노드 A 로 로그인하고 노드 B 로 refresh 했을 때, 반대편 노드가 날린 SQL 을 문장 로깅으로 직접 잡았다</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 3.2 KiB |
@@ -0,0 +1,47 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 860 430" font-family="-apple-system,BlinkMacSystemFont,Segoe UI,Helvetica,sans-serif">
|
||||||
|
<style>
|
||||||
|
.box{fill:#fff;stroke:#8c959f;stroke-width:1.4;rx:6}
|
||||||
|
.dead{fill:#fff5f5;stroke:#cf222e;stroke-width:1.6;stroke-dasharray:5 3;rx:6}
|
||||||
|
.ok{fill:#f6fdf6;stroke:#1a7f37;stroke-width:1.6;rx:6}
|
||||||
|
.warn{fill:#fff8f0;stroke:#bf8700;stroke-width:1.5;rx:6}
|
||||||
|
.t{font-size:12px;fill:#1f2328}
|
||||||
|
.tb{font-size:12.5px;fill:#1f2328;font-weight:600}
|
||||||
|
.s{font-size:10.5px;fill:#656d76}
|
||||||
|
.ttl{font-size:14px;fill:#1f2328;font-weight:600}
|
||||||
|
.r{stroke:#cf222e;stroke-width:1.6;fill:none}
|
||||||
|
.g{stroke:#1a7f37;stroke-width:1.6;fill:none}
|
||||||
|
.k{stroke:#8c959f;stroke-width:1.3;fill:none}
|
||||||
|
</style>
|
||||||
|
<defs>
|
||||||
|
<marker id="a" markerWidth="8" markerHeight="8" refX="7" refY="3" orient="auto"><path d="M0,0 L7,3 L0,6 z" fill="#8c959f"/></marker>
|
||||||
|
<marker id="ar" markerWidth="8" markerHeight="8" refX="7" refY="3" orient="auto"><path d="M0,0 L7,3 L0,6 z" fill="#cf222e"/></marker>
|
||||||
|
<marker id="ag" markerWidth="8" markerHeight="8" refX="7" refY="3" orient="auto"><path d="M0,0 L7,3 L0,6 z" fill="#1a7f37"/></marker>
|
||||||
|
</defs>
|
||||||
|
<text x="20" y="26" class="ttl">A-1 — 7800 을 막아도 세션 공유는 안 깨진다. 깨지는 것은 로그아웃 전파다</text>
|
||||||
|
<text x="20" y="44" class="s">예측 하나가 빗나갔고, 예상 못 한 것이 둘 나왔다</text>
|
||||||
|
<rect x="30" y="72" width="300" height="104" class="box"/>
|
||||||
|
<text x="46" y="94" class="tb">keycloak-0</text>
|
||||||
|
<text x="46" y="116" class="s">로그인 처리 · 세션을 DB 에 쓴다</text>
|
||||||
|
<rect x="530" y="72" width="300" height="104" class="box"/>
|
||||||
|
<text x="546" y="94" class="tb">keycloak-1</text>
|
||||||
|
<text x="546" y="116" class="s">DB 를 읽어 같은 세션을 안다</text>
|
||||||
|
<rect x="352" y="86" width="156" height="34" class="dead"/>
|
||||||
|
<text x="430" y="107" class="t" text-anchor="middle" fill="#cf222e">TCP 7800 차단</text>
|
||||||
|
<text x="430" y="140" class="s" text-anchor="middle">NetworkPolicy 는 허용목록이다</text>
|
||||||
|
<text x="430" y="156" class="s" text-anchor="middle">8080·9000 만 열고 7800 은 누락시킨다</text>
|
||||||
|
<rect x="300" y="206" width="260" height="64" class="ok"/>
|
||||||
|
<text x="316" y="228" class="tb">PostgreSQL</text>
|
||||||
|
<text x="316" y="248" class="s">세션은 여기 있다 → 교차 노드 refresh 200</text>
|
||||||
|
<path d="M150 178 L320 202" class="g" marker-end="url(#ag)"/>
|
||||||
|
<path d="M710 178 L540 202" class="g" marker-end="url(#ag)"/>
|
||||||
|
<rect x="30" y="290" width="390" height="76" class="dead"/>
|
||||||
|
<text x="46" y="312" class="tb">깨진 것 — 로그아웃 전파</text>
|
||||||
|
<text x="46" y="332" class="s">무효화 통지가 7800 을 탄다</text>
|
||||||
|
<text x="46" y="350" class="s">400 이어야 할 refresh 가 200 을 반환했다</text>
|
||||||
|
<rect x="440" y="290" width="390" height="76" class="warn"/>
|
||||||
|
<text x="456" y="312" class="tb">★ 주입이 먹지 않았다 — conntrack</text>
|
||||||
|
<text x="456" y="332" class="s">ESTABLISHED 연결은 규칙 평가를 건너뛴다</text>
|
||||||
|
<text x="456" y="350" class="s">cluster_size 가 25분간 2 로 남았다 → 삭제 후에야 갈렸다</text>
|
||||||
|
<text x="30" y="392" class="s">그리고 예상 못 한 둘째 — 분단된 노드가 readiness 실패로 스스로 로드밸런서에서 빠진다</text>
|
||||||
|
<text x="30" y="410" class="s">덕분에 외부에서는 장애가 보이지 않는다. 관측 지점을 밖에만 두면 이 실험은 「아무 일도 없음」으로 보인다</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 3.3 KiB |
@@ -0,0 +1,55 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 860 400" font-family="-apple-system,BlinkMacSystemFont,Segoe UI,Helvetica,sans-serif">
|
||||||
|
<style>
|
||||||
|
.box{fill:#fff;stroke:#8c959f;stroke-width:1.4;rx:6}
|
||||||
|
.dead{fill:#fff5f5;stroke:#cf222e;stroke-width:1.6;stroke-dasharray:5 3;rx:6}
|
||||||
|
.ok{fill:#f6fdf6;stroke:#1a7f37;stroke-width:1.6;rx:6}
|
||||||
|
.warn{fill:#fff8f0;stroke:#bf8700;stroke-width:1.5;rx:6}
|
||||||
|
.t{font-size:12px;fill:#1f2328}
|
||||||
|
.tb{font-size:12.5px;fill:#1f2328;font-weight:600}
|
||||||
|
.s{font-size:10.5px;fill:#656d76}
|
||||||
|
.ttl{font-size:14px;fill:#1f2328;font-weight:600}
|
||||||
|
.r{stroke:#cf222e;stroke-width:1.6;fill:none}
|
||||||
|
.g{stroke:#1a7f37;stroke-width:1.6;fill:none}
|
||||||
|
.k{stroke:#8c959f;stroke-width:1.3;fill:none}
|
||||||
|
</style>
|
||||||
|
<defs>
|
||||||
|
<marker id="a" markerWidth="8" markerHeight="8" refX="7" refY="3" orient="auto"><path d="M0,0 L7,3 L0,6 z" fill="#8c959f"/></marker>
|
||||||
|
<marker id="ar" markerWidth="8" markerHeight="8" refX="7" refY="3" orient="auto"><path d="M0,0 L7,3 L0,6 z" fill="#cf222e"/></marker>
|
||||||
|
<marker id="ag" markerWidth="8" markerHeight="8" refX="7" refY="3" orient="auto"><path d="M0,0 L7,3 L0,6 z" fill="#1a7f37"/></marker>
|
||||||
|
</defs>
|
||||||
|
<text x="20" y="26" class="ttl">A-3 — 200 을 받은 로그인 153건 중 4건이 DB 에 없다</text>
|
||||||
|
<text x="20" y="44" class="s">버그가 아니라 synchronous_commit OFF 의 대가를 실측한 것이다</text>
|
||||||
|
<rect x="30" y="74" width="180" height="70" class="box"/>
|
||||||
|
<text x="46" y="96" class="tb">클라이언트</text>
|
||||||
|
<text x="46" y="116" class="s">로그인 요청</text>
|
||||||
|
<text x="46" y="134" class="s">200 + 토큰 수신</text>
|
||||||
|
<rect x="250" y="74" width="200" height="70" class="box"/>
|
||||||
|
<text x="266" y="96" class="tb">Keycloak</text>
|
||||||
|
<text x="266" y="116" class="s">트랜잭션마다</text>
|
||||||
|
<text x="266" y="134" class="s">SET LOCAL synchronous_commit OFF</text>
|
||||||
|
<rect x="490" y="74" width="180" height="70" class="warn"/>
|
||||||
|
<text x="506" y="96" class="tb">PostgreSQL</text>
|
||||||
|
<text x="506" y="116" class="s">COMMIT 을 즉시 반환</text>
|
||||||
|
<text x="506" y="134" class="s">WAL 은 아직 메모리에</text>
|
||||||
|
<rect x="710" y="74" width="120" height="70" class="box"/>
|
||||||
|
<text x="726" y="96" class="tb">디스크</text>
|
||||||
|
<text x="726" y="116" class="s">wal_writer_delay</text>
|
||||||
|
<text x="726" y="134" class="s">200ms 뒤 기록</text>
|
||||||
|
<path d="M212 108 L246 108" class="k" marker-end="url(#a)"/>
|
||||||
|
<path d="M452 108 L486 108" class="k" marker-end="url(#a)"/>
|
||||||
|
<path d="M672 108 L706 108" class="r" stroke-dasharray="4 3" marker-end="url(#ar)"/>
|
||||||
|
<text x="676" y="164" class="s" fill="#cf222e">이 구간이 비어 있다</text>
|
||||||
|
<rect x="620" y="180" width="210" height="40" class="dead"/>
|
||||||
|
<text x="636" y="205" class="t" fill="#cf222e">✂ 여기서 강제 종료</text>
|
||||||
|
<rect x="30" y="250" width="380" height="96" class="dead"/>
|
||||||
|
<text x="46" y="272" class="tb">결과 — RPO 는 0 이 아니다</text>
|
||||||
|
<text x="46" y="296" class="t">클라이언트가 200 받은 로그인 153 건</text>
|
||||||
|
<text x="46" y="316" class="t">그중 DB 에 실제로 존재 149 건</text>
|
||||||
|
<text x="46" y="336" class="t" fill="#cf222e">★ 유실 4 건</text>
|
||||||
|
<rect x="430" y="250" width="400" height="96" class="box"/>
|
||||||
|
<text x="446" y="272" class="tb">주입도 두 번 실패했다</text>
|
||||||
|
<text x="446" y="294" class="s">kubectl delete --force 는 크래시가 아니다 — 런타임이 SIGTERM 을 보내</text>
|
||||||
|
<text x="446" y="310" class="s">PostgreSQL 이 정상 플러시했고 유실이 0 이었다</text>
|
||||||
|
<text x="446" y="332" class="s">kill -9 1 도 무시된다 — PID 1 은 자기 네임스페이스의 SIGKILL 을 받지 않는다</text>
|
||||||
|
<text x="30" y="372" class="s">백엔드 프로세스를 죽여 postmaster 가 reinitialize 하게 만들자 비로소 "not properly shut down / redo starts" 가 찍혔다</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 3.7 KiB |
@@ -0,0 +1,52 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 860 420" font-family="-apple-system,BlinkMacSystemFont,Segoe UI,Helvetica,sans-serif">
|
||||||
|
<style>
|
||||||
|
.box{fill:#fff;stroke:#8c959f;stroke-width:1.4;rx:6}
|
||||||
|
.dead{fill:#fff5f5;stroke:#cf222e;stroke-width:1.6;stroke-dasharray:5 3;rx:6}
|
||||||
|
.ok{fill:#f6fdf6;stroke:#1a7f37;stroke-width:1.6;rx:6}
|
||||||
|
.warn{fill:#fff8f0;stroke:#bf8700;stroke-width:1.5;rx:6}
|
||||||
|
.t{font-size:12px;fill:#1f2328}
|
||||||
|
.tb{font-size:12.5px;fill:#1f2328;font-weight:600}
|
||||||
|
.s{font-size:10.5px;fill:#656d76}
|
||||||
|
.ttl{font-size:14px;fill:#1f2328;font-weight:600}
|
||||||
|
.r{stroke:#cf222e;stroke-width:1.6;fill:none}
|
||||||
|
.g{stroke:#1a7f37;stroke-width:1.6;fill:none}
|
||||||
|
.k{stroke:#8c959f;stroke-width:1.3;fill:none}
|
||||||
|
</style>
|
||||||
|
<defs>
|
||||||
|
<marker id="a" markerWidth="8" markerHeight="8" refX="7" refY="3" orient="auto"><path d="M0,0 L7,3 L0,6 z" fill="#8c959f"/></marker>
|
||||||
|
<marker id="ar" markerWidth="8" markerHeight="8" refX="7" refY="3" orient="auto"><path d="M0,0 L7,3 L0,6 z" fill="#cf222e"/></marker>
|
||||||
|
<marker id="ag" markerWidth="8" markerHeight="8" refX="7" refY="3" orient="auto"><path d="M0,0 L7,3 L0,6 z" fill="#1a7f37"/></marker>
|
||||||
|
</defs>
|
||||||
|
<text x="20" y="26" class="ttl">A-4 — 둘 다 전면 장애지만 이유가 다르다</text>
|
||||||
|
<text x="20" y="44" class="s">그리고 죽은 파드가 산 파드보다 건강해 보인다</text>
|
||||||
|
<rect x="30" y="70" width="390" height="150" class="box"/>
|
||||||
|
<text x="46" y="92" class="tb">4a — 워커(kc-lab-2) 상실</text>
|
||||||
|
<rect x="46" y="104" width="170" height="46" class="dead"/>
|
||||||
|
<text x="58" y="124" class="t">keycloak-0</text>
|
||||||
|
<text x="58" y="142" class="s">PostgreSQL 도 여기 있었다</text>
|
||||||
|
<rect x="236" y="104" width="170" height="46" class="warn"/>
|
||||||
|
<text x="248" y="124" class="t">keycloak-1 살아있음</text>
|
||||||
|
<text x="248" y="142" class="s">그러나 DB 가 없다</text>
|
||||||
|
<text x="46" y="172" class="t">외부 응답 <tspan fill="#cf222e" font-weight="600">503</tspan> · kubectl 정상</text>
|
||||||
|
<text x="46" y="194" class="s">DB 가 같이 죽어서 장애다. 남은 파드는 돌지만 아무것도 못 한다</text>
|
||||||
|
<text x="46" y="212" class="s">PVC 가 local-path 라 다른 노드로 재배치되지 않는다</text>
|
||||||
|
<rect x="440" y="70" width="390" height="150" class="box"/>
|
||||||
|
<text x="456" y="92" class="tb">4b — 컨트롤 플레인(kc-lab-1) 상실</text>
|
||||||
|
<rect x="456" y="104" width="170" height="46" class="ok"/>
|
||||||
|
<text x="468" y="124" class="t">keycloak-0</text>
|
||||||
|
<text x="468" y="142" class="s">★ 계속 돌고 있다</text>
|
||||||
|
<rect x="646" y="104" width="170" height="46" class="dead"/>
|
||||||
|
<text x="658" y="124" class="t">API 서버 · traefik</text>
|
||||||
|
<text x="658" y="142" class="s">들어갈 길이 없다</text>
|
||||||
|
<text x="456" y="172" class="t">외부 응답 <tspan fill="#cf222e" font-weight="600">000</tspan> · kubectl <tspan fill="#cf222e">불통</tspan></text>
|
||||||
|
<text x="456" y="194" class="s">워크로드는 멀쩡한데 도달할 수 없어 장애다</text>
|
||||||
|
<text x="456" y="212" class="s">진입점이 단일 노드에 있으면 워크로드 이중화는 의미가 없다</text>
|
||||||
|
<rect x="30" y="242" width="800" height="86" class="warn"/>
|
||||||
|
<text x="46" y="264" class="tb">★ 예상하지 못한 것 셋</text>
|
||||||
|
<text x="46" y="286" class="s">1. 죽은 파드가 산 파드보다 건강해 보인다 — kubelet 이 사라져 상태가 갱신되지 않으니 Running 으로 남는다</text>
|
||||||
|
<text x="46" y="304" class="s">2. StatefulSet 은 Terminating 파드의 대체를 만들지 않는다 — 이름이 같아야 하므로 지워지기를 기다린다</text>
|
||||||
|
<text x="46" y="322" class="s">3. node-monitor-grace-period 40초 + tolerationSeconds 300초 = 축출까지 5분 40초</text>
|
||||||
|
<rect x="30" y="348" width="800" height="46" class="ok"/>
|
||||||
|
<text x="46" y="370" class="tb">복구는 둘 다 virsh start 이후 60초</text>
|
||||||
|
<text x="46" y="388" class="s">장애 시간의 대부분은 복구가 아니라 "누가 죽은 것을 알아채는 데" 걸린 시간이다</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 3.9 KiB |
@@ -0,0 +1,49 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 860 420" font-family="-apple-system,BlinkMacSystemFont,Segoe UI,Helvetica,sans-serif">
|
||||||
|
<style>
|
||||||
|
.box{fill:#fff;stroke:#8c959f;stroke-width:1.4;rx:6}
|
||||||
|
.dead{fill:#fff5f5;stroke:#cf222e;stroke-width:1.6;stroke-dasharray:5 3;rx:6}
|
||||||
|
.ok{fill:#f6fdf6;stroke:#1a7f37;stroke-width:1.6;rx:6}
|
||||||
|
.warn{fill:#fff8f0;stroke:#bf8700;stroke-width:1.5;rx:6}
|
||||||
|
.t{font-size:12px;fill:#1f2328}
|
||||||
|
.tb{font-size:12.5px;fill:#1f2328;font-weight:600}
|
||||||
|
.s{font-size:10.5px;fill:#656d76}
|
||||||
|
.ttl{font-size:14px;fill:#1f2328;font-weight:600}
|
||||||
|
.r{stroke:#cf222e;stroke-width:1.6;fill:none}
|
||||||
|
.g{stroke:#1a7f37;stroke-width:1.6;fill:none}
|
||||||
|
.k{stroke:#8c959f;stroke-width:1.3;fill:none}
|
||||||
|
</style>
|
||||||
|
<defs>
|
||||||
|
<marker id="a" markerWidth="8" markerHeight="8" refX="7" refY="3" orient="auto"><path d="M0,0 L7,3 L0,6 z" fill="#8c959f"/></marker>
|
||||||
|
<marker id="ar" markerWidth="8" markerHeight="8" refX="7" refY="3" orient="auto"><path d="M0,0 L7,3 L0,6 z" fill="#cf222e"/></marker>
|
||||||
|
<marker id="ag" markerWidth="8" markerHeight="8" refX="7" refY="3" orient="auto"><path d="M0,0 L7,3 L0,6 z" fill="#1a7f37"/></marker>
|
||||||
|
</defs>
|
||||||
|
<text x="20" y="26" class="ttl">A-5 — 비대칭 차단은 클러스터를 가르지 못한다. 그리고 갈라도 서비스는 계속된다</text>
|
||||||
|
<text x="20" y="44" class="s">주입을 세 번 실패했고, 세 번 모두 「아무 일도 없었다」로 보였다</text>
|
||||||
|
<rect x="30" y="72" width="390" height="112" class="box"/>
|
||||||
|
<text x="46" y="94" class="tb">① 한 방향만 차단</text>
|
||||||
|
<rect x="46" y="106" width="130" height="34" class="box"/>
|
||||||
|
<text x="58" y="128" class="t">keycloak-0</text>
|
||||||
|
<rect x="290" y="106" width="116" height="34" class="box"/>
|
||||||
|
<text x="302" y="128" class="t">keycloak-1</text>
|
||||||
|
<path d="M178 116 L286 116" class="r" stroke-dasharray="4 3" marker-end="url(#ar)"/>
|
||||||
|
<path d="M286 132 L178 132" class="g" marker-end="url(#ag)"/>
|
||||||
|
<text x="46" y="164" class="s" fill="#1a7f37">열린 방향으로 재연결한다 → cluster_size 2 유지. 가르지 못한다</text>
|
||||||
|
<rect x="440" y="72" width="390" height="112" class="box"/>
|
||||||
|
<text x="456" y="94" class="tb">② 양방향 완전 차단</text>
|
||||||
|
<rect x="456" y="106" width="130" height="34" class="dead"/>
|
||||||
|
<text x="468" y="128" class="t">keycloak-0</text>
|
||||||
|
<rect x="700" y="106" width="116" height="34" class="ok"/>
|
||||||
|
<text x="712" y="128" class="t">keycloak-1</text>
|
||||||
|
<path d="M588 116 L696 116" class="r" stroke-dasharray="4 3" marker-end="url(#ar)"/>
|
||||||
|
<path d="M696 132 L588 132" class="r" stroke-dasharray="4 3" marker-end="url(#ar)"/>
|
||||||
|
<text x="456" y="164" class="s">양쪽 모두 멤버 1개. 그런데 <tspan fill="#cf222e" font-weight="600">한쪽만 DOWN</tspan> 이 된다 — 코디네이터 쪽이 살아남는다</text>
|
||||||
|
<rect x="30" y="204" width="800" height="48" class="ok"/>
|
||||||
|
<text x="46" y="226" class="tb">그래서 전면 장애 경로가 없다 — 외부 응답 200 유지</text>
|
||||||
|
<text x="46" y="244" class="s">분단된 쪽이 스스로 readiness 를 떨어뜨려 로드밸런서에서 빠지고, 남은 쪽이 계속 응답한다</text>
|
||||||
|
<rect x="30" y="272" width="800" height="130" class="warn"/>
|
||||||
|
<text x="46" y="294" class="tb">★ 세 번의 주입 실패 — 전부 「조용히」 실패했다</text>
|
||||||
|
<text x="46" y="318" class="s">1. iptables -I FORWARD 1 이 무시됐다 — kube-router 가 자기 체인을 FORWARD 맨 위에 다시 끼워 넣는다 (패킷 0)</text>
|
||||||
|
<text x="46" y="338" class="s">2. 방향이 뒤집혀 있었다 — JGroups 의 client/server 역할은 재시작마다 바뀐다. raw 규칙이 엉뚱한 노드에 걸렸다</text>
|
||||||
|
<text x="46" y="358" class="s">3. dev eth0 이 없다 — Debian 은 enp1s0 이고, 게다가 flannel VXLAN 이 이미 캡슐화해 파드 IP 가 안 보인다</text>
|
||||||
|
<text x="46" y="382" class="s" fill="#cf222e">해결: raw 테이블 PREROUTING(conntrack 보다 먼저) + flannel.1 인터페이스에서 필터</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 3.8 KiB |
@@ -0,0 +1,51 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 860 400" font-family="-apple-system,BlinkMacSystemFont,Segoe UI,Helvetica,sans-serif">
|
||||||
|
<style>
|
||||||
|
.box{fill:#fff;stroke:#8c959f;stroke-width:1.4;rx:6}
|
||||||
|
.dead{fill:#fff5f5;stroke:#cf222e;stroke-width:1.6;stroke-dasharray:5 3;rx:6}
|
||||||
|
.ok{fill:#f6fdf6;stroke:#1a7f37;stroke-width:1.6;rx:6}
|
||||||
|
.warn{fill:#fff8f0;stroke:#bf8700;stroke-width:1.5;rx:6}
|
||||||
|
.t{font-size:12px;fill:#1f2328}
|
||||||
|
.tb{font-size:12.5px;fill:#1f2328;font-weight:600}
|
||||||
|
.s{font-size:10.5px;fill:#656d76}
|
||||||
|
.ttl{font-size:14px;fill:#1f2328;font-weight:600}
|
||||||
|
.r{stroke:#cf222e;stroke-width:1.6;fill:none}
|
||||||
|
.g{stroke:#1a7f37;stroke-width:1.6;fill:none}
|
||||||
|
.k{stroke:#8c959f;stroke-width:1.3;fill:none}
|
||||||
|
</style>
|
||||||
|
<defs>
|
||||||
|
<marker id="a" markerWidth="8" markerHeight="8" refX="7" refY="3" orient="auto"><path d="M0,0 L7,3 L0,6 z" fill="#8c959f"/></marker>
|
||||||
|
<marker id="ar" markerWidth="8" markerHeight="8" refX="7" refY="3" orient="auto"><path d="M0,0 L7,3 L0,6 z" fill="#cf222e"/></marker>
|
||||||
|
<marker id="ag" markerWidth="8" markerHeight="8" refX="7" refY="3" orient="auto"><path d="M0,0 L7,3 L0,6 z" fill="#1a7f37"/></marker>
|
||||||
|
</defs>
|
||||||
|
<text x="20" y="26" class="ttl">A-6 — 200밀리초가 22초가 된다</text>
|
||||||
|
<text x="20" y="44" class="s">지연은 왕복 횟수만큼 곱해지고, 커넥션 풀에서 한 번 더 곱해진다</text>
|
||||||
|
<rect x="30" y="76" width="180" height="60" class="ok"/>
|
||||||
|
<text x="46" y="98" class="tb">대조군 keycloak-0</text>
|
||||||
|
<text x="46" y="120" class="s">DB 와 같은 노드 · 66ms</text>
|
||||||
|
<rect x="250" y="76" width="180" height="60" class="dead"/>
|
||||||
|
<text x="266" y="98" class="tb">시험군 keycloak-1</text>
|
||||||
|
<text x="266" y="120" class="s">tc netem delay 200ms</text>
|
||||||
|
<text x="230" y="112" class="tb" fill="#cf222e">✂</text>
|
||||||
|
<rect x="470" y="76" width="180" height="60" class="warn"/>
|
||||||
|
<text x="486" y="98" class="tb">단일 요청</text>
|
||||||
|
<text x="486" y="120" class="t">1,872 ms <tspan class="s">(28배)</tspan></text>
|
||||||
|
<rect x="690" y="76" width="140" height="60" class="dead"/>
|
||||||
|
<text x="706" y="98" class="tb">동시 20건</text>
|
||||||
|
<text x="706" y="120" class="t" fill="#cf222e">22.2 초</text>
|
||||||
|
<path d="M432 106 L466 106" class="k" marker-end="url(#a)"/>
|
||||||
|
<path d="M652 106 L686 106" class="r" marker-end="url(#ar)"/>
|
||||||
|
<text x="20" y="176" class="tb">왜 200ms 가 1,872ms 가 되는가 — 왕복 횟수</text>
|
||||||
|
<rect x="30" y="190" width="800" height="46" class="box"/>
|
||||||
|
<text x="46" y="210" class="s">로그인 한 번은 DB 왕복을 여러 번 한다: 클라이언트 조회 · 사용자 조회 · 세션 INSERT · 커밋 …</text>
|
||||||
|
<text x="46" y="228" class="s">왕복마다 200ms 가 더해진다. 애플리케이션은 아무것도 잘못하지 않았다</text>
|
||||||
|
<text x="20" y="266" class="tb">왜 1,872ms 가 22.2초가 되는가 — 커넥션 풀</text>
|
||||||
|
<rect x="30" y="280" width="390" height="76" class="dead"/>
|
||||||
|
<text x="46" y="302" class="s">요청이 커넥션을 오래 붙들고 있으면</text>
|
||||||
|
<text x="46" y="320" class="s">뒤의 요청은 풀에서 대기한다</text>
|
||||||
|
<text x="46" y="342" class="t" fill="#cf222e">agroal 커넥션 획득 대기 최대 20,000 ms</text>
|
||||||
|
<rect x="440" y="280" width="390" height="76" class="warn"/>
|
||||||
|
<text x="456" y="302" class="tb">그리고 파드가 죽는다</text>
|
||||||
|
<text x="456" y="322" class="s">readiness 프로브가 타임아웃으로 실패 →</text>
|
||||||
|
<text x="456" y="342" class="s">느린 노드가 로드밸런서에서 빠진다. 느림이 장애로 승격된다</text>
|
||||||
|
<text x="30" y="384" class="s">측정 장치 주의 — 동시 20건을 kubectl run --rm -i 로 돌리면 출력 스트림이 유실된다. 상주 탐침에 파일로 모아야 한다</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 3.6 KiB |
@@ -0,0 +1,70 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 860 470" font-family="-apple-system,BlinkMacSystemFont,'Segoe UI',Helvetica,sans-serif">
|
||||||
|
<style>
|
||||||
|
.box{fill:#fff;stroke:#8c959f;stroke-width:1.4;rx:6}
|
||||||
|
.dead{fill:#fff5f5;stroke:#cf222e;stroke-width:1.6;stroke-dasharray:5 3;rx:6}
|
||||||
|
.ok{fill:#f6fdf6;stroke:#1a7f37;stroke-width:1.6;rx:6}
|
||||||
|
.warn{fill:#fff8f0;stroke:#bf8700;stroke-width:1.5;rx:6}
|
||||||
|
.t{font-size:12px;fill:#1f2328}
|
||||||
|
.tb{font-size:12.5px;fill:#1f2328;font-weight:600}
|
||||||
|
.s{font-size:10.5px;fill:#656d76}
|
||||||
|
.ttl{font-size:14px;fill:#1f2328;font-weight:600}
|
||||||
|
.r{stroke:#cf222e;stroke-width:1.6;fill:none}
|
||||||
|
.g{stroke:#1a7f37;stroke-width:1.6;fill:none}
|
||||||
|
.k{stroke:#8c959f;stroke-width:1.3;fill:none}
|
||||||
|
</style>
|
||||||
|
<defs>
|
||||||
|
<marker id="a" markerWidth="8" markerHeight="8" refX="7" refY="3" orient="auto"><path d="M0,0 L7,3 L0,6 z" fill="#8c959f"/></marker>
|
||||||
|
<marker id="ar" markerWidth="8" markerHeight="8" refX="7" refY="3" orient="auto"><path d="M0,0 L7,3 L0,6 z" fill="#cf222e"/></marker>
|
||||||
|
<marker id="ag" markerWidth="8" markerHeight="8" refX="7" refY="3" orient="auto"><path d="M0,0 L7,3 L0,6 z" fill="#1a7f37"/></marker>
|
||||||
|
</defs>
|
||||||
|
<text x="20" y="26" class="ttl">A-7a — volatile + DB 정지의 결과는 「무엇을 하느냐」가 아니라 「캐시가 더운가」로 갈린다</text>
|
||||||
|
<text x="20" y="44" class="s">문장 로깅(log_statement=all)으로 측정 · 세 상태 모두 재현</text>
|
||||||
|
|
||||||
|
<rect x="20" y="66" width="380" height="120" class="box"/>
|
||||||
|
<text x="34" y="88" class="tb">로그인이 쏘는 SQL</text>
|
||||||
|
<rect x="36" y="100" width="348" height="34" class="ok"/>
|
||||||
|
<text x="48" y="122" class="t">0개 — realm·사용자·클라이언트가 전부 캐시</text>
|
||||||
|
<text x="36" y="152" class="s">MARK_LOGIN_START / MARK_LOGIN_END 사이가 비어 있다</text>
|
||||||
|
<text x="36" y="170" class="s">volatile 이라 세션 쓰기도 없다</text>
|
||||||
|
|
||||||
|
<rect x="440" y="66" width="400" height="120" class="box"/>
|
||||||
|
<text x="454" y="88" class="tb">refresh 가 쏘는 SQL</text>
|
||||||
|
<rect x="456" y="100" width="368" height="34" class="warn"/>
|
||||||
|
<text x="466" y="115" class="t">select cscme1_0.SCOPE_ID from CLIENT_SCOPE_CLIENT</text>
|
||||||
|
<text x="466" y="129" class="s">where CLIENT_ID=$1 and DEFAULT_SCOPE=$2 ('f' = optional)</text>
|
||||||
|
<text x="454" y="152" class="s">★ REVOKED_TOKEN 은 한 번도 나오지 않는다 — A-7 의 가설은 틀렸다</text>
|
||||||
|
<text x="454" y="170" class="s">그리고 첫 refresh 만 쏜다. 이후 3회는 SQL 0건 = 캐시됨</text>
|
||||||
|
|
||||||
|
<text x="20" y="222" class="tb">그래서 같은 설정이 세 가지 답을 낸다 — PostgreSQL 정지 시</text>
|
||||||
|
|
||||||
|
<rect x="20" y="240" width="266" height="120" class="dead"/>
|
||||||
|
<text x="34" y="262" class="tb">① 완전 냉시동</text>
|
||||||
|
<text x="34" y="282" class="s">재시작 직후, 아무것도 안 함</text>
|
||||||
|
<text x="34" y="306" class="t">로그인 <tspan fill="#cf222e" font-weight="600">400</tspan> refresh 400</text>
|
||||||
|
<text x="34" y="328" class="s">select ce1_0.ID from CLIENT</text>
|
||||||
|
<text x="34" y="344" class="s">클라이언트 조회조차 캐시에 없다</text>
|
||||||
|
|
||||||
|
<rect x="298" y="240" width="266" height="120" class="warn"/>
|
||||||
|
<text x="312" y="262" class="tb">② CLIENT 만 더움</text>
|
||||||
|
<text x="312" y="282" class="s">로그인 1회 뒤 · ← A-7 이 본 상태</text>
|
||||||
|
<text x="312" y="306" class="t">로그인 200 refresh <tspan fill="#bf8700" font-weight="600">500</tspan></text>
|
||||||
|
<text x="312" y="328" class="s">CLIENT_SCOPE_CLIENT 조회 실패</text>
|
||||||
|
<text x="312" y="344" class="s">Keycloak 로그가 이 SQL 을 직접 지목</text>
|
||||||
|
|
||||||
|
<rect x="576" y="240" width="264" height="120" class="ok"/>
|
||||||
|
<text x="590" y="262" class="tb">③ 완전히 더움</text>
|
||||||
|
<text x="590" y="282" class="s">refresh 3회로 캐시를 채운 뒤</text>
|
||||||
|
<text x="590" y="306" class="t">로그인 200 refresh <tspan fill="#1a7f37" font-weight="600">200</tspan></text>
|
||||||
|
<text x="590" y="328" class="s">SQL 0건 — DB 를 아예 안 본다</text>
|
||||||
|
<text x="590" y="344" class="s">A-7 의 표와 정반대</text>
|
||||||
|
|
||||||
|
<path d="M286 300 L294 300" class="k" marker-end="url(#a)"/>
|
||||||
|
<path d="M564 300 L572 300" class="k" marker-end="url(#a)"/>
|
||||||
|
<text x="292" y="290" class="s">로그인 1회</text>
|
||||||
|
<text x="570" y="290" class="s">refresh 3회</text>
|
||||||
|
|
||||||
|
<rect x="20" y="384" width="820" height="66" class="box"/>
|
||||||
|
<text x="34" y="406" class="tb">남기는 것</text>
|
||||||
|
<text x="34" y="426" class="s">· A-7 의 표에는 조건이 빠져 있었다 — 「volatile 이면 DB 없이 로그인된다」도 냉시동에서는 거짓이다</text>
|
||||||
|
<text x="34" y="442" class="s">· 상태가 결과를 바꾸는데 그 상태가 안 보인다. A-1 의 conntrack 과 같은 계열의 함정이다</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 4.5 KiB |
@@ -0,0 +1,47 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 860 380" font-family="-apple-system,BlinkMacSystemFont,Segoe UI,Helvetica,sans-serif">
|
||||||
|
<style>
|
||||||
|
.box{fill:#fff;stroke:#8c959f;stroke-width:1.4;rx:6}
|
||||||
|
.dead{fill:#fff5f5;stroke:#cf222e;stroke-width:1.6;stroke-dasharray:5 3;rx:6}
|
||||||
|
.ok{fill:#f6fdf6;stroke:#1a7f37;stroke-width:1.6;rx:6}
|
||||||
|
.warn{fill:#fff8f0;stroke:#bf8700;stroke-width:1.5;rx:6}
|
||||||
|
.t{font-size:12px;fill:#1f2328}
|
||||||
|
.tb{font-size:12.5px;fill:#1f2328;font-weight:600}
|
||||||
|
.s{font-size:10.5px;fill:#656d76}
|
||||||
|
.ttl{font-size:14px;fill:#1f2328;font-weight:600}
|
||||||
|
.r{stroke:#cf222e;stroke-width:1.6;fill:none}
|
||||||
|
.g{stroke:#1a7f37;stroke-width:1.6;fill:none}
|
||||||
|
.k{stroke:#8c959f;stroke-width:1.3;fill:none}
|
||||||
|
</style>
|
||||||
|
<defs>
|
||||||
|
<marker id="a" markerWidth="8" markerHeight="8" refX="7" refY="3" orient="auto"><path d="M0,0 L7,3 L0,6 z" fill="#8c959f"/></marker>
|
||||||
|
<marker id="ar" markerWidth="8" markerHeight="8" refX="7" refY="3" orient="auto"><path d="M0,0 L7,3 L0,6 z" fill="#cf222e"/></marker>
|
||||||
|
<marker id="ag" markerWidth="8" markerHeight="8" refX="7" refY="3" orient="auto"><path d="M0,0 L7,3 L0,6 z" fill="#1a7f37"/></marker>
|
||||||
|
</defs>
|
||||||
|
<text x="20" y="26" class="ttl">A-8 — 세션은 살아남고 캐시만 사라진다</text>
|
||||||
|
<text x="20" y="44" class="s">이것이 persistent-user-sessions 를 켜는 진짜 이유다</text>
|
||||||
|
<rect x="30" y="74" width="800" height="30" class="box"/>
|
||||||
|
<text x="46" y="94" class="tb">kubectl rollout restart statefulset/keycloak — 한 번에 한 파드씩</text>
|
||||||
|
<rect x="30" y="124" width="250" height="110" class="dead"/>
|
||||||
|
<text x="46" y="146" class="tb">사라지는 것 — 메모리</text>
|
||||||
|
<text x="46" y="168" class="s">Infinispan sessions 캐시</text>
|
||||||
|
<text x="46" y="188" class="t">entries_unique → <tspan fill="#cf222e" font-weight="600">0</tspan></text>
|
||||||
|
<text x="46" y="212" class="s">파드와 함께 없어진다</text>
|
||||||
|
<rect x="305" y="124" width="250" height="110" class="ok"/>
|
||||||
|
<text x="321" y="146" class="tb">남는 것 — PostgreSQL</text>
|
||||||
|
<text x="321" y="168" class="s">OFFLINE_USER_SESSION</text>
|
||||||
|
<text x="321" y="188" class="t">151 → <tspan fill="#1a7f37" font-weight="600">151</tspan> 그대로</text>
|
||||||
|
<text x="321" y="212" class="s">재시작과 무관하다</text>
|
||||||
|
<rect x="580" y="124" width="250" height="110" class="ok"/>
|
||||||
|
<text x="596" y="146" class="tb">그래서 사용자는</text>
|
||||||
|
<text x="596" y="168" class="s">재시작 전 발급한 refresh token</text>
|
||||||
|
<text x="596" y="188" class="t">여전히 <tspan fill="#1a7f37" font-weight="600">200</tspan></text>
|
||||||
|
<text x="596" y="212" class="s">전 구간 중단 없음</text>
|
||||||
|
<path d="M282 178 L301 178" class="k" marker-end="url(#a)"/>
|
||||||
|
<path d="M557 178 L576 178" class="k" marker-end="url(#a)"/>
|
||||||
|
<rect x="30" y="254" width="800" height="52" class="box"/>
|
||||||
|
<text x="46" y="276" class="tb">클러스터는 자동 재형성된다</text>
|
||||||
|
<text x="46" y="296" class="s">JGROUPS_PING 테이블에 다시 등록되고 cluster_size 가 2 로 돌아온다 — 사람이 할 일이 없다</text>
|
||||||
|
<rect x="30" y="322" width="800" height="44" class="warn"/>
|
||||||
|
<text x="46" y="344" class="s">★ A-7 과 대조 — volatile 이면 같은 재시작에서 refresh 가 400 Session not active 가 된다.</text>
|
||||||
|
<text x="46" y="360" class="s">세션이 메모리에만 있었으므로 캐시가 사라지는 순간 세션도 사라진다. 이 실험의 결론은 버전 조건부다</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 3.3 KiB |
@@ -0,0 +1,45 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 860 400" font-family="-apple-system,BlinkMacSystemFont,Segoe UI,Helvetica,sans-serif">
|
||||||
|
<style>
|
||||||
|
.box{fill:#fff;stroke:#8c959f;stroke-width:1.4;rx:6}
|
||||||
|
.dead{fill:#fff5f5;stroke:#cf222e;stroke-width:1.6;stroke-dasharray:5 3;rx:6}
|
||||||
|
.ok{fill:#f6fdf6;stroke:#1a7f37;stroke-width:1.6;rx:6}
|
||||||
|
.warn{fill:#fff8f0;stroke:#bf8700;stroke-width:1.5;rx:6}
|
||||||
|
.t{font-size:12px;fill:#1f2328}
|
||||||
|
.tb{font-size:12.5px;fill:#1f2328;font-weight:600}
|
||||||
|
.s{font-size:10.5px;fill:#656d76}
|
||||||
|
.ttl{font-size:14px;fill:#1f2328;font-weight:600}
|
||||||
|
.r{stroke:#cf222e;stroke-width:1.6;fill:none}
|
||||||
|
.g{stroke:#1a7f37;stroke-width:1.6;fill:none}
|
||||||
|
.k{stroke:#8c959f;stroke-width:1.3;fill:none}
|
||||||
|
</style>
|
||||||
|
<defs>
|
||||||
|
<marker id="a" markerWidth="8" markerHeight="8" refX="7" refY="3" orient="auto"><path d="M0,0 L7,3 L0,6 z" fill="#8c959f"/></marker>
|
||||||
|
<marker id="ar" markerWidth="8" markerHeight="8" refX="7" refY="3" orient="auto"><path d="M0,0 L7,3 L0,6 z" fill="#cf222e"/></marker>
|
||||||
|
<marker id="ag" markerWidth="8" markerHeight="8" refX="7" refY="3" orient="auto"><path d="M0,0 L7,3 L0,6 z" fill="#1a7f37"/></marker>
|
||||||
|
</defs>
|
||||||
|
<text x="20" y="26" class="ttl">B-0 — 아무것도 설정하지 않으면 Spring 이 무엇을 고르는가</text>
|
||||||
|
<text x="20" y="44" class="s">추측이 맞았지만, 추측으로 두면 안 되는 이유가 둘째 줄에 있다</text>
|
||||||
|
<rect x="30" y="72" width="800" height="118" class="box"/>
|
||||||
|
<text x="46" y="94" class="tb">/actuator/beans 가 실제로 답한 것</text>
|
||||||
|
<text x="46" y="118" class="t">authorizedClientService → InMemoryOAuth2AuthorizedClientService</text>
|
||||||
|
<text x="46" y="138" class="t">authorizedClientRepository → AuthenticatedPrincipalOAuth2AuthorizedClientRepository</text>
|
||||||
|
<text x="46" y="158" class="t">SessionRepository → 없음 (서블릿 컨테이너 in-memory)</text>
|
||||||
|
<text x="46" y="178" class="t" fill="#cf222e">Redis / Spring Session → 없음</text>
|
||||||
|
<rect x="30" y="212" width="390" height="150" class="warn"/>
|
||||||
|
<text x="46" y="234" class="tb">★ 이름이 곧 설명이다</text>
|
||||||
|
<text x="46" y="256" class="s">AuthenticatedPrincipal…Repository 는</text>
|
||||||
|
<text x="46" y="276" class="t">principal 이름으로 찾는다</text>
|
||||||
|
<rect x="46" y="288" width="358" height="30" class="dead"/>
|
||||||
|
<text x="58" y="308" class="s">조회 키에 session id 가 없다</text>
|
||||||
|
<text x="46" y="336" class="s">그래서 세션 저장소를 Redis 로 옮겨도</text>
|
||||||
|
<text x="46" y="354" class="s">토큰은 따라오지 않는다 — B-1 이 겪는 문제의 뿌리</text>
|
||||||
|
<rect x="440" y="212" width="390" height="150" class="box"/>
|
||||||
|
<text x="456" y="234" class="tb">두 개가 서로 다른 것을 저장한다</text>
|
||||||
|
<rect x="456" y="248" width="358" height="40" class="box"/>
|
||||||
|
<text x="468" y="266" class="t">Application Session</text>
|
||||||
|
<text x="468" y="282" class="s">누가 로그인했는지 · 세션 id 로 찾는다</text>
|
||||||
|
<rect x="456" y="298" width="358" height="40" class="box"/>
|
||||||
|
<text x="468" y="316" class="t">OAuth2AuthorizedClient</text>
|
||||||
|
<text x="468" y="332" class="s">access / refresh token · principal 이름으로 찾는다</text>
|
||||||
|
<text x="456" y="356" class="s">이 둘을 하나로 생각하면 B-1·B-2 의 결과를 해석할 수 없다</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 3.2 KiB |
@@ -0,0 +1,55 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 860 440" font-family="-apple-system,BlinkMacSystemFont,Segoe UI,Helvetica,sans-serif">
|
||||||
|
<style>
|
||||||
|
.box{fill:#fff;stroke:#8c959f;stroke-width:1.4;rx:6}
|
||||||
|
.dead{fill:#fff5f5;stroke:#cf222e;stroke-width:1.6;stroke-dasharray:5 3;rx:6}
|
||||||
|
.ok{fill:#f6fdf6;stroke:#1a7f37;stroke-width:1.6;rx:6}
|
||||||
|
.warn{fill:#fff8f0;stroke:#bf8700;stroke-width:1.5;rx:6}
|
||||||
|
.t{font-size:12px;fill:#1f2328}
|
||||||
|
.tb{font-size:12.5px;fill:#1f2328;font-weight:600}
|
||||||
|
.s{font-size:10.5px;fill:#656d76}
|
||||||
|
.ttl{font-size:14px;fill:#1f2328;font-weight:600}
|
||||||
|
.r{stroke:#cf222e;stroke-width:1.6;fill:none}
|
||||||
|
.g{stroke:#1a7f37;stroke-width:1.6;fill:none}
|
||||||
|
.k{stroke:#8c959f;stroke-width:1.3;fill:none}
|
||||||
|
</style>
|
||||||
|
<defs>
|
||||||
|
<marker id="a" markerWidth="8" markerHeight="8" refX="7" refY="3" orient="auto"><path d="M0,0 L7,3 L0,6 z" fill="#8c959f"/></marker>
|
||||||
|
<marker id="ar" markerWidth="8" markerHeight="8" refX="7" refY="3" orient="auto"><path d="M0,0 L7,3 L0,6 z" fill="#cf222e"/></marker>
|
||||||
|
<marker id="ag" markerWidth="8" markerHeight="8" refX="7" refY="3" orient="auto"><path d="M0,0 L7,3 L0,6 z" fill="#1a7f37"/></marker>
|
||||||
|
</defs>
|
||||||
|
<text x="20" y="26" class="ttl">B-2 — 저장소를 나눠 문제를 풀자 다른 두 문제가 남았다</text>
|
||||||
|
<text x="20" y="44" class="s">Q1 의 네 항목 중 둘이 통과, 둘이 실패</text>
|
||||||
|
<rect x="30" y="72" width="240" height="90" class="box"/>
|
||||||
|
<text x="46" y="94" class="tb">bff 인스턴스 2개</text>
|
||||||
|
<text x="46" y="116" class="s">어느 쪽으로 요청이 가도 된다</text>
|
||||||
|
<text x="46" y="136" class="s">enableServiceLinks: false</text>
|
||||||
|
<text x="46" y="154" class="s">(REDIS_PORT 충돌 회피)</text>
|
||||||
|
<rect x="330" y="72" width="230" height="90" class="ok"/>
|
||||||
|
<text x="346" y="94" class="tb">Redis</text>
|
||||||
|
<text x="346" y="116" class="s">Application Session</text>
|
||||||
|
<text x="346" y="136" class="s">세션 id 로 조회</text>
|
||||||
|
<text x="346" y="154" class="s">로그아웃 시 정리됨</text>
|
||||||
|
<rect x="600" y="72" width="230" height="90" class="warn"/>
|
||||||
|
<text x="616" y="94" class="tb">PostgreSQL</text>
|
||||||
|
<text x="616" y="116" class="s">OAuth2AuthorizedClient</text>
|
||||||
|
<text x="616" y="136" class="s">principal 이름으로 조회</text>
|
||||||
|
<text x="616" y="154" class="s">로그아웃해도 남는다</text>
|
||||||
|
<path d="M272 108 L326 108" class="g" marker-end="url(#ag)"/>
|
||||||
|
<path d="M562 108 L596 108" class="g" marker-end="url(#ag)"/>
|
||||||
|
<rect x="30" y="188" width="390" height="100" class="ok"/>
|
||||||
|
<text x="46" y="210" class="tb">통과</text>
|
||||||
|
<text x="46" y="234" class="t">① 다른 인스턴스로 요청해도 된다</text>
|
||||||
|
<text x="46" y="256" class="t">② 재시작 후에도 로그인 유지</text>
|
||||||
|
<text x="46" y="278" class="s">저장소를 밖으로 뺐으니 당연한 결과다</text>
|
||||||
|
<rect x="440" y="188" width="390" height="100" class="dead"/>
|
||||||
|
<text x="456" y="210" class="tb">실패</text>
|
||||||
|
<text x="456" y="234" class="t" fill="#cf222e">③ 같은 사용자의 다른 브라우저가 덮어쓴다</text>
|
||||||
|
<text x="456" y="256" class="t" fill="#cf222e">④ 로그아웃해도 한쪽만 정리된다</text>
|
||||||
|
<text x="456" y="278" class="s">둘 다 저장소 선택의 문제가 아니라 스키마의 문제다</text>
|
||||||
|
<rect x="30" y="308" width="800" height="70" class="warn"/>
|
||||||
|
<text x="46" y="330" class="tb">★ 뿌리는 DDL 한 줄이다</text>
|
||||||
|
<text x="46" y="352" class="t">PRIMARY KEY (client_registration_id, principal_name)</text>
|
||||||
|
<text x="46" y="370" class="s">세션 id 가 키에 없다 → 같은 사용자의 두 세션이 같은 행을 쓴다 → 나중 로그인이 앞의 토큰을 덮어쓴다</text>
|
||||||
|
<rect x="30" y="392" width="800" height="40" class="dead"/>
|
||||||
|
<text x="46" y="416" class="s">로그아웃 후: Redis 세션 0 키 (정리됨) · PostgreSQL 토큰 1 행 — 평문 refresh token 이 그대로 남는다</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 3.7 KiB |
@@ -0,0 +1,52 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 860 420" font-family="-apple-system,BlinkMacSystemFont,Segoe UI,Helvetica,sans-serif">
|
||||||
|
<style>
|
||||||
|
.box{fill:#fff;stroke:#8c959f;stroke-width:1.4;rx:6}
|
||||||
|
.dead{fill:#fff5f5;stroke:#cf222e;stroke-width:1.6;stroke-dasharray:5 3;rx:6}
|
||||||
|
.ok{fill:#f6fdf6;stroke:#1a7f37;stroke-width:1.6;rx:6}
|
||||||
|
.warn{fill:#fff8f0;stroke:#bf8700;stroke-width:1.5;rx:6}
|
||||||
|
.t{font-size:12px;fill:#1f2328}
|
||||||
|
.tb{font-size:12.5px;fill:#1f2328;font-weight:600}
|
||||||
|
.s{font-size:10.5px;fill:#656d76}
|
||||||
|
.ttl{font-size:14px;fill:#1f2328;font-weight:600}
|
||||||
|
.r{stroke:#cf222e;stroke-width:1.6;fill:none}
|
||||||
|
.g{stroke:#1a7f37;stroke-width:1.6;fill:none}
|
||||||
|
.k{stroke:#8c959f;stroke-width:1.3;fill:none}
|
||||||
|
</style>
|
||||||
|
<defs>
|
||||||
|
<marker id="a" markerWidth="8" markerHeight="8" refX="7" refY="3" orient="auto"><path d="M0,0 L7,3 L0,6 z" fill="#8c959f"/></marker>
|
||||||
|
<marker id="ar" markerWidth="8" markerHeight="8" refX="7" refY="3" orient="auto"><path d="M0,0 L7,3 L0,6 z" fill="#cf222e"/></marker>
|
||||||
|
<marker id="ag" markerWidth="8" markerHeight="8" refX="7" refY="3" orient="auto"><path d="M0,0 L7,3 L0,6 z" fill="#1a7f37"/></marker>
|
||||||
|
</defs>
|
||||||
|
<text x="20" y="26" class="ttl">B-7 — BFF 와 정반대의 성질: 공유할 상태가 없고, 대신 겹침 구간도 없다</text>
|
||||||
|
<text x="20" y="44" class="s">oauth2-proxy 는 세션을 쿠키에 담는다</text>
|
||||||
|
<rect x="30" y="72" width="380" height="120" class="box"/>
|
||||||
|
<text x="46" y="94" class="tb">replica 는 무엇을 공유하나</text>
|
||||||
|
<rect x="46" y="106" width="160" height="34" class="box"/>
|
||||||
|
<text x="58" y="128" class="t">oauth2-proxy A</text>
|
||||||
|
<rect x="250" y="106" width="146" height="34" class="box"/>
|
||||||
|
<text x="262" y="128" class="t">oauth2-proxy B</text>
|
||||||
|
<rect x="106" y="152" width="230" height="30" class="ok"/>
|
||||||
|
<text x="118" y="172" class="s">같은 k8s Secret 을 읽는다</text>
|
||||||
|
<path d="M126 142 L180 150" class="g"/>
|
||||||
|
<path d="M316 142 L262 150" class="g"/>
|
||||||
|
<text x="46" y="196" class="s"> </text>
|
||||||
|
<rect x="440" y="72" width="390" height="120" class="ok"/>
|
||||||
|
<text x="456" y="94" class="tb">그래서 얻는 것</text>
|
||||||
|
<text x="456" y="118" class="s">공유할 서버 상태가 없다 → 콜백이 다른 replica 로 가도 된다</text>
|
||||||
|
<text x="456" y="140" class="s">replica 를 늘려도 세션 저장소 고민이 없다</text>
|
||||||
|
<text x="456" y="164" class="s">BFF 가 Redis·PostgreSQL 로 풀던 문제가 아예 생기지 않는다</text>
|
||||||
|
<rect x="30" y="212" width="380" height="106" class="dead"/>
|
||||||
|
<text x="46" y="234" class="tb">★ 대신 겹침 구간을 만들 수 없다</text>
|
||||||
|
<text x="46" y="258" class="t">--cookie-secret 은 단수다</text>
|
||||||
|
<text x="46" y="280" class="s">「옛 secret 도 당분간 받아준다」가 불가능하다</text>
|
||||||
|
<text x="46" y="300" class="s">교체하는 순간 모든 쿠키가 한꺼번에 무효가 된다</text>
|
||||||
|
<rect x="440" y="212" width="390" height="106" class="warn"/>
|
||||||
|
<text x="456" y="234" class="tb">교체하면 벌어지는 일</text>
|
||||||
|
<text x="456" y="258" class="s">옛 쿠키 → session ticket cookie failed validation</text>
|
||||||
|
<text x="456" y="278" class="s">Keycloak SSO 가 살아 있으면 로그인 화면 없이 조용히 재인증</text>
|
||||||
|
<text x="456" y="300" class="s" fill="#cf222e">서버 쪽 세션은 고아로 남는다 — 티켓을 못 풀어 지우지도 못한다</text>
|
||||||
|
<rect x="30" y="338" width="800" height="66" class="ok"/>
|
||||||
|
<text x="46" y="360" class="tb">→ B-7a 가 이어받았다</text>
|
||||||
|
<text x="46" y="382" class="s">「지울 수 없다」는 oauth2-proxy 의 한계일 뿐이었다. 운영자는 지울 수 있고, TTL 역산으로 고아만 골라낼 수 있다</text>
|
||||||
|
<text x="46" y="398" class="s">그리고 고아는 생성 후 정확히 1시간에 사라진다 — TTL 이 요청으로 갱신되지 않기 때문이다</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 3.7 KiB |
@@ -0,0 +1,74 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 860 500" font-family="-apple-system,BlinkMacSystemFont,'Segoe UI',Helvetica,sans-serif">
|
||||||
|
<style>
|
||||||
|
.box{fill:#fff;stroke:#8c959f;stroke-width:1.4;rx:6}
|
||||||
|
.dead{fill:#fff5f5;stroke:#cf222e;stroke-width:1.6;stroke-dasharray:5 3;rx:6}
|
||||||
|
.ok{fill:#f6fdf6;stroke:#1a7f37;stroke-width:1.6;rx:6}
|
||||||
|
.warn{fill:#fff8f0;stroke:#bf8700;stroke-width:1.5;rx:6}
|
||||||
|
.t{font-size:12px;fill:#1f2328}
|
||||||
|
.tb{font-size:12.5px;fill:#1f2328;font-weight:600}
|
||||||
|
.s{font-size:10.5px;fill:#656d76}
|
||||||
|
.ttl{font-size:14px;fill:#1f2328;font-weight:600}
|
||||||
|
.r{stroke:#cf222e;stroke-width:1.6;fill:none}
|
||||||
|
.g{stroke:#1a7f37;stroke-width:1.6;fill:none}
|
||||||
|
.k{stroke:#8c959f;stroke-width:1.3;fill:none}
|
||||||
|
</style>
|
||||||
|
<defs>
|
||||||
|
<marker id="a" markerWidth="8" markerHeight="8" refX="7" refY="3" orient="auto"><path d="M0,0 L7,3 L0,6 z" fill="#8c959f"/></marker>
|
||||||
|
<marker id="ar" markerWidth="8" markerHeight="8" refX="7" refY="3" orient="auto"><path d="M0,0 L7,3 L0,6 z" fill="#cf222e"/></marker>
|
||||||
|
<marker id="ag" markerWidth="8" markerHeight="8" refX="7" refY="3" orient="auto"><path d="M0,0 L7,3 L0,6 z" fill="#1a7f37"/></marker>
|
||||||
|
</defs>
|
||||||
|
<text x="20" y="26" class="ttl">B-7a — 고아 세션은 지울 수 있다. oauth2-proxy 가 못 할 뿐이다</text>
|
||||||
|
<text x="20" y="44" class="s">회전 2회 측정 · Redis + oauth2-proxy 로그 + 브라우저</text>
|
||||||
|
|
||||||
|
<text x="20" y="76" class="tb">왜 프록시는 못 지우는가 — 티켓의 구조</text>
|
||||||
|
<rect x="20" y="88" width="400" height="86" class="box"/>
|
||||||
|
<text x="34" y="110" class="t">쿠키에 담기는 것 = 티켓</text>
|
||||||
|
<rect x="36" y="120" width="180" height="30" class="box"/>
|
||||||
|
<text x="48" y="140" class="s">세션 ID → Redis 키 이름</text>
|
||||||
|
<rect x="224" y="120" width="180" height="30" class="box"/>
|
||||||
|
<text x="236" y="140" class="s">암호화 키 → 값 복호화</text>
|
||||||
|
<text x="34" y="166" class="s">티켓 전체가 cookie secret 으로 암호화되어 있다</text>
|
||||||
|
|
||||||
|
<rect x="444" y="88" width="396" height="86" class="dead"/>
|
||||||
|
<text x="458" y="110" class="tb">secret 을 바꾸면</text>
|
||||||
|
<text x="458" y="130" class="s">티켓을 못 연다 → 세션 ID 조차 못 읽는다</text>
|
||||||
|
<text x="458" y="148" class="s">→ 어느 Redis 키를 지울지 모른다</text>
|
||||||
|
<text x="458" y="166" class="s">Error removing session: error decoding ticket to clear session</text>
|
||||||
|
<path d="M424 131 L440 131" class="r" marker-end="url(#ar)"/>
|
||||||
|
|
||||||
|
<text x="20" y="208" class="tb">회전할 때마다 누적한다</text>
|
||||||
|
<line x1="70" y1="256" x2="800" y2="256" class="k"/>
|
||||||
|
<line x1="130" y1="244" x2="130" y2="268" stroke="#cf222e" stroke-width="2"/>
|
||||||
|
<line x1="430" y1="244" x2="430" y2="268" stroke="#cf222e" stroke-width="2"/>
|
||||||
|
<text x="92" y="288" class="s">1차 회전 11:29:56</text>
|
||||||
|
<text x="392" y="288" class="s">2차 회전 11:33:27</text>
|
||||||
|
|
||||||
|
<rect x="70" y="224" width="60" height="16" class="ok"/>
|
||||||
|
<text x="76" y="236" class="s">세션 A</text>
|
||||||
|
<rect x="130" y="224" width="300" height="16" class="dead"/>
|
||||||
|
<text x="150" y="236" class="s" fill="#cf222e">A 는 고아 · TTL 만료까지 남는다</text>
|
||||||
|
<rect x="130" y="224" width="0" height="0"/>
|
||||||
|
<rect x="150" y="304" width="280" height="16" class="ok"/>
|
||||||
|
<text x="156" y="316" class="s">세션 B (1차 회전 후 생성)</text>
|
||||||
|
<rect x="430" y="304" width="370" height="16" class="dead"/>
|
||||||
|
<text x="450" y="316" class="s" fill="#cf222e">B 도 고아가 된다</text>
|
||||||
|
<rect x="450" y="224" width="350" height="16" class="ok"/>
|
||||||
|
<text x="456" y="236" class="s">세션 C (2차 회전 후 생성)</text>
|
||||||
|
<text x="70" y="338" class="s">회전 1회 = 그 시점 로그인 사용자 수만큼의 고아</text>
|
||||||
|
|
||||||
|
<rect x="20" y="356" width="400" height="126" class="dead"/>
|
||||||
|
<text x="34" y="378" class="tb">Redis 만 보고는 구분할 수 없다</text>
|
||||||
|
<text x="34" y="400" class="s">이름 _oauth2_proxy-<불투명한 32자 hex> (동일 형식)</text>
|
||||||
|
<text x="34" y="418" class="s">type string (동일)</text>
|
||||||
|
<text x="34" y="436" class="s">크기 3510 바이트 (바이트까지 동일)</text>
|
||||||
|
<text x="34" y="454" class="s">값 암호화됨 (읽을 수 없다)</text>
|
||||||
|
<text x="34" y="474" class="s" fill="#cf222e">→ 다른 것은 TTL 하나뿐이다</text>
|
||||||
|
|
||||||
|
<rect x="444" y="356" width="396" height="126" class="ok"/>
|
||||||
|
<text x="458" y="378" class="tb">그래서 TTL 이 정리 규칙이 된다</text>
|
||||||
|
<text x="458" y="398" class="s">--cookie-refresh 가 없어 TTL 이 갱신되지 않는다 (refresh:disabled)</text>
|
||||||
|
<text x="458" y="416" class="s">→ TTL 은 생성 시각의 정확한 함수다</text>
|
||||||
|
<rect x="460" y="424" width="364" height="26" class="box"/>
|
||||||
|
<text x="470" y="442" class="t">생성시각 = 지금 − (cookie-expire − TTL)</text>
|
||||||
|
<text x="458" y="466" class="s" fill="#1a7f37">이 값이 회전 시각보다 이르면 고아. 검증: 역산 11:30:26 vs 로그 11:30:27 — 1초 오차</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 4.8 KiB |
@@ -0,0 +1,44 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 860 400" font-family="-apple-system,BlinkMacSystemFont,Segoe UI,Helvetica,sans-serif">
|
||||||
|
<style>
|
||||||
|
.box{fill:#fff;stroke:#8c959f;stroke-width:1.4;rx:6}
|
||||||
|
.dead{fill:#fff5f5;stroke:#cf222e;stroke-width:1.6;stroke-dasharray:5 3;rx:6}
|
||||||
|
.ok{fill:#f6fdf6;stroke:#1a7f37;stroke-width:1.6;rx:6}
|
||||||
|
.warn{fill:#fff8f0;stroke:#bf8700;stroke-width:1.5;rx:6}
|
||||||
|
.t{font-size:12px;fill:#1f2328}
|
||||||
|
.tb{font-size:12.5px;fill:#1f2328;font-weight:600}
|
||||||
|
.s{font-size:10.5px;fill:#656d76}
|
||||||
|
.ttl{font-size:14px;fill:#1f2328;font-weight:600}
|
||||||
|
.r{stroke:#cf222e;stroke-width:1.6;fill:none}
|
||||||
|
.g{stroke:#1a7f37;stroke-width:1.6;fill:none}
|
||||||
|
.k{stroke:#8c959f;stroke-width:1.3;fill:none}
|
||||||
|
</style>
|
||||||
|
<defs>
|
||||||
|
<marker id="a" markerWidth="8" markerHeight="8" refX="7" refY="3" orient="auto"><path d="M0,0 L7,3 L0,6 z" fill="#8c959f"/></marker>
|
||||||
|
<marker id="ar" markerWidth="8" markerHeight="8" refX="7" refY="3" orient="auto"><path d="M0,0 L7,3 L0,6 z" fill="#cf222e"/></marker>
|
||||||
|
<marker id="ag" markerWidth="8" markerHeight="8" refX="7" refY="3" orient="auto"><path d="M0,0 L7,3 L0,6 z" fill="#1a7f37"/></marker>
|
||||||
|
</defs>
|
||||||
|
<text x="20" y="26" class="ttl">C-2 — 로그아웃이 안 퍼지는 이유는 단순했다. 아무도 구현하지 않았다</text>
|
||||||
|
<text x="20" y="44" class="s">C-1 이 관측한 현상의 원인</text>
|
||||||
|
<text x="20" y="76" class="tb">백채널 로그아웃이 동작하려면 양쪽이 다 있어야 한다</text>
|
||||||
|
<rect x="30" y="94" width="380" height="110" class="dead"/>
|
||||||
|
<text x="46" y="116" class="tb">① IdP 쪽 — Keycloak 클라이언트 설정</text>
|
||||||
|
<text x="46" y="140" class="t">backchannelLogoutUrl</text>
|
||||||
|
<text x="46" y="162" class="t" fill="#cf222e">두 클라이언트 모두 비어 있다</text>
|
||||||
|
<text x="46" y="186" class="s">Keycloak 은 부를 주소를 모른다</text>
|
||||||
|
<rect x="440" y="94" width="390" height="110" class="dead"/>
|
||||||
|
<text x="456" y="116" class="tb">② 앱 쪽 — 수신 엔드포인트</text>
|
||||||
|
<text x="456" y="140" class="t">/logout/connect/back-channel/{registrationId}</text>
|
||||||
|
<text x="456" y="162" class="t" fill="#cf222e">BFF 소스에 oidcLogout 설정이 없다</text>
|
||||||
|
<text x="456" y="186" class="s">주소를 알려줘도 받을 곳이 없다</text>
|
||||||
|
<rect x="30" y="228" width="800" height="60" class="warn"/>
|
||||||
|
<text x="46" y="250" class="tb">★ 그래서 IdP 쪽만 설정하면 되는 줄 알면 틀린다</text>
|
||||||
|
<text x="46" y="272" class="s">backchannelLogoutUrl 을 넣어 봤지만 앱 세션은 그대로 남았다. 받는 쪽이 없기 때문이다</text>
|
||||||
|
<rect x="30" y="308" width="380" height="72" class="ok"/>
|
||||||
|
<text x="46" y="330" class="tb">네트워크 문제가 아니라는 확인</text>
|
||||||
|
<text x="46" y="352" class="s">Keycloak 파드에서 앱 URL 로 요청 → HTTP 200</text>
|
||||||
|
<text x="46" y="372" class="s">닿기는 한다. 닿아도 처리할 코드가 없을 뿐이다</text>
|
||||||
|
<rect x="440" y="308" width="390" height="72" class="box"/>
|
||||||
|
<text x="456" y="330" class="tb">남기는 것</text>
|
||||||
|
<text x="456" y="352" class="s">「설정이 빠졌다」와 「기능이 없다」는 다르게 고쳐야 한다</text>
|
||||||
|
<text x="456" y="372" class="s">여기는 둘 다였고, 확인 순서를 바꿨다면 한쪽만 고치고 끝냈을 것이다</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 3.2 KiB |
@@ -0,0 +1,48 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 860 420" font-family="-apple-system,BlinkMacSystemFont,Segoe UI,Helvetica,sans-serif">
|
||||||
|
<style>
|
||||||
|
.box{fill:#fff;stroke:#8c959f;stroke-width:1.4;rx:6}
|
||||||
|
.dead{fill:#fff5f5;stroke:#cf222e;stroke-width:1.6;stroke-dasharray:5 3;rx:6}
|
||||||
|
.ok{fill:#f6fdf6;stroke:#1a7f37;stroke-width:1.6;rx:6}
|
||||||
|
.warn{fill:#fff8f0;stroke:#bf8700;stroke-width:1.5;rx:6}
|
||||||
|
.t{font-size:12px;fill:#1f2328}
|
||||||
|
.tb{font-size:12.5px;fill:#1f2328;font-weight:600}
|
||||||
|
.s{font-size:10.5px;fill:#656d76}
|
||||||
|
.ttl{font-size:14px;fill:#1f2328;font-weight:600}
|
||||||
|
.r{stroke:#cf222e;stroke-width:1.6;fill:none}
|
||||||
|
.g{stroke:#1a7f37;stroke-width:1.6;fill:none}
|
||||||
|
.k{stroke:#8c959f;stroke-width:1.3;fill:none}
|
||||||
|
</style>
|
||||||
|
<defs>
|
||||||
|
<marker id="a" markerWidth="8" markerHeight="8" refX="7" refY="3" orient="auto"><path d="M0,0 L7,3 L0,6 z" fill="#8c959f"/></marker>
|
||||||
|
<marker id="ar" markerWidth="8" markerHeight="8" refX="7" refY="3" orient="auto"><path d="M0,0 L7,3 L0,6 z" fill="#cf222e"/></marker>
|
||||||
|
<marker id="ag" markerWidth="8" markerHeight="8" refX="7" refY="3" orient="auto"><path d="M0,0 L7,3 L0,6 z" fill="#1a7f37"/></marker>
|
||||||
|
</defs>
|
||||||
|
<text x="20" y="26" class="ttl">D-2 — 롤백은 안 된다. 다만 롤링 업데이트가 사고를 절반에서 멈춰줬다</text>
|
||||||
|
<text x="20" y="44" class="s">그리고 이 결론은 조건부였다 — 스키마가 안 바뀌면 롤백된다(후속에서 정밀화)</text>
|
||||||
|
<rect x="30" y="72" width="380" height="126" class="box"/>
|
||||||
|
<text x="46" y="94" class="tb">앞으로 (26.7.0 → 26.7.3)</text>
|
||||||
|
<text x="46" y="118" class="t" fill="#1a7f37">된다. 무중단</text>
|
||||||
|
<text x="46" y="140" class="s">87회 요청 전부 200</text>
|
||||||
|
<text x="46" y="160" class="s">databasechangelog 210 → 210 (스키마 변경 없음)</text>
|
||||||
|
<text x="46" y="180" class="s">세션 유지 · Infinispan 16.0.12 → 16.0.14</text>
|
||||||
|
<rect x="440" y="72" width="390" height="126" class="dead"/>
|
||||||
|
<text x="456" y="94" class="tb">뒤로 (스키마가 이미 움직였을 때)</text>
|
||||||
|
<text x="456" y="118" class="t" fill="#cf222e">안 된다</text>
|
||||||
|
<text x="456" y="140" class="s">liquibase ValidationFailedException:</text>
|
||||||
|
<text x="456" y="158" class="s">1 changesets check sum</text>
|
||||||
|
<text x="456" y="180" class="s">새 버전이 남긴 체크섬을 옛 버전이 거부한다</text>
|
||||||
|
<rect x="30" y="222" width="800" height="72" class="ok"/>
|
||||||
|
<text x="46" y="244" class="tb">★ 그런데 서비스는 살아 있었다 — StatefulSet 롤링 업데이트 덕분이다</text>
|
||||||
|
<text x="46" y="266" class="s">한 번에 한 파드씩 바꾼다. 첫 파드가 기동에 실패하면 거기서 멈추고 나머지는 건드리지 않는다</text>
|
||||||
|
<text x="46" y="286" class="s">그래서 남은 파드가 외부 200 을 계속 냈다. 「롤백 계획」이 없어도 사고가 전면화되지 않았다</text>
|
||||||
|
<rect x="30" y="314" width="380" height="90" class="warn"/>
|
||||||
|
<text x="46" y="336" class="tb">그러면 무엇으로 판단하나</text>
|
||||||
|
<text x="46" y="358" class="t">select count(*) from databasechangelog</text>
|
||||||
|
<text x="46" y="380" class="s">업그레이드 전후 이 수가 같으면 롤백 가능</text>
|
||||||
|
<text x="46" y="398" class="s">늘었으면 스키마가 움직였다 = 롤백 불가</text>
|
||||||
|
<rect x="440" y="314" width="390" height="90" class="box"/>
|
||||||
|
<text x="456" y="336" class="tb">전제 — 백업이 먼저다 (D-1)</text>
|
||||||
|
<text x="456" y="358" class="s">롤백이 막히는 상황에서 되돌릴 방법은 덤프 복원뿐이다</text>
|
||||||
|
<text x="456" y="380" class="s">D-1 이 그 절차를 재고, D-2 가 그 절차가 필요한 이유를 만든다</text>
|
||||||
|
<text x="456" y="398" class="s">순서를 바꾸면 되돌릴 수 없는 상태에서 백업을 배우게 된다</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 3.6 KiB |
@@ -0,0 +1,94 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 860 520" font-family="-apple-system,BlinkMacSystemFont,'Segoe UI',Helvetica,sans-serif">
|
||||||
|
<style>
|
||||||
|
.box{fill:#fff;stroke:#8c959f;stroke-width:1.4;rx:6}
|
||||||
|
.dead{fill:#fff5f5;stroke:#cf222e;stroke-width:1.6;stroke-dasharray:5 3;rx:6}
|
||||||
|
.ok{fill:#f6fdf6;stroke:#1a7f37;stroke-width:1.6;rx:6}
|
||||||
|
.t{font-size:12px;fill:#1f2328}
|
||||||
|
.tb{font-size:12.5px;fill:#1f2328;font-weight:600}
|
||||||
|
.s{font-size:10.5px;fill:#656d76}
|
||||||
|
.ttl{font-size:14px;fill:#1f2328;font-weight:600}
|
||||||
|
.r{stroke:#cf222e;stroke-width:1.6;fill:none}
|
||||||
|
.g{stroke:#1a7f37;stroke-width:1.6;fill:none}
|
||||||
|
.k{stroke:#8c959f;stroke-width:1.3;fill:none}
|
||||||
|
</style>
|
||||||
|
<defs>
|
||||||
|
<marker id="a" markerWidth="8" markerHeight="8" refX="7" refY="3" orient="auto"><path d="M0,0 L7,3 L0,6 z" fill="#8c959f"/></marker>
|
||||||
|
<marker id="ar" markerWidth="8" markerHeight="8" refX="7" refY="3" orient="auto"><path d="M0,0 L7,3 L0,6 z" fill="#cf222e"/></marker>
|
||||||
|
<marker id="ag" markerWidth="8" markerHeight="8" refX="7" refY="3" orient="auto"><path d="M0,0 L7,3 L0,6 z" fill="#1a7f37"/></marker>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
<text x="20" y="26" class="ttl">D-4 — 「갱신 성공」과 「새 인증서 서빙」 사이의 38분 25초</text>
|
||||||
|
<text x="20" y="44" class="s">측정: 2026-09-04 · 일련번호 5초 간격 564표본 · 새 연결 0.2초 간격 8856건</text>
|
||||||
|
|
||||||
|
<!-- 좌: 디스크 -->
|
||||||
|
<rect x="20" y="70" width="250" height="196" class="box"/>
|
||||||
|
<text x="34" y="92" class="tb">디스크 — certbot</text>
|
||||||
|
<rect x="36" y="104" width="218" height="46" class="box"/>
|
||||||
|
<text x="48" y="122" class="t">cert1.pem</text>
|
||||||
|
<text x="48" y="138" class="s">09-03 10:47:40 · notAfter Dec 2</text>
|
||||||
|
<rect x="36" y="158" width="218" height="46" class="ok"/>
|
||||||
|
<text x="48" y="176" class="t">cert2.pem</text>
|
||||||
|
<text x="48" y="192" class="s">09-04 17:22:13 · notAfter Dec 3</text>
|
||||||
|
<text x="36" y="224" class="s">certbot renew --force-renewal</text>
|
||||||
|
<text x="36" y="240" class="s">→ 성공. live/ 링크가 2번을 가리킨다</text>
|
||||||
|
<text x="36" y="256" class="s">경로는 그대로, 내용만 바뀐다</text>
|
||||||
|
|
||||||
|
<!-- 중: 끊긴 고리 -->
|
||||||
|
<rect x="300" y="96" width="260" height="144" class="dead"/>
|
||||||
|
<text x="316" y="118" class="tb">★ 잇는 것이 없다</text>
|
||||||
|
<text x="316" y="140" class="t">1. ExecStartPost</text>
|
||||||
|
<text x="470" y="140" class="t" fill="#cf222e">없음</text>
|
||||||
|
<text x="316" y="160" class="t">2. renewal-hooks/deploy/</text>
|
||||||
|
<text x="470" y="160" class="t" fill="#cf222e">비었음</text>
|
||||||
|
<text x="316" y="180" class="t">3. certbot nginx 플러그인</text>
|
||||||
|
<text x="470" y="180" class="t" fill="#cf222e">없음</text>
|
||||||
|
<text x="316" y="206" class="s">post/ · pre/ 도 비어 있다 (total 8)</text>
|
||||||
|
<text x="316" y="222" class="s">셋 다 비면 아무도 reload 를 부르지 않는다</text>
|
||||||
|
<path d="M270 168 L296 168" class="r" marker-end="url(#ar)"/>
|
||||||
|
<path d="M300 230 L560 108" stroke="#cf222e" stroke-width="1.2" stroke-dasharray="4 3"/>
|
||||||
|
<path d="M300 108 L560 230" stroke="#cf222e" stroke-width="1.2" stroke-dasharray="4 3"/>
|
||||||
|
|
||||||
|
<!-- 우: nginx -->
|
||||||
|
<rect x="590" y="70" width="250" height="196" class="box"/>
|
||||||
|
<text x="604" y="92" class="tb">nginx — 메모리</text>
|
||||||
|
<rect x="606" y="104" width="218" height="60" class="dead"/>
|
||||||
|
<text x="618" y="124" class="t">기동 시 읽은 cert1</text>
|
||||||
|
<text x="618" y="140" class="s">master 585 / worker 586</text>
|
||||||
|
<text x="618" y="156" class="s">둘 다 09-03 19:00:39, 22.4시간째</text>
|
||||||
|
<text x="606" y="184" class="s">reload 없이는 디스크를 다시 안 본다.</text>
|
||||||
|
<text x="606" y="200" class="s">reload 하면 마스터는 남고</text>
|
||||||
|
<text x="606" y="216" class="s">워커만 새로 뜬다 → 워커 PID 로 확인</text>
|
||||||
|
<text x="606" y="240" class="s">ssl_certificate .../fullchain.pem</text>
|
||||||
|
<path d="M564 168 L586 168" class="k" marker-end="url(#a)"/>
|
||||||
|
|
||||||
|
<!-- 타임라인 -->
|
||||||
|
<text x="20" y="306" class="tb">밖에서 본 것 — 일련번호 (5초 간격)</text>
|
||||||
|
<line x1="60" y1="356" x2="800" y2="356" class="k"/>
|
||||||
|
<line x1="60" y1="350" x2="60" y2="362" class="k"/>
|
||||||
|
<line x1="300" y1="350" x2="300" y2="362" class="k"/>
|
||||||
|
<line x1="660" y1="344" x2="660" y2="368" stroke="#1a7f37" stroke-width="2"/>
|
||||||
|
<line x1="800" y1="350" x2="800" y2="362" class="k"/>
|
||||||
|
<text x="46" y="382" class="s">08:10:51</text>
|
||||||
|
<text x="278" y="382" class="s">08:22:13</text>
|
||||||
|
<text x="620" y="382" class="s">08:58:52</text>
|
||||||
|
<text x="770" y="382" class="s">09:02</text>
|
||||||
|
<text x="278" y="398" class="s">디스크 기록</text>
|
||||||
|
<text x="618" y="398" class="s">nginx -s reload</text>
|
||||||
|
|
||||||
|
<rect x="60" y="330" width="600" height="16" class="dead"/>
|
||||||
|
<text x="300" y="342" class="s" text-anchor="middle" fill="#cf222e">0520BB…B853 (옛 인증서) — 이 구간에서 428회 관측</text>
|
||||||
|
<rect x="660" y="330" width="140" height="16" class="ok"/>
|
||||||
|
<text x="730" y="342" class="s" text-anchor="middle" fill="#1a7f37">06C7CB…EA1D</text>
|
||||||
|
<path d="M300 416 L660 416" class="r" marker-end="url(#ar)"/>
|
||||||
|
<path d="M660 416 L300 416" class="r" marker-end="url(#ar)"/>
|
||||||
|
<text x="480" y="410" class="tb" text-anchor="middle" fill="#cf222e">38분 25초</text>
|
||||||
|
<text x="480" y="434" class="s" text-anchor="middle">사람이 reload 했기에 36분이다. 아무도 안 했다면 다음 재시작까지 — 무기한</text>
|
||||||
|
|
||||||
|
<!-- reload 판정 -->
|
||||||
|
<rect x="20" y="452" width="400" height="52" class="ok"/>
|
||||||
|
<text x="34" y="472" class="tb">reload 는 무중단이었다</text>
|
||||||
|
<text x="34" y="490" class="s">새 연결 8856건 전부 200 · p95 205.7 → 204.3ms (변화 없음)</text>
|
||||||
|
<rect x="440" y="452" width="400" height="52" class="ok"/>
|
||||||
|
<text x="454" y="472" class="tb">진행 중이던 요청도 살아남았다</text>
|
||||||
|
<text x="454" y="490" class="s">전송 12초째에 reload · 845361바이트 전량 · 연결수 1</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 5.6 KiB |
@@ -0,0 +1,57 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 860 430" font-family="-apple-system,BlinkMacSystemFont,'Segoe UI',Helvetica,sans-serif">
|
||||||
|
<style>
|
||||||
|
.box{fill:#fff;stroke:#8c959f;stroke-width:1.4;rx:6}
|
||||||
|
.dead{fill:#fff5f5;stroke:#cf222e;stroke-width:1.6;stroke-dasharray:5 3;rx:6}
|
||||||
|
.ok{fill:#f6fdf6;stroke:#1a7f37;stroke-width:1.6;rx:6}
|
||||||
|
.warn{fill:#fff8f0;stroke:#bf8700;stroke-width:1.5;rx:6}
|
||||||
|
.t{font-size:12px;fill:#1f2328}
|
||||||
|
.tb{font-size:12.5px;fill:#1f2328;font-weight:600}
|
||||||
|
.s{font-size:10.5px;fill:#656d76}
|
||||||
|
.ttl{font-size:14px;fill:#1f2328;font-weight:600}
|
||||||
|
.r{stroke:#cf222e;stroke-width:1.6;fill:none}
|
||||||
|
.g{stroke:#1a7f37;stroke-width:1.6;fill:none}
|
||||||
|
.k{stroke:#8c959f;stroke-width:1.3;fill:none}
|
||||||
|
</style>
|
||||||
|
<defs>
|
||||||
|
<marker id="a" markerWidth="8" markerHeight="8" refX="7" refY="3" orient="auto"><path d="M0,0 L7,3 L0,6 z" fill="#8c959f"/></marker>
|
||||||
|
<marker id="ar" markerWidth="8" markerHeight="8" refX="7" refY="3" orient="auto"><path d="M0,0 L7,3 L0,6 z" fill="#cf222e"/></marker>
|
||||||
|
<marker id="ag" markerWidth="8" markerHeight="8" refX="7" refY="3" orient="auto"><path d="M0,0 L7,3 L0,6 z" fill="#1a7f37"/></marker>
|
||||||
|
</defs>
|
||||||
|
<text x="20" y="26" class="ttl">D-4a — deploy 훅 하나로 38분 25초가 1~2초가 된다</text>
|
||||||
|
<text x="20" y="44" class="s">시각은 전부 실제 UTC (test-server 시계에서 106초 보정)</text>
|
||||||
|
|
||||||
|
<rect x="20" y="68" width="820" height="112" class="dead"/>
|
||||||
|
<text x="34" y="90" class="tb">D-4 — 훅이 없을 때</text>
|
||||||
|
<line x1="60" y1="130" x2="800" y2="130" class="k"/>
|
||||||
|
<line x1="90" y1="120" x2="90" y2="140" stroke="#cf222e" stroke-width="2"/>
|
||||||
|
<line x1="740" y1="120" x2="740" y2="140" stroke="#1a7f37" stroke-width="2"/>
|
||||||
|
<rect x="90" y="122" width="650" height="16" class="dead"/>
|
||||||
|
<text x="415" y="134" class="s" text-anchor="middle" fill="#cf222e">옛 인증서를 계속 서빙 — 428회 관측</text>
|
||||||
|
<text x="62" y="158" class="s">08:20:27 디스크 기록</text>
|
||||||
|
<text x="660" y="158" class="s">08:58:52 사람이 nginx -s reload</text>
|
||||||
|
<text x="415" y="112" class="tb" text-anchor="middle" fill="#cf222e">2305초 = 38분 25초</text>
|
||||||
|
<text x="34" y="174" class="s">아무도 reload 하지 않았다면 다음 nginx 재시작까지 = 사실상 무기한</text>
|
||||||
|
|
||||||
|
<rect x="20" y="196" width="820" height="112" class="ok"/>
|
||||||
|
<text x="34" y="218" class="tb">D-4a — deploy 훅을 넣었을 때</text>
|
||||||
|
<line x1="60" y1="258" x2="800" y2="258" class="k"/>
|
||||||
|
<line x1="90" y1="248" x2="90" y2="268" stroke="#1a7f37" stroke-width="2"/>
|
||||||
|
<line x1="104" y1="248" x2="104" y2="268" stroke="#1a7f37" stroke-width="2"/>
|
||||||
|
<rect x="90" y="250" width="14" height="16" class="ok"/>
|
||||||
|
<text x="62" y="286" class="s">12:27:49.05 발급 (SCT — CT 로그의 독립 시계)</text>
|
||||||
|
<text x="360" y="286" class="s">12:27:50 nginx -t · 새 워커 37252 · 12:27:51 reload</text>
|
||||||
|
<text x="150" y="244" class="tb" fill="#1a7f37">1~2초, 자동</text>
|
||||||
|
<text x="34" y="302" class="s">certbot 이 파일을 쓴 직후 같은 실행 안에서 deploy 훅을 부른다 — 사람이 개입할 자리가 없다</text>
|
||||||
|
|
||||||
|
<rect x="20" y="324" width="400" height="92" class="box"/>
|
||||||
|
<text x="34" y="346" class="tb">판정은 문구가 아니라 워커 PID 로</text>
|
||||||
|
<text x="34" y="366" class="s">master 585 → 585 (유지)</text>
|
||||||
|
<text x="34" y="384" class="s">worker 28829 → 37252 (교체 = reload 됨)</text>
|
||||||
|
<text x="34" y="404" class="s">nginx 는 인증서를 기동 시 읽어 메모리에 든다</text>
|
||||||
|
|
||||||
|
<rect x="444" y="324" width="396" height="92" class="warn"/>
|
||||||
|
<text x="458" y="346" class="tb">★ 함정 — 성공을 실패로 오독한다</text>
|
||||||
|
<text x="458" y="366" class="s">Hook 'deploy-hook' ran with error output:</text>
|
||||||
|
<text x="458" y="384" class="s"> [warn] could not build optimal types_hash …</text>
|
||||||
|
<text x="458" y="402" class="s">nginx 경고가 stderr 로 나갔을 뿐. 내용은 test is successful</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 3.8 KiB |
@@ -0,0 +1,117 @@
|
|||||||
|
A-7a — volatile 모드에서 refresh 가 500 인 진짜 이유
|
||||||
|
=======================================================
|
||||||
|
수집: 2026-09-04 11:18 ~ 11:24 UTC · kc-lab-1 에서 sudo kubectl
|
||||||
|
|
||||||
|
A-7 이 남긴 가설
|
||||||
|
----------------
|
||||||
|
> **측정은 확실하지만 원인은 확정하지 못했다.** 유력한 후보는
|
||||||
|
> `REVOKED_TOKEN` 테이블이다 — refresh token 회전에서 이미 쓴 토큰인지
|
||||||
|
> 확인하려면 그 테이블을 봐야 하고, 그 경로는 캐시되지 않는다.
|
||||||
|
|
||||||
|
★ 이 가설은 틀렸다.
|
||||||
|
|
||||||
|
방법 — PostgreSQL 문장 로깅 (A-3 기법)
|
||||||
|
---------------------------------------
|
||||||
|
ALTER SYSTEM SET log_statement = 'all'; SELECT pg_reload_conf();
|
||||||
|
|
||||||
|
표식을 넣어 로그인과 refresh 가 각각 어떤 SQL 을 쏘는지 구분했다.
|
||||||
|
select 'MARK_LOGIN_START' ... 로그인 ... select 'MARK_LOGIN_END'
|
||||||
|
select 'MARK_REFRESH_START' ... refresh ... select 'MARK_REFRESH_END'
|
||||||
|
|
||||||
|
volatile 전환 확인
|
||||||
|
args: ["start","--features-disabled=persistent-user-sessions"]
|
||||||
|
로그인 200 · offline_user_session 행수 = 0 ← volatile 맞다
|
||||||
|
|
||||||
|
[측정 1] 로그인은 SQL 을 0개 쏜다
|
||||||
|
----------------------------------
|
||||||
|
11:18:49.461 statement: select 'MARK_LOGIN_START'
|
||||||
|
11:18:49.743 statement: select 'MARK_LOGIN_END'
|
||||||
|
↑ 사이에 아무것도 없다
|
||||||
|
|
||||||
|
realm·사용자·클라이언트가 전부 Infinispan 캐시에 있어 DB 를 안 본다.
|
||||||
|
|
||||||
|
[측정 2] ★ refresh 는 CLIENT_SCOPE_CLIENT 를 본다
|
||||||
|
--------------------------------------------------
|
||||||
|
11:18:52.009 statement: select 'MARK_REFRESH_START'
|
||||||
|
11:18:52.137 statement: BEGIN
|
||||||
|
11:18:52.137 execute <unnamed>/C_107:
|
||||||
|
select cscme1_0.SCOPE_ID from CLIENT_SCOPE_CLIENT cscme1_0
|
||||||
|
where cscme1_0.CLIENT_ID=$1 and cscme1_0.DEFAULT_SCOPE=$2
|
||||||
|
parameters: $1 = '131a9912-b578-4b9c-b16a-97518704077e', $2 = 'f'
|
||||||
|
11:18:52.148 execute S_2: COMMIT
|
||||||
|
11:18:52.253 statement: select 'MARK_REFRESH_END'
|
||||||
|
|
||||||
|
REVOKED_TOKEN 은 **한 번도 나오지 않는다.**
|
||||||
|
`DEFAULT_SCOPE='f'` 이므로 **선택적(optional) 클라이언트 스코프** 조회다.
|
||||||
|
|
||||||
|
[측정 3] 그 조회는 한 번뿐이다 — 캐시에 들어간다
|
||||||
|
-------------------------------------------------
|
||||||
|
연속 refresh 3회, 전부 200. 표식 사이 SQL:
|
||||||
|
statement: select 'MARK_R1'
|
||||||
|
statement: select 'MARK_R2'
|
||||||
|
statement: select 'MARK_R3'
|
||||||
|
statement: select 'MARK_R_END'
|
||||||
|
↑ SQL 0건
|
||||||
|
|
||||||
|
첫 refresh 가 캐시를 채우고, 이후로는 DB 를 보지 않는다.
|
||||||
|
|
||||||
|
════ 그래서 A-7 의 표 자체가 조건부였다 ════
|
||||||
|
|
||||||
|
같은 설정에서 **캐시 온도만으로 결과가 셋으로 갈린다.** 전부 측정했다.
|
||||||
|
|
||||||
|
┌──────────────────────┬────────┬─────────┬──────────────────────────────┐
|
||||||
|
│ 캐시 상태 │ 로그인 │ refresh │ 실패한 SQL │
|
||||||
|
├──────────────────────┼────────┼─────────┼──────────────────────────────┤
|
||||||
|
│ 완전 냉시동 │ 400 │ 400 │ select ce1_0.ID from CLIENT │
|
||||||
|
│ (재시작 직후) │ │ │ where CLIENT_ID=? REALM_ID=?│
|
||||||
|
├──────────────────────┼────────┼─────────┼──────────────────────────────┤
|
||||||
|
│ CLIENT 만 더움 │ 200 │ ★ 500 │ select cscme1_0.SCOPE_ID │
|
||||||
|
│ ← A-7 이 본 상태 │ │ │ from CLIENT_SCOPE_CLIENT │
|
||||||
|
├──────────────────────┼────────┼─────────┼──────────────────────────────┤
|
||||||
|
│ 완전히 더움 │ 200 │ 200 │ 없음 (SQL 0건) │
|
||||||
|
└──────────────────────┴────────┴─────────┴──────────────────────────────┘
|
||||||
|
|
||||||
|
[재현 A] 완전 냉시동 — 로그인부터 400
|
||||||
|
keycloak 재시작 → postgres 정지 → 로그인
|
||||||
|
로그인 400 {"error":"unauthorized_client",
|
||||||
|
"error_description":"Unexpected error when authenticating client"}
|
||||||
|
ERROR [org.keycloak.services] KC-SERVICES0015: Unexpected error when
|
||||||
|
authenticating client: org.hibernate.exception.GenericJDBCException:
|
||||||
|
JDBC exception executing SQL [FATAL: terminating connection due to
|
||||||
|
administrator command]
|
||||||
|
[select ce1_0.ID from CLIENT ce1_0 where ce1_0.CLIENT_ID=? and ce1_0.REALM_ID=?]
|
||||||
|
|
||||||
|
→ A-7 은 "volatile 이면 DB 없이 로그인된다"고 적었지만,
|
||||||
|
**냉시동에서는 그것도 안 된다.**
|
||||||
|
|
||||||
|
[재현 B] ★ A-7 이 본 그 조건 — 로그인 200, refresh 500
|
||||||
|
keycloak 재시작 → (DB 살아있을 때) 로그인 1회 → postgres 정지 → refresh
|
||||||
|
로그인 200
|
||||||
|
refresh 500 {"error":"unknown_error"}
|
||||||
|
JDBC exception executing SQL [FATAL: terminating connection due to
|
||||||
|
administrator command]
|
||||||
|
[select cscme1_0.SCOPE_ID from CLIENT_SCOPE_CLIENT cscme1_0
|
||||||
|
where cscme1_0.CLIENT_ID=? and cscme1_0.DEFAULT_SCOPE=?]
|
||||||
|
|
||||||
|
★ 로그가 실패한 SQL 을 직접 지목한다. CLIENT_SCOPE_CLIENT 다.
|
||||||
|
|
||||||
|
[재현 C] 완전히 더움 — 둘 다 200
|
||||||
|
refresh 를 3회 미리 돌려 캐시를 채운 뒤 postgres 정지
|
||||||
|
로그인 200
|
||||||
|
refresh 200 ← A-7 의 표와 정반대다
|
||||||
|
|
||||||
|
════ 결론 ════
|
||||||
|
|
||||||
|
1. **원인은 REVOKED_TOKEN 이 아니라 선택적 클라이언트 스코프 조회다.**
|
||||||
|
`CLIENT_SCOPE_CLIENT` 를 `DEFAULT_SCOPE='f'` 로 조회하는 그 한 문장이다.
|
||||||
|
|
||||||
|
2. **A-7 의 "volatile → refresh 500" 은 캐시가 차가울 때만 참이다.**
|
||||||
|
더워지면 200 이 된다. A-7 은 그 조건을 몰랐고, 그래서 표에 적지 않았다.
|
||||||
|
|
||||||
|
3. **"volatile 이면 DB 없이 로그인된다" 도 조건부다.**
|
||||||
|
냉시동에서는 400 이다. 클라이언트 조회조차 캐시에 없기 때문이다.
|
||||||
|
|
||||||
|
4. **일반화** — volatile 모드에서 DB 정지 시의 동작은 "무엇을 하느냐"가
|
||||||
|
아니라 **"그 경로가 이미 캐시를 채웠느냐"** 로 결정된다. 그래서 같은
|
||||||
|
명령이 재시작 직후와 얼마 쓴 뒤에 다른 답을 낸다. 이런 종류의 결과는
|
||||||
|
**한 번 재보고 표로 적으면 안 된다** — A-7 이 그렇게 했다.
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
# A-7a — volatile refresh 500 의 원인 확정 증거
|
||||||
|
|
||||||
|
2026-09-04 11:18 – 11:24 UTC
|
||||||
|
해설: [`docs/experiment-a7a-volatile-cause.md`](../../experiment-a7a-volatile-cause.md)
|
||||||
|
|
||||||
|
| 파일 | 무엇을 보여주는가 |
|
||||||
|
|---|---|
|
||||||
|
| `01-cause-determined.txt` | **가설이 틀렸다.** `REVOKED_TOKEN` 이 아니라 `CLIENT_SCOPE_CLIENT` 조회다. 문장 로깅으로 잡고, 실패 로그가 그 SQL 을 직접 지목한다. 그리고 **같은 설정에서 캐시 온도만으로 400/500/200 셋이 나온다** |
|
||||||
|
|
||||||
|
## 핵심 네 줄
|
||||||
|
|
||||||
|
1. **원인은 선택적 클라이언트 스코프 조회다.** `select cscme1_0.SCOPE_ID from CLIENT_SCOPE_CLIENT where CLIENT_ID=? and DEFAULT_SCOPE=?` — A-7 이 지목한 `REVOKED_TOKEN` 은 로그에 한 번도 안 나온다.
|
||||||
|
2. **로그인은 SQL 을 0개 쏜다.** refresh 만 이 한 문장을 쏘고, 그것도 **첫 번째만** 쏜다. 이후로는 캐시된다.
|
||||||
|
3. **A-7 의 표가 조건부였다.** 완전 냉시동이면 로그인부터 400, 캐시가 완전히 더우면 refresh 도 200. A-7 이 본 「로그인 200 · refresh 500」은 그 사이의 한 상태다 — 셋 다 재현했다.
|
||||||
|
4. **교훈** — volatile + DB 정지의 동작은 "무엇을 하느냐"가 아니라 **"그 경로가 캐시를 채웠느냐"** 로 갈린다. 한 번 재보고 표로 적으면 안 되는 종류의 측정이다.
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
B-7a — cookie secret 회전이 남기는 고아 세션의 수명과 정리
|
||||||
|
=============================================================
|
||||||
|
수집: 2026-09-04 11:29 ~ 11:34 UTC · Redis + oauth2-proxy 로그 + Playwright
|
||||||
|
|
||||||
|
B-7 이 남긴 것
|
||||||
|
--------------
|
||||||
|
| 관찰 | |
|
||||||
|
|---|---|
|
||||||
|
| 옛 쿠키 | 검증 실패 — session ticket cookie failed validation |
|
||||||
|
| 사용자 경험 | Keycloak SSO 가 살아 있어 조용히 재로그인 |
|
||||||
|
| **서버 쪽 세션** | **★ 지우지 못했다** |
|
||||||
|
|
||||||
|
`Error removing session: error decoding ticket to clear session`
|
||||||
|
→ **티켓을 못 푸니 Redis 키를 계산할 수 없고, 그래서 지울 수도 없다.**
|
||||||
|
|
||||||
|
B-7 은 여기서 멈췄다. 남은 물음 셋을 잰다.
|
||||||
|
(1) 고아의 TTL 은 실제로 줄어드는가 — 정말 사라지긴 하는가
|
||||||
|
(2) 운영자가 직접 지울 수 있는가 · 지우면 산 세션이 다치는가
|
||||||
|
(3) ★ 어느 키가 고아인지 구분할 수 있는가
|
||||||
|
|
||||||
|
[기준선] 회전 전 — 11:29:42 UTC
|
||||||
|
------------------------------------
|
||||||
|
secret = COOKIE_SECRET_A
|
||||||
|
_oauth2_proxy-f6a9201fd534a047998278452001ccbf
|
||||||
|
type=string ttl=3568초 크기=3510바이트
|
||||||
|
dbsize=1
|
||||||
|
|
||||||
|
설정: --cookie-expire=1h --session-store-type=redis
|
||||||
|
기동 로그: Cookie settings: name:_oauth2_proxy secure(https):true
|
||||||
|
httponly:true expiry:1h0m0s ... refresh:disabled
|
||||||
|
|
||||||
|
[주입] 1차 회전 A → B — 11:29:56 UTC
|
||||||
|
--------------------------------------
|
||||||
|
kubectl patch deployment oauth2-proxy ... COOKIE_SECRET_B
|
||||||
|
회전 직후 Redis: 키 그대로 1개 (회전만으로는 아무 일도 안 일어난다)
|
||||||
|
|
||||||
|
브라우저가 접근한 순간(11:30:27) 로그:
|
||||||
|
[stored_session.go:94] Error loading cookied session:
|
||||||
|
session ticket cookie failed validation: <nil>, removing session
|
||||||
|
[stored_session.go:97] Error removing session:
|
||||||
|
error decoding ticket to clear session: session ticket cookie failed validation
|
||||||
|
[oauthproxy.go:1024] No valid authentication in request. Initiating login.
|
||||||
|
[AuthSuccess] Authenticated via OAuth2: Session{email:labuser@example.com ...}
|
||||||
|
|
||||||
|
→ B-7 의 관찰 그대로 재현. Keycloak SSO 가 살아 있어 로그인 화면 없이 통과했다.
|
||||||
|
|
||||||
|
Redis:
|
||||||
|
_oauth2_proxy-87faa1c94db3bd72c11c4e100c3ca593 ttl=3588 ← 새 세션
|
||||||
|
_oauth2_proxy-f6a9201fd534a047998278452001ccbf ttl=3511 ← ★ 고아
|
||||||
|
dbsize=2
|
||||||
|
|
||||||
|
[측정 1] ★ Redis 만 보고는 구분할 수 없다
|
||||||
|
-------------------------------------------
|
||||||
|
키 type strlen ttl
|
||||||
|
_oauth2_proxy-87faa1c9…(새) string 3510 3558
|
||||||
|
_oauth2_proxy-f6a9201f…(고아) string 3510 3480
|
||||||
|
|
||||||
|
· 이름 접두사가 같다 (_oauth2_proxy-)
|
||||||
|
· 뒤는 불투명한 32자 hex — 사용자·시각·상태 어느 것도 안 담긴다
|
||||||
|
· 타입이 같다, 크기가 **바이트 단위로 같다** (3510)
|
||||||
|
· 값은 암호화되어 있다
|
||||||
|
새 "\xcb\xb3h\xfa\x98\xedc\xe4@<\x9b\x83\xce\xc1\x18<…"
|
||||||
|
고아 "N\xf5\x0e=\xe1N\xfc|\xa2qE\xde\x1b\x82k\x88\x05…"
|
||||||
|
md5 f9ad43cc6bbb2db4 / 9b31f7c4138e6472 (다르지만 뜻을 읽을 수 없다)
|
||||||
|
|
||||||
|
→ **다른 것은 TTL 뿐이다.**
|
||||||
|
|
||||||
|
[측정 2] TTL 은 정직하게 줄어든다 — 그리고 갱신되지 않는다
|
||||||
|
-----------------------------------------------------------
|
||||||
|
30초 간격 3회:
|
||||||
|
t+00초 새=3557 고아=3479
|
||||||
|
t+30초 새=3526 고아=3448
|
||||||
|
t+60초 새=3494 고아=3417
|
||||||
|
|
||||||
|
1초에 1초씩. 고아는 **생성 후 정확히 1시간에 사라진다.**
|
||||||
|
|
||||||
|
요청을 보내도 늘지 않는다 (11:32:26, 11:32:49 두 번 요청 후):
|
||||||
|
살아있는 세션 ttl=3464 ← 계속 줄어든다
|
||||||
|
기동 로그의 `refresh:disabled` 와 일치한다. `--cookie-refresh` 가 없기 때문이다.
|
||||||
|
|
||||||
|
★ 이것이 다음 측정의 열쇠가 된다 — TTL 이 갱신되지 않으므로
|
||||||
|
TTL 은 **생성 시각의 정확한 함수**다.
|
||||||
|
|
||||||
|
[측정 3] 운영자는 지울 수 있다 — 산 세션은 다치지 않는다
|
||||||
|
----------------------------------------------------------
|
||||||
|
redis-cli del _oauth2_proxy-f6a9201f… → 반환 1
|
||||||
|
dbsize 2 → 1
|
||||||
|
남은 키: _oauth2_proxy-87faa1c9…
|
||||||
|
|
||||||
|
삭제 직후 브라우저 요청 (11:32:49):
|
||||||
|
app2.hyeonworks.com GET - "/oauth2/userinfo" ... labuser@example.com 200 108
|
||||||
|
|
||||||
|
→ **200. 산 세션은 영향이 없다.**
|
||||||
|
oauth2-proxy 는 못 지우지만 **운영자는 지울 수 있다.**
|
||||||
|
|
||||||
|
[측정 4] ★ 누적한다 — 회전할 때마다
|
||||||
|
-------------------------------------
|
||||||
|
2차 회전 B → A — 11:33:27 UTC. 브라우저 재접근 후:
|
||||||
|
|
||||||
|
키 TTL 생성시각(추정) 판정
|
||||||
|
_oauth2_proxy-dad9c9fb… 3581 11:33:54 살아있음
|
||||||
|
_oauth2_proxy-87faa1c9… 3373 11:30:26 ★ 고아
|
||||||
|
dbsize=2
|
||||||
|
|
||||||
|
**1차 회전에서 살아남았던 세션이 2차 회전에서 고아가 됐다.**
|
||||||
|
회전 1회 = 그 시점 로그인 사용자 수만큼의 고아.
|
||||||
|
|
||||||
|
[측정 5] ★ 그래서 정리 규칙을 유도할 수 있다
|
||||||
|
----------------------------------------------
|
||||||
|
TTL 이 갱신되지 않으므로(측정 2):
|
||||||
|
|
||||||
|
생성시각 = 지금 - (cookie-expire - TTL)
|
||||||
|
|
||||||
|
이 값이 **회전 시각보다 이르면 그 키는 고아다.** 회전 이후에 만들어진
|
||||||
|
세션은 새 secret 으로 만들어졌으므로 반드시 유효하기 때문이다.
|
||||||
|
|
||||||
|
검증 — 추정 생성시각 11:30:26 vs 로그의 AuthSuccess 11:30:27.
|
||||||
|
**1초 오차.** 추정이 아니라 사실상 정확하다.
|
||||||
|
|
||||||
|
실행:
|
||||||
|
NOW=$(date -u +%s); ROT=<회전 시각 epoch>
|
||||||
|
redis-cli --scan --pattern '_oauth2_proxy-*' | while read K; do
|
||||||
|
T=$(redis-cli ttl "$K")
|
||||||
|
C=$(( NOW - (3600 - T) ))
|
||||||
|
[ $C -lt $ROT ] && redis-cli del "$K"
|
||||||
|
done
|
||||||
|
|
||||||
|
실제 실행 결과: `삭제: _oauth2_proxy-87faa1c9…` · 남은 dbsize=1
|
||||||
|
산 세션은 남고 고아만 사라졌다.
|
||||||
|
|
||||||
|
════ 결론 ════
|
||||||
|
|
||||||
|
1. **"지울 수 없다"는 oauth2-proxy 의 한계이지 Redis 의 한계가 아니다.**
|
||||||
|
프록시는 티켓을 못 풀어 키를 계산할 수 없다. 운영자는 키를 직접 안다.
|
||||||
|
|
||||||
|
2. **고아는 반드시 사라진다 — 생성 후 1시간.** TTL 이 갱신되지 않기 때문에
|
||||||
|
"쓰고 있으면 안 지워진다" 같은 일이 없다. 다만 그 1시간 동안은 남는다.
|
||||||
|
|
||||||
|
3. **어느 것이 고아인지는 Redis 값으로 알 수 없다.** 이름·타입·크기가
|
||||||
|
같고 값은 암호화되어 있다. **TTL 만이 신호다.**
|
||||||
|
|
||||||
|
4. **그 TTL 로 정리 규칙이 유도된다.** 회전 시각 이전에 생성된 키는 전부
|
||||||
|
고아다. 1초 오차로 정확히 골라낼 수 있고, 실제로 골라내 지웠다.
|
||||||
|
|
||||||
|
5. **전제가 하나 있다 — `--cookie-refresh` 를 켜면 이 규칙이 깨진다.**
|
||||||
|
TTL 이 갱신되면 생성 시각을 역산할 수 없기 때문이다. 그때는 회전 후
|
||||||
|
`FLUSHDB` 로 전부 지우고 모두 재인증시키는 편이 오히려 정직하다.
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
# B-7a — 고아 세션의 수명과 정리 증거
|
||||||
|
|
||||||
|
2026-09-04 11:29 – 11:34 UTC
|
||||||
|
해설: [`docs/experiment-b7a-orphan-session.md`](../../experiment-b7a-orphan-session.md)
|
||||||
|
|
||||||
|
| 파일 | 무엇을 보여주는가 |
|
||||||
|
|---|---|
|
||||||
|
| `01-orphan-lifecycle.txt` | 회전 2회로 고아가 **누적**하는 것 · TTL 이 1초/초로 줄고 **요청으로 갱신되지 않는 것** · Redis 만으로는 **구분 불가**(이름·타입·크기 동일, 값 암호화) · `redis-cli del` 로 지워도 산 세션은 **200** · TTL 역산 정리 규칙이 **1초 오차**로 맞는 것 |
|
||||||
|
| `b7a-live-session-after-orphan-delete.png` | 고아를 지운 직후 살아있는 세션이 `/oauth2/userinfo` 를 정상 응답하는 브라우저 화면 |
|
||||||
|
|
||||||
|
## 핵심 다섯 줄
|
||||||
|
|
||||||
|
1. **「지울 수 없다」는 oauth2-proxy 의 한계이지 Redis 의 한계가 아니다.** 프록시는 티켓을 못 풀어 키를 계산 못 한다. 운영자는 키를 직접 안다 — `del` 반환 1, dbsize 2→1, 산 세션은 그대로 200.
|
||||||
|
2. **고아는 반드시 사라진다 — 생성 후 정확히 1시간.** TTL 이 요청으로 갱신되지 않기 때문이다(`refresh:disabled`). 다만 그 1시간은 남는다.
|
||||||
|
3. **회전할 때마다 누적한다.** 1차 회전을 살아남은 세션이 2차 회전에서 고아가 됐다. 회전 1회 = 그 시점 로그인 사용자 수만큼.
|
||||||
|
4. **Redis 값으로는 고아를 못 고른다.** 이름 접두사·타입·크기(3510바이트)가 같고 값은 암호화되어 있다. **TTL 만이 신호다.**
|
||||||
|
5. **그 TTL 로 정리 규칙이 유도된다.** `생성시각 = 지금 − (cookie-expire − TTL)` 이 회전 시각보다 이르면 고아다. 추정 11:30:26 대 로그 11:30:27 — **1초 오차**. 실제로 골라 지웠고 산 세션만 남았다.
|
||||||
|
After Width: | Height: | Size: 18 KiB |
@@ -0,0 +1,19 @@
|
|||||||
|
D-4 대조군 — 주입 없는 상태의 가용성 잡음 바닥
|
||||||
|
수집 시각(dev): 2026-09-04T08:03:07Z UTC
|
||||||
|
대상: https://auth.hyeonworks.com/realms/master · 0.2초 간격 900회 = 180초
|
||||||
|
형식: http_code time_total time_appconnect(TLS 핸드셰이크까지)
|
||||||
|
왜: 갱신 중 000 이 한 번 나와도, 평시 오류율을 모르면 그게 갱신 탓인지 알 수 없다.
|
||||||
|
----------------------------------------------------------------
|
||||||
|
표본 900 개
|
||||||
|
|
||||||
|
[상태코드 분포]
|
||||||
|
900 200
|
||||||
|
|
||||||
|
[응답시간 ms]
|
||||||
|
최소 67 중앙 98 p95 195 최대 1121 평균 106.9
|
||||||
|
|
||||||
|
[TLS 핸드셰이크 ms — 0 이면 연결 재사용, >0 이면 새 핸드셰이크]
|
||||||
|
핸드셰이크 발생 900회 / 900 평균 83 ms 최대 1100 ms
|
||||||
|
|
||||||
|
[비정상 응답 원문 — 있으면 아래에 전부]
|
||||||
|
비200 총 0
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
D-4 대조군 2 — '진행 중이던 요청' 측정 장치의 무주입 동작
|
||||||
|
수집 시각(dev): 2026-09-04T08:08:45Z UTC
|
||||||
|
|
||||||
|
왜 이 장치가 따로 필요한가
|
||||||
|
05-control 의 0.2초 폴링은 매 요청이 새 TCP 연결이다(핸드셰이크 900/900).
|
||||||
|
그래서 '새 연결을 받아주는가'는 재지만, D-4 가 묻는 '이미 진행 중이던
|
||||||
|
요청이 어떻게 되는가'는 재지 못한다. 재하려면 reload 순간에 실제로
|
||||||
|
전송 중인 요청이 있어야 한다.
|
||||||
|
|
||||||
|
장치
|
||||||
|
845KB 짜리 관리 콘솔 번들을 --limit-rate 20k 로 내려받는다.
|
||||||
|
응답을 일부러 느리게 읽어 요청을 ~42초 동안 살아 있게 만든다.
|
||||||
|
대상: https://auth.hyeonworks.com/resources/55yjq/admin/keycloak.v2/assets/main-BbID33M6.js
|
||||||
|
----------------------------------------------------------------
|
||||||
|
[대조군: 주입 없이 1회]
|
||||||
|
코드=200 받은바이트=845361 총시간=41.392198s 연결수=1 실효속도=20423B/s
|
||||||
|
기대 크기 845361 / 실제 845361 bytes
|
||||||
|
|
||||||
|
판정 기준 (주입 시 이 값들과 비교한다)
|
||||||
|
· 코드 200 + 크기 845361 = 진행 중이던 요청이 끝까지 살아남았다(graceful)
|
||||||
|
· 코드 000 또는 크기 부족 = reload 가 진행 중이던 연결을 끊었다
|
||||||
|
· 연결수 2 이상 = 중간에 끊겨 curl 이 다시 붙었다
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
D-4 — 갱신은 되는데 nginx 가 집지 않는다: 배포 훅 부재
|
||||||
|
=========================================================
|
||||||
|
수집: 2026-09-04 17:2x KST · test-server (sudo 없이 읽을 수 있는 범위)
|
||||||
|
|
||||||
|
발단
|
||||||
|
----
|
||||||
|
사용자가 `sudo certbot renew --force-renewal` 을 실행했다고 알려왔다.
|
||||||
|
그런데 밖에서 본 인증서가 바뀌지 않았다.
|
||||||
|
|
||||||
|
serial=0520BB6416D569E26697B1691440F523B853
|
||||||
|
notBefore=Sep 3 00:47:23 2026 GMT ← 어제 것 그대로
|
||||||
|
notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
|
||||||
|
일련번호 감시 161표본(약 13분) 동안 단 한 번도 바뀌지 않았다.
|
||||||
|
|
||||||
|
[증거 1] nginx 는 reload 된 적이 없다
|
||||||
|
--------------------------------------
|
||||||
|
$ ps -eo pid,ppid,etimes,lstart,args | grep nginx
|
||||||
|
585 1 80529 Thu Sep 3 19:00:39 2026 nginx: master process /usr/bin/nginx
|
||||||
|
586 585 80529 Thu Sep 3 19:00:39 2026 nginx: worker process
|
||||||
|
|
||||||
|
읽는 법 — nginx 의 reload 는 마스터를 유지한 채 **워커만 새로 띄운다.**
|
||||||
|
· 마스터 585, 워커 586 = 마스터 기동 직후의 첫 fork
|
||||||
|
· 둘의 lstart 가 같고 etimes 도 같다(80529초 = 22.4시간)
|
||||||
|
→ reload 가 한 번도 없었다. 22.4시간 전 기동 그대로다.
|
||||||
|
|
||||||
|
인증서 파일이 새로 써졌더라도, nginx 는 기동 시점에 읽어 메모리에 들고 있다.
|
||||||
|
reload 가 없으면 **옛 인증서를 계속 서빙한다.**
|
||||||
|
|
||||||
|
[증거 2] ★ 갱신 유닛에 reload 가 없다
|
||||||
|
--------------------------------------
|
||||||
|
$ systemctl cat certbot-renew.service
|
||||||
|
# /usr/lib/systemd/system/certbot-renew.service
|
||||||
|
[Unit]
|
||||||
|
Description=Renew certificates acquired via Certbot
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
ExecStart=/usr/bin/certbot -q renew
|
||||||
|
PrivateTmp=true
|
||||||
|
|
||||||
|
`ExecStart` 가 전부다. **ExecStartPost 도, --deploy-hook 도 없다.**
|
||||||
|
배포판(Arch)이 넣어준 기본 유닛이 그렇다.
|
||||||
|
|
||||||
|
$ systemctl cat certbot-renew.timer
|
||||||
|
OnCalendar=*-*-* 00/12:00:00
|
||||||
|
RandomizedDelaySec=12h
|
||||||
|
Persistent=true
|
||||||
|
|
||||||
|
[증거 3] 타이머는 정상 동작한다 — 그래서 더 위험하다
|
||||||
|
------------------------------------------------------
|
||||||
|
$ systemctl status certbot-renew.service
|
||||||
|
Active: inactive (dead) since Fri 2026-09-04 17:04:11 KST
|
||||||
|
Process: 28452 ExecStart=/usr/bin/certbot -q renew (code=exited, status=0/SUCCESS)
|
||||||
|
|
||||||
|
$ journalctl -u certbot-renew.service --since today
|
||||||
|
Sep 04 03:19:39 Starting Renew certificates acquired via Certbot...
|
||||||
|
Sep 04 03:19:41 Finished Renew certificates acquired via Certbot.
|
||||||
|
Sep 04 17:04:09 Starting Renew certificates acquired via Certbot...
|
||||||
|
Sep 04 17:04:11 Finished Renew certificates acquired via Certbot.
|
||||||
|
|
||||||
|
오늘 두 번 돌았고 두 번 다 status=0/SUCCESS 다. 만료까지 88일 남아 실제
|
||||||
|
갱신은 하지 않았으므로 **아직은** 아무 문제가 없다.
|
||||||
|
|
||||||
|
[증거 4] nginx 가 무엇을 물고 있나
|
||||||
|
-----------------------------------
|
||||||
|
$ grep -rn ssl_certificate /etc/nginx/
|
||||||
|
/etc/nginx/sites-available/keycloak-lab:18: ssl_certificate /etc/letsencrypt/live/auth.hyeonworks.com/fullchain.pem;
|
||||||
|
/etc/nginx/sites-available/keycloak-lab:19: ssl_certificate_key /etc/letsencrypt/live/auth.hyeonworks.com/privkey.pem;
|
||||||
|
|
||||||
|
`live/` 는 심볼릭 링크다. certbot 이 갱신하면 링크가 새 `archive/` 파일을
|
||||||
|
가리키도록 바뀐다 — **파일 경로는 그대로인데 내용이 바뀐다.** 그래서 nginx
|
||||||
|
설정은 고칠 필요가 없고, 바로 그 때문에 "설정이 그대로니 괜찮다"고 착각하기 쉽다.
|
||||||
|
필요한 것은 설정 변경이 아니라 **reload** 다.
|
||||||
|
|
||||||
|
무엇을 확인하지 못했나
|
||||||
|
----------------------
|
||||||
|
`/etc/letsencrypt/renewal-hooks/deploy/` 에 스크립트가 있으면 유닛 파일과
|
||||||
|
무관하게 실행된다. 이 디렉터리는 root 전용이라 읽지 못했다.
|
||||||
|
|
||||||
|
$ ls -laR /etc/letsencrypt/renewal-hooks/
|
||||||
|
ls: cannot access '/etc/letsencrypt/renewal-hooks/': Permission denied
|
||||||
|
|
||||||
|
따라서 두 갈래가 남는다.
|
||||||
|
(a) 강제 갱신이 실패했다 → 파일도 안 바뀌었고 reload 도 없었다
|
||||||
|
(b) 강제 갱신이 성공했다 → 파일은 바뀌었는데 훅이 없어 reload 가 안 됐다
|
||||||
|
|
||||||
|
(b) 라면 이 실험대에서 가장 값진 발견이다. **"갱신 성공"과 "새 인증서 서빙"이
|
||||||
|
다른 사건**이라는 것을, 88일 뒤가 아니라 지금 잡은 것이기 때문이다.
|
||||||
|
|
||||||
|
가리는 방법 — root 권한 한 번:
|
||||||
|
sudo certbot certificates # notAfter 가 오늘+90일이면 (b)
|
||||||
|
sudo ls -la /etc/letsencrypt/archive/auth.hyeonworks.com/
|
||||||
|
sudo tail -60 /var/log/letsencrypt/letsencrypt.log
|
||||||
|
|
||||||
|
곁다리로 확인한 것 — CT 로그로는 가릴 수 없다
|
||||||
|
-----------------------------------------------
|
||||||
|
발급 사실은 Certificate Transparency 에 남으므로 sudo 없이 확인할 수 있을 것
|
||||||
|
같았다. 실제로 서빙 중인 인증서에는 SCT 가 2개 박혀 있다.
|
||||||
|
|
||||||
|
CT Precertificate SCTs:
|
||||||
|
Log ID: C2:31:7E:57:...:52:CD Timestamp: Sep 3 01:45:53.183 2026 GMT
|
||||||
|
Log ID: 46:AF:86:3D:...:50:5F Timestamp: Sep 3 01:45:53.352 2026 GMT
|
||||||
|
|
||||||
|
그런데 crt.sh 는 이 이름을 모른다.
|
||||||
|
|
||||||
|
$ curl -s 'https://crt.sh/?q=auth.hyeonworks.com&output=json'
|
||||||
|
[] ← 0건
|
||||||
|
$ curl -s 'https://crt.sh/?q=hyeonworks.com&output=json'
|
||||||
|
13건, 최신 not_before=2026-08-11 ← auth 는 없다
|
||||||
|
|
||||||
|
**인증서에 SCT 가 박혀 있다는 것과 crt.sh 가 그것을 색인했다는 것은 다르다.**
|
||||||
|
관측 도구가 진실의 부분집합만 본다는, A-2 의 `up` 지표와 같은 종류의 함정이다.
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
D-4 — in-flight 감시에서 나온 76건 실패: 서버 탓이 아니다
|
||||||
|
============================================================
|
||||||
|
수집: 2026-09-04 · dev 머신에서 실행한 감시 로그
|
||||||
|
|
||||||
|
무엇이 보였나
|
||||||
|
-------------
|
||||||
|
42초짜리 in-flight 요청을 연달아 돌리는 감시에서, 91건 중 76건이 실패했다.
|
||||||
|
|
||||||
|
08:13:40 코드=200 바이트=845361 시간=41.041830 연결수=1
|
||||||
|
08:14:22 코드=200 바이트=845361 시간=42.338482 연결수=1
|
||||||
|
08:15:04 코드=000 바이트=0 시간=0.001148 연결수=0 ← 여기부터
|
||||||
|
08:15:04 코드=000 바이트=0 시간=0.001140 연결수=0
|
||||||
|
08:15:04 코드=000 바이트=0 시간=0.000055 연결수=0
|
||||||
|
... (76건, 전부 08:15:04)
|
||||||
|
08:15:04 코드=200 바이트=845361 시간=42.236496 연결수=1 ← 곧바로 복귀
|
||||||
|
08:15:47 코드=200 바이트=845361 시간=42.327129 연결수=1
|
||||||
|
|
||||||
|
이걸 "갱신 중 진행 중이던 요청이 끊겼다"로 읽으면 안 되는 이유
|
||||||
|
----------------------------------------------------------------
|
||||||
|
1. **같은 순간 폴링은 멀쩡했다.** 0.2초 간격 폴링의 08:15:00–08:15:20 구간
|
||||||
|
표본 49건이 전부 200이다. 같은 호스트, 같은 TLS, 같은 머신이다.
|
||||||
|
|
||||||
|
08:15:00.24 200 0.092491
|
||||||
|
08:15:01.15 200 0.099177
|
||||||
|
08:15:02.38 200 0.078729
|
||||||
|
... 49건 / 비200 0건
|
||||||
|
|
||||||
|
2. **연결수=0 이다.** curl 이 TCP 연결을 시도조차 못 했다는 뜻이다.
|
||||||
|
서버가 연결을 끊었다면 연결수는 1이고 바이트는 0보다 크다.
|
||||||
|
|
||||||
|
3. **시간이 0.00005초다.** 50마이크로초. DNS 조회조차 이보다 오래 걸린다.
|
||||||
|
요청이 나가기 전에 로컬에서 실패했다.
|
||||||
|
|
||||||
|
4. **76건이 1초 안에 몰렸다.** 실패가 즉시 반환되니 while 루프가 폭주한 것이다.
|
||||||
|
시간축에 퍼진 장애가 아니라 순간의 스파이크다.
|
||||||
|
|
||||||
|
5. **재현되지 않는다.**
|
||||||
|
같은 URL 100회 연속 → 실패 0/100
|
||||||
|
DNS 해석 200회 → 실패 0/200
|
||||||
|
URL 유효성 → 코드=200 크기=845361, 콘솔이 참조하는 경로와 동일
|
||||||
|
|
||||||
|
6. **nginx 는 그 시각에 아무 일도 하지 않았다.** 마스터·워커가 22.4시간째
|
||||||
|
같은 프로세스다(07 참조). reload 도 재시작도 없었다.
|
||||||
|
|
||||||
|
결론
|
||||||
|
----
|
||||||
|
**로컬(dev 머신) 쪽 일시적 원인이며, 서버 가용성 사건이 아니다.**
|
||||||
|
정확한 원인은 특정하지 못했다 — curl 을 `-s` 로 돌려 오류 메시지를 버렸고,
|
||||||
|
종료 코드도 기록하지 않았기 때문이다.
|
||||||
|
|
||||||
|
고친 것
|
||||||
|
-------
|
||||||
|
감시를 종료 코드까지 남기는 형태로 교체했다. 다음에 같은 일이 생기면
|
||||||
|
curl 의 종료 코드(6=DNS, 7=connect, 35=TLS ...)로 원인이 바로 나온다.
|
||||||
|
실패 시 1초 쉬게 해 루프 폭주도 막았다.
|
||||||
|
|
||||||
|
R=$(curl -s --limit-rate 20k -o /dev/null -w "..." "$URL" 2>/dev/null); E=$?
|
||||||
|
echo "$T $R curl종료=$E"
|
||||||
|
[ $E -ne 0 ] && sleep 1
|
||||||
|
|
||||||
|
남기는 교훈
|
||||||
|
-----------
|
||||||
|
측정 장치가 실패했을 때 **왜 실패했는지 남기지 않으면, 그 실패를 대상 탓으로
|
||||||
|
돌릴지 장치 탓으로 돌릴지 판단할 근거가 없다.** 여기서는 대조 폴링이 같은
|
||||||
|
시각에 멀쩡했다는 사실 하나로 겨우 갈랐다. 대조군이 없었다면 이 76건은
|
||||||
|
"갱신 중 대규모 요청 실패"라는 그럴듯한 오보가 됐을 것이다.
|
||||||
@@ -0,0 +1,564 @@
|
|||||||
|
08:10:51 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:10:56 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:11:01 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:11:06 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:11:12 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:11:17 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:11:22 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:11:27 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:11:32 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:11:37 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:11:42 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:11:47 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:11:52 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:11:58 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:12:03 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:12:08 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:12:13 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:12:18 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:12:23 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:12:28 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:12:33 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:12:38 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:12:44 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:12:49 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:12:54 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:12:59 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:13:04 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:13:09 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:13:14 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:13:19 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:13:25 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:13:30 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:13:35 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:13:40 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:13:45 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:13:50 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:13:55 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:14:00 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:14:05 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:14:10 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:14:15 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:14:21 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:14:26 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:14:31 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:14:36 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:14:41 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:14:46 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:14:51 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:14:56 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:15:01 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:15:07 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:15:12 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:15:17 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:15:22 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:15:27 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:15:32 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:15:37 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:15:42 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:15:48 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:15:53 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:15:58 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:16:03 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:16:08 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:16:13 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:16:19 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:16:24 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:16:29 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:16:34 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:16:39 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:16:44 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:16:50 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:16:55 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:17:00 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:17:05 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:17:10 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:17:15 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:17:20 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:17:25 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:17:31 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:17:36 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:17:41 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:17:46 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:17:51 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:17:56 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:18:01 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:18:06 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:18:12 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:18:17 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:18:22 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:18:27 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:18:32 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:18:37 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:18:42 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:18:48 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:18:53 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:18:58 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:19:03 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:19:08 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:19:13 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:19:18 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:19:23 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:19:29 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:19:34 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:19:39 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:19:44 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:19:49 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:19:54 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:19:59 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:20:05 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:20:10 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:20:15 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:20:20 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:20:25 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:20:30 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:20:35 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:20:40 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:20:45 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:20:51 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:20:56 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:21:01 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:21:06 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:21:11 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:21:16 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:21:21 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:21:26 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:21:31 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:21:37 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:21:42 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:21:47 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:21:52 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:21:57 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:22:02 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:22:07 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:22:12 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:22:17 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:22:22 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:22:28 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:22:33 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:22:38 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:22:43 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:22:48 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:22:53 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:22:58 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:23:03 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:23:08 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:23:13 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:23:19 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:23:24 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:23:29 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:23:34 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:23:39 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:23:44 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:23:49 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:23:54 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:24:00 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:24:05 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:24:10 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:24:15 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:24:20 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:24:25 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:24:30 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:24:35 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:24:40 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:24:46 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:24:51 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:24:56 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:25:01 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:25:06 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:25:11 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:25:16 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:25:21 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:25:26 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:25:31 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:25:37 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:25:42 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:25:47 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:25:52 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:25:57 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:26:02 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:26:07 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:26:12 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:26:17 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:26:23 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:26:28 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:26:33 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:26:38 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:26:43 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:26:48 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:26:53 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:26:58 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:27:03 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:27:08 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:27:14 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:27:19 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:27:24 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:27:29 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:27:34 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:27:39 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:27:44 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:27:49 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:27:54 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:28:00 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:28:05 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:28:10 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:28:15 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:28:20 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:28:25 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:28:30 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:28:35 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:28:40 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:28:46 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:28:51 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:28:56 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:29:01 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:29:06 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:29:11 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:29:16 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:29:21 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:29:26 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:29:31 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:29:36 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:29:42 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:29:47 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:29:52 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:29:57 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:30:02 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:30:07 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:30:12 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:30:17 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:30:22 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:30:28 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:30:33 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:30:38 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:30:43 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:30:48 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:30:53 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:30:58 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:31:03 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:31:08 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:31:13 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:31:19 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:31:24 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:31:29 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:31:34 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:31:39 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:31:44 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:31:49 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:31:54 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:31:59 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:32:05 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:32:10 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:32:15 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:32:20 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:32:25 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:32:30 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:32:35 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:32:40 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:32:46 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:32:51 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:32:56 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:33:01 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:33:06 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:33:11 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:33:16 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:33:21 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:33:26 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:33:32 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:33:37 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:33:42 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:33:47 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:33:52 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:33:57 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:34:02 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:34:07 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:34:13 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:34:18 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:34:23 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:34:28 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:34:33 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:34:38 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:34:43 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:34:48 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:34:54 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:34:59 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:35:04 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:35:09 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:35:14 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:35:19 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:35:24 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:35:29 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:35:35 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:35:40 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:35:45 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:35:50 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:35:56 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:36:01 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:36:06 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:36:11 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:36:16 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:36:22 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:36:27 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:36:32 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:36:37 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:36:42 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:36:47 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:36:52 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:36:57 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:37:03 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:37:08 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:37:13 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:37:18 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:37:23 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:37:28 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:37:33 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:37:38 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:37:43 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:37:48 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:37:54 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:37:59 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:38:04 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:38:09 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:38:14 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:38:19 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:38:24 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:38:29 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:38:35 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:38:40 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:38:45 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:38:50 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:38:55 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:39:00 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:39:05 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:39:10 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:39:15 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:39:21 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:39:26 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:39:31 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:39:36 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:39:41 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:39:46 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:39:51 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:39:56 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:40:01 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:40:07 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:40:12 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:40:17 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:40:22 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:40:27 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:40:32 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:40:38 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:40:43 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:40:48 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:40:53 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:40:58 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:41:03 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:41:08 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:41:14 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:41:19 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:41:24 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:41:29 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:41:35 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:41:40 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:41:45 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:41:50 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:41:55 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:42:00 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:42:05 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:42:10 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:42:16 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:42:21 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:42:26 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:42:31 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:42:36 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:42:41 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:42:46 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:42:51 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:42:56 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:43:01 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:43:07 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:43:12 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:43:17 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:43:22 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:43:27 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:43:32 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:43:37 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:43:42 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:43:48 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:43:53 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:43:58 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:44:03 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:44:08 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:44:14 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:44:19 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:44:24 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:44:29 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:44:34 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:44:39 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:44:45 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:44:50 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:44:55 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:45:00 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:45:06 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:45:11 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:45:16 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:45:21 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:45:26 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:45:31 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:45:36 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:45:41 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:45:47 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:45:52 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:45:57 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:46:02 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:46:07 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:46:12 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:46:17 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:46:23 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:46:28 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:46:33 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:46:38 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:46:43 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:46:49 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:46:54 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:46:59 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:47:04 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:47:09 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:47:15 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:47:20 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:47:25 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:47:30 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:47:35 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:47:40 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:47:45 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:47:50 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:47:56 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:48:01 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:48:06 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:48:11 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:48:16 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:48:21 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:48:26 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:48:31 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:48:37 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:48:42 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:48:47 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:48:52 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:48:57 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:49:03 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:49:08 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:49:13 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:49:18 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:49:23 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:49:28 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:49:33 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:49:39 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:49:44 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:49:49 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:49:54 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:49:59 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:50:04 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:50:10 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:50:15 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:50:20 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:50:25 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:50:30 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:50:35 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:50:40 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:50:46 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:50:51 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:50:56 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:51:01 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:51:06 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:51:11 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:51:16 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:51:21 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:51:27 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:51:32 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:51:37 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:51:42 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:51:47 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:51:52 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:51:57 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:52:02 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:52:08 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:52:13 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:52:18 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:52:23 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:52:28 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:52:33 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:52:38 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:52:43 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:52:48 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:52:54 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:52:59 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:53:04 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:53:09 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:53:14 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:53:19 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:53:24 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:53:29 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:53:35 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:53:40 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:53:45 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:53:50 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:53:55 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:54:00 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:54:05 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:54:10 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:54:16 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:54:21 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:54:26 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:54:31 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:54:36 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:54:41 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:54:46 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:54:51 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:54:57 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:55:02 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:55:07 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:55:12 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:55:17 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:55:22 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:55:27 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:55:32 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:55:37 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:55:43 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:55:48 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:55:53 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:55:58 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:56:03 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:56:08 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:56:13 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:56:19 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:56:24 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:56:29 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:56:34 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:56:39 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:56:44 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:56:49 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:56:54 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:56:59 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:57:05 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:57:10 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:57:15 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:57:20 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:57:25 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:57:30 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:57:35 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:57:40 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:57:46 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:57:51 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:57:56 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:58:01 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:58:06 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:58:11 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:58:16 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:58:22 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:58:27 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:58:32 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:58:37 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:58:42 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:58:47 serial=0520BB6416D569E26697B1691440F523B853 notAfter=Dec 2 00:47:22 2026 GMT
|
||||||
|
08:58:52 serial=06C7CB6DF1DA8A6D7995D93C264BB9ECEA1D notAfter=Dec 3 07:21:52 2026 GMT
|
||||||
|
08:58:52 ★ 일련번호 변경 감지 — 3분 더 재고 종료
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
08:10:51.60 200 0.104996
|
||||||
|
08:10:51.91 200 0.070124
|
||||||
|
08:10:52.19 200 0.131785
|
||||||
|
... (중략)
|
||||||
|
08:58:30.03 200 0.077607
|
||||||
|
08:58:30.32 200 0.109888
|
||||||
|
08:58:30.64 200 0.195213
|
||||||
|
08:58:31.06 200 0.103103
|
||||||
|
08:58:31.38 200 0.079529
|
||||||
|
08:58:31.67 200 0.187888
|
||||||
|
08:58:32.07 200 0.194786
|
||||||
|
08:58:32.48 200 0.192114
|
||||||
|
08:58:32.89 200 0.108582
|
||||||
|
08:58:33.22 200 0.079331
|
||||||
|
08:58:33.51 200 0.189853
|
||||||
|
08:58:33.92 200 0.189635
|
||||||
|
08:58:34.33 200 0.195752
|
||||||
|
08:58:34.74 200 0.195164
|
||||||
|
08:58:35.15 200 0.078307
|
||||||
|
08:58:35.45 200 0.102306
|
||||||
|
08:58:35.76 200 0.193694
|
||||||
|
08:58:36.18 200 0.193912
|
||||||
|
08:58:36.58 200 0.079270
|
||||||
|
08:58:36.88 200 0.100873
|
||||||
|
08:58:37.20 200 0.077209
|
||||||
|
08:58:37.49 200 0.102600
|
||||||
|
08:58:37.81 200 0.195354
|
||||||
|
08:58:38.22 200 0.194041
|
||||||
|
08:58:38.63 200 0.079332
|
||||||
|
08:58:38.93 200 0.077710
|
||||||
|
08:58:39.22 200 0.073483
|
||||||
|
08:58:39.51 200 0.126482
|
||||||
|
08:58:39.86 200 0.196844
|
||||||
|
08:58:40.27 200 0.189230
|
||||||
|
08:58:40.68 200 0.089921
|
||||||
|
08:58:40.98 200 0.080912
|
||||||
|
08:58:41.28 200 0.093971
|
||||||
|
08:58:41.60 200 0.191146
|
||||||
|
08:58:42.00 200 0.079236
|
||||||
|
08:58:42.30 200 0.105411
|
||||||
|
08:58:42.62 200 0.195868
|
||||||
|
08:58:43.03 200 0.147224
|
||||||
|
08:58:43.40 200 0.136670
|
||||||
|
08:58:43.75 200 0.191708
|
||||||
|
08:58:44.16 200 0.084091
|
||||||
|
08:58:44.46 200 0.079146
|
||||||
|
08:58:44.75 200 0.114239
|
||||||
|
08:58:45.08 200 0.078791
|
||||||
|
08:58:45.38 200 0.074542
|
||||||
|
08:58:45.67 200 0.078430
|
||||||
|
08:58:45.97 200 0.073271
|
||||||
|
08:58:46.26 200 0.081292
|
||||||
|
08:58:46.55 200 0.086169
|
||||||
|
08:58:46.86 200 0.078664
|
||||||
|
08:58:47.16 200 0.077433
|
||||||
|
08:58:47.45 200 0.076830
|
||||||
|
08:58:47.74 200 0.078261
|
||||||
|
08:58:48.04 200 0.082249
|
||||||
|
08:58:48.34 200 0.077844
|
||||||
|
08:58:48.64 200 0.084788
|
||||||
|
08:58:48.94 200 0.080198
|
||||||
|
08:58:49.24 200 0.078426
|
||||||
|
08:58:49.53 200 0.078641
|
||||||
|
08:58:49.82 200 0.080504
|
||||||
|
08:58:50.12 200 0.079685
|
||||||
|
08:58:50.42 200 0.079559
|
||||||
|
08:58:50.71 200 0.078883
|
||||||
|
08:58:51.01 200 0.077754
|
||||||
|
08:58:51.30 200 0.078970
|
||||||
|
08:58:51.60 200 0.084448
|
||||||
|
08:58:51.90 200 0.131753
|
||||||
|
08:58:52.25 200 0.197813
|
||||||
|
08:58:52.66 200 0.091437
|
||||||
|
08:58:52.97 200 0.199400
|
||||||
|
08:58:53.39 200 0.085250
|
||||||
|
08:58:53.69 200 0.084521
|
||||||
|
08:58:53.99 200 0.072331
|
||||||
|
08:58:54.28 200 0.081943
|
||||||
|
08:58:54.58 200 0.113594
|
||||||
|
08:58:54.92 200 0.185589
|
||||||
|
08:58:55.32 200 0.200308
|
||||||
|
08:58:55.74 200 0.098051
|
||||||
|
08:58:56.05 200 0.086943
|
||||||
|
08:58:56.36 200 0.192842
|
||||||
|
08:58:56.77 200 0.178977
|
||||||
|
08:58:57.17 200 0.083091
|
||||||
|
08:58:57.47 200 0.194195
|
||||||
|
08:58:57.88 200 0.147293
|
||||||
|
08:58:58.24 200 0.149510
|
||||||
|
08:58:58.61 200 0.088007
|
||||||
|
08:58:58.91 200 0.190298
|
||||||
|
08:58:59.32 200 0.199024
|
||||||
|
08:58:59.73 200 0.183439
|
||||||
|
08:59:00.13 200 0.206752
|
||||||
|
08:59:00.55 200 0.179475
|
||||||
|
08:59:00.95 200 0.089502
|
||||||
|
08:59:01.25 200 0.096302
|
||||||
|
08:59:01.57 200 0.203361
|
||||||
|
08:59:01.99 200 0.175288
|
||||||
|
08:59:02.38 200 0.200164
|
||||||
|
08:59:02.80 200 0.157565
|
||||||
|
08:59:03.17 200 0.081179
|
||||||
|
08:59:03.47 200 0.133562
|
||||||
|
08:59:03.82 200 0.207667
|
||||||
|
08:59:04.24 200 0.156695
|
||||||
|
08:59:04.61 200 0.126136
|
||||||
|
08:59:04.95 200 0.185645
|
||||||
|
08:59:05.36 200 0.197049
|
||||||
|
08:59:05.77 200 0.090621
|
||||||
|
08:59:06.08 200 0.182576
|
||||||
|
08:59:06.48 200 0.107587
|
||||||
|
08:59:06.80 200 0.176183
|
||||||
|
08:59:07.19 200 0.076604
|
||||||
|
08:59:07.48 200 0.112886
|
||||||
|
08:59:07.81 200 0.197961
|
||||||
|
08:59:08.23 200 0.197695
|
||||||
|
08:59:08.64 200 0.192117
|
||||||
|
08:59:09.05 200 0.086968
|
||||||
|
08:59:09.36 200 0.073667
|
||||||
|
08:59:09.64 200 0.107286
|
||||||
|
08:59:09.97 200 0.187141
|
||||||
|
08:59:10.37 200 0.196110
|
||||||
|
08:59:10.78 200 0.080634
|
||||||
|
08:59:11.08 200 0.101278
|
||||||
|
08:59:11.40 200 0.190454
|
||||||
|
08:59:11.81 200 0.195550
|
||||||
|
08:59:12.22 200 0.084865
|
||||||
|
08:59:12.52 200 0.097978
|
||||||
|
08:59:12.84 200 0.188273
|
||||||
|
08:59:13.24 200 0.078268
|
||||||
|
08:59:13.54 200 0.098529
|
||||||
|
08:59:13.85 200 0.196250
|
||||||
|
08:59:14.26 200 0.198583
|
||||||
|
08:59:14.67 200 0.193794
|
||||||
|
08:59:15.08 200 0.142313
|
||||||
|
08:59:15.44 200 0.142774
|
||||||
|
08:59:15.80 200 0.203481
|
||||||
|
08:59:16.22 200 0.190197
|
||||||
|
08:59:16.62 200 0.193118
|
||||||
|
08:59:17.03 200 0.191270
|
||||||
|
08:59:17.44 200 0.190572
|
||||||
|
08:59:17.85 200 0.159825
|
||||||
|
08:59:18.23 200 0.115453
|
||||||
|
08:59:18.56 200 0.212529
|
||||||
|
08:59:18.99 200 0.178715
|
||||||
|
08:59:19.38 200 0.373160
|
||||||
|
08:59:19.98 200 0.114451
|
||||||
|
08:59:20.31 200 0.198672
|
||||||
|
08:59:20.72 200 0.186258
|
||||||
|
08:59:21.12 200 0.103469
|
||||||
|
08:59:21.44 200 0.197932
|
||||||
|
08:59:21.86 200 0.077019
|
||||||
|
08:59:22.15 200 0.180685
|
||||||
|
08:59:22.55 200 0.203363
|
||||||
|
08:59:22.97 200 0.197513
|
||||||
|
08:59:23.38 200 0.196509
|
||||||
|
08:59:23.80 200 0.094951
|
||||||
|
08:59:24.11 200 0.175434
|
||||||
|
08:59:24.50 200 0.372545
|
||||||
|
08:59:25.10 200 0.077048
|
||||||
|
08:59:25.39 200 0.077420
|
||||||
|
08:59:25.69 200 0.138478
|
||||||
|
08:59:26.04 200 0.190167
|
||||||
|
08:59:26.45 200 0.192613
|
||||||
|
08:59:26.86 200 0.161352
|
||||||
|
08:59:27.24 200 0.081419
|
||||||
|
08:59:27.54 200 0.131994
|
||||||
|
08:59:27.88 200 0.202003
|
||||||
|
08:59:28.30 200 0.186137
|
||||||
|
08:59:28.71 200 0.091572
|
||||||
|
08:59:29.02 200 0.077290
|
||||||
|
08:59:29.31 200 0.090913
|
||||||
|
08:59:29.62 200 0.198752
|
||||||
|
... (중략)
|
||||||
|
09:01:10.29 200 0.105354
|
||||||
|
09:01:10.62 200 0.173389
|
||||||
|
09:01:11.00 200 0.191564
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
08:10:51 코드=200 바이트=845361 시간=42.335582 연결수=1
|
||||||
|
08:11:33 코드=200 바이트=845361 시간=42.328216 연결수=1
|
||||||
|
08:12:16 코드=200 바이트=845361 시간=42.332256 연결수=1
|
||||||
|
08:12:58 코드=200 바이트=845361 시간=42.327378 연결수=1
|
||||||
|
08:13:40 코드=200 바이트=845361 시간=41.041830 연결수=1
|
||||||
|
08:14:22 코드=200 바이트=845361 시간=42.338482 연결수=1
|
||||||
|
08:15:04 코드=000 바이트=0 시간=0.001148 연결수=0
|
||||||
|
08:15:04 코드=000 바이트=0 시간=0.001140 연결수=0
|
||||||
|
08:15:04 코드=000 바이트=0 시간=0.001124 연결수=0
|
||||||
|
08:15:04 코드=000 바이트=0 시간=0.000055 연결수=0
|
||||||
|
08:15:04 코드=000 바이트=0 시간=0.000080 연결수=0
|
||||||
|
08:15:04 코드=000 바이트=0 시간=0.000077 연결수=0
|
||||||
|
08:15:04 코드=000 바이트=0 시간=0.001167 연결수=0
|
||||||
|
08:15:04 코드=000 바이트=0 시간=0.000048 연결수=0
|
||||||
|
08:15:04 코드=000 바이트=0 시간=0.000079 연결수=0
|
||||||
|
08:15:04 코드=000 바이트=0 시간=0.000096 연결수=0
|
||||||
|
08:15:04 코드=000 바이트=0 시간=0.000039 연결수=0
|
||||||
|
08:15:04 코드=000 바이트=0 시간=0.000083 연결수=0
|
||||||
|
08:15:04 코드=000 바이트=0 시간=0.000075 연결수=0
|
||||||
|
08:15:04 코드=000 바이트=0 시간=0.000041 연결수=0
|
||||||
|
08:15:04 코드=000 바이트=0 시간=0.000061 연결수=0
|
||||||
|
08:15:04 코드=000 바이트=0 시간=0.001141 연결수=0
|
||||||
|
08:15:04 코드=000 바이트=0 시간=0.000078 연결수=0
|
||||||
|
08:15:04 코드=000 바이트=0 시간=0.000053 연결수=0
|
||||||
|
08:15:04 코드=000 바이트=0 시간=0.000079 연결수=0
|
||||||
|
08:15:04 코드=000 바이트=0 시간=0.000083 연결수=0
|
||||||
|
08:15:04 코드=000 바이트=0 시간=0.000079 연결수=0
|
||||||
|
08:15:04 코드=000 바이트=0 시간=0.000099 연결수=0
|
||||||
|
08:15:04 코드=000 바이트=0 시간=0.000083 연결수=0
|
||||||
|
08:15:04 코드=000 바이트=0 시간=0.001164 연결수=0
|
||||||
|
08:15:04 코드=000 바이트=0 시간=0.001106 연결수=0
|
||||||
|
08:15:04 코드=000 바이트=0 시간=0.000074 연결수=0
|
||||||
|
08:15:04 코드=000 바이트=0 시간=0.000087 연결수=0
|
||||||
|
08:15:04 코드=000 바이트=0 시간=0.000079 연결수=0
|
||||||
|
08:15:04 코드=000 바이트=0 시간=0.000072 연결수=0
|
||||||
|
08:15:04 코드=000 바이트=0 시간=0.000085 연결수=0
|
||||||
|
08:15:04 코드=000 바이트=0 시간=0.000085 연결수=0
|
||||||
|
08:15:04 코드=000 바이트=0 시간=0.000048 연결수=0
|
||||||
|
08:15:04 코드=000 바이트=0 시간=0.000046 연결수=0
|
||||||
|
08:15:04 코드=000 바이트=0 시간=0.001147 연결수=0
|
||||||
|
08:15:04 코드=000 바이트=0 시간=0.000044 연결수=0
|
||||||
|
08:15:04 코드=000 바이트=0 시간=0.000053 연결수=0
|
||||||
|
08:15:04 코드=000 바이트=0 시간=0.000076 연결수=0
|
||||||
|
08:15:04 코드=000 바이트=0 시간=0.000043 연결수=0
|
||||||
|
08:15:04 코드=000 바이트=0 시간=0.000091 연결수=0
|
||||||
|
08:15:04 코드=000 바이트=0 시간=0.000074 연결수=0
|
||||||
|
08:15:04 코드=000 바이트=0 시간=0.000049 연결수=0
|
||||||
|
08:15:04 코드=000 바이트=0 시간=0.000051 연결수=0
|
||||||
|
08:15:04 코드=000 바이트=0 시간=0.000087 연결수=0
|
||||||
|
08:15:04 코드=000 바이트=0 시간=0.000091 연결수=0
|
||||||
|
08:15:04 코드=000 바이트=0 시간=0.000079 연결수=0
|
||||||
|
08:15:04 코드=000 바이트=0 시간=0.000095 연결수=0
|
||||||
|
08:15:04 코드=000 바이트=0 시간=0.000055 연결수=0
|
||||||
|
08:15:04 코드=000 바이트=0 시간=0.000076 연결수=0
|
||||||
|
08:15:04 코드=000 바이트=0 시간=0.000077 연결수=0
|
||||||
|
08:15:04 코드=000 바이트=0 시간=0.000088 연결수=0
|
||||||
|
08:15:04 코드=000 바이트=0 시간=0.000085 연결수=0
|
||||||
|
08:15:04 코드=000 바이트=0 시간=0.000092 연결수=0
|
||||||
|
08:15:04 코드=000 바이트=0 시간=0.000080 연결수=0
|
||||||
|
08:15:04 코드=000 바이트=0 시간=0.000085 연결수=0
|
||||||
|
08:15:04 코드=000 바이트=0 시간=0.001157 연결수=0
|
||||||
|
08:15:04 코드=000 바이트=0 시간=0.000048 연결수=0
|
||||||
|
08:15:04 코드=000 바이트=0 시간=0.001097 연결수=0
|
||||||
|
08:15:04 코드=000 바이트=0 시간=0.000060 연결수=0
|
||||||
|
08:15:04 코드=000 바이트=0 시간=0.000080 연결수=0
|
||||||
|
08:15:04 코드=000 바이트=0 시간=0.000087 연결수=0
|
||||||
|
08:15:04 코드=000 바이트=0 시간=0.000048 연결수=0
|
||||||
|
08:15:04 코드=000 바이트=0 시간=0.000081 연결수=0
|
||||||
|
08:15:04 코드=000 바이트=0 시간=0.000084 연결수=0
|
||||||
|
08:15:04 코드=000 바이트=0 시간=0.000087 연결수=0
|
||||||
|
08:15:04 코드=000 바이트=0 시간=0.000079 연결수=0
|
||||||
|
08:15:04 코드=000 바이트=0 시간=0.001153 연결수=0
|
||||||
|
08:15:04 코드=000 바이트=0 시간=0.000053 연결수=0
|
||||||
|
08:15:04 코드=000 바이트=0 시간=0.000088 연결수=0
|
||||||
|
08:15:04 코드=000 바이트=0 시간=0.000052 연결수=0
|
||||||
|
08:15:04 코드=000 바이트=0 시간=0.000085 연결수=0
|
||||||
|
08:15:04 코드=000 바이트=0 시간=0.000084 연결수=0
|
||||||
|
08:15:04 코드=000 바이트=0 시간=0.000080 연결수=0
|
||||||
|
08:15:04 코드=000 바이트=0 시간=0.000079 연결수=0
|
||||||
|
08:15:04 코드=000 바이트=0 시간=0.001106 연결수=0
|
||||||
|
08:15:04 코드=000 바이트=0 시간=0.000051 연결수=0
|
||||||
|
08:15:04 코드=000 바이트=0 시간=0.000056 연결수=0
|
||||||
|
08:15:04 코드=200 바이트=845361 시간=42.236496 연결수=1
|
||||||
|
08:15:47 코드=200 바이트=845361 시간=42.327129 연결수=1
|
||||||
|
08:16:29 코드=200 바이트=845361 시간=41.442808 연결수=1
|
||||||
|
08:17:10 코드=200 바이트=845361 시간=42.332025 연결수=1
|
||||||
|
08:17:53 코드=200 바이트=845361 시간=40.434517 연결수=1
|
||||||
|
08:18:33 코드=200 바이트=845361 시간=41.438410 연결수=1
|
||||||
|
08:19:15 코드=200 바이트=845361 시간=42.334617 연결수=1
|
||||||
|
08:19:57 코드=200 바이트=845361 시간=42.333846 연결수=1
|
||||||
|
08:20:39 코드=200 바이트=845361 시간=42.232748 연결수=1
|
||||||
|
08:21:22 코드=200 바이트=845361 시간=42.324095 연결수=1
|
||||||
|
08:22:04 코드=200 바이트=845361 시간=42.331593 연결수=1
|
||||||
|
08:22:46 코드=200 바이트=845361 시간=41.626941 연결수=1
|
||||||
|
08:23:28 코드=200 바이트=845361 시간=42.330280 연결수=1
|
||||||
|
08:24:59 코드=200 바이트=845361 시간=42.331694 연결수=1 curl종료=0
|
||||||
|
08:25:42 코드=200 바이트=845361 시간=42.333930 연결수=1 curl종료=0
|
||||||
|
08:26:24 코드=200 바이트=845361 시간=41.839874 연결수=1 curl종료=0
|
||||||
|
08:27:06 코드=200 바이트=845361 시간=41.834401 연결수=1 curl종료=0
|
||||||
|
08:27:48 코드=200 바이트=845361 시간=41.840212 연결수=1 curl종료=0
|
||||||
|
08:28:30 코드=200 바이트=845361 시간=41.032736 연결수=1 curl종료=0
|
||||||
|
08:29:11 코드=200 바이트=845361 시간=41.433394 연결수=1 curl종료=0
|
||||||
|
08:29:52 코드=200 바이트=845361 시간=42.325976 연결수=1 curl종료=0
|
||||||
|
08:30:34 코드=200 바이트=845361 시간=42.037070 연결수=1 curl종료=0
|
||||||
|
08:31:16 코드=200 바이트=845361 시간=42.343058 연결수=1 curl종료=0
|
||||||
|
08:31:59 코드=200 바이트=845361 시간=41.837942 연결수=1 curl종료=0
|
||||||
|
08:32:41 코드=200 바이트=845361 시간=42.318577 연결수=1 curl종료=0
|
||||||
|
08:33:23 코드=200 바이트=845361 시간=42.336079 연결수=1 curl종료=0
|
||||||
|
08:34:05 코드=200 바이트=845361 시간=42.328392 연결수=1 curl종료=0
|
||||||
|
08:34:48 코드=200 바이트=845361 시간=41.839243 연결수=1 curl종료=0
|
||||||
|
08:35:30 코드=200 바이트=845361 시간=42.339658 연결수=1 curl종료=0
|
||||||
|
08:36:12 코드=200 바이트=845361 시간=42.330835 연결수=1 curl종료=0
|
||||||
|
08:36:54 코드=200 바이트=845361 시간=41.844755 연결수=1 curl종료=0
|
||||||
|
08:37:36 코드=200 바이트=845361 시간=41.442783 연결수=1 curl종료=0
|
||||||
|
08:38:18 코드=200 바이트=845361 시간=42.334462 연결수=1 curl종료=0
|
||||||
|
08:39:00 코드=200 바이트=845361 시간=42.331275 연결수=1 curl종료=0
|
||||||
|
08:39:42 코드=200 바이트=845361 시간=41.243768 연결수=1 curl종료=0
|
||||||
|
08:40:23 코드=200 바이트=845361 시간=41.837874 연결수=1 curl종료=0
|
||||||
|
08:41:05 코드=200 바이트=845361 시간=42.336582 연결수=1 curl종료=0
|
||||||
|
08:41:48 코드=200 바이트=845361 시간=42.327934 연결수=1 curl종료=0
|
||||||
|
08:42:30 코드=200 바이트=845361 시간=42.333559 연결수=1 curl종료=0
|
||||||
|
08:43:12 코드=200 바이트=845361 시간=41.841294 연결수=1 curl종료=0
|
||||||
|
08:43:54 코드=200 바이트=845361 시간=42.329699 연결수=1 curl종료=0
|
||||||
|
08:44:37 코드=200 바이트=845361 시간=41.845127 연결수=1 curl종료=0
|
||||||
|
08:45:18 코드=200 바이트=845361 시간=42.040360 연결수=1 curl종료=0
|
||||||
|
08:46:00 코드=200 바이트=845361 시간=41.845192 연결수=1 curl종료=0
|
||||||
|
08:46:42 코드=200 바이트=845361 시간=42.327749 연결수=1 curl종료=0
|
||||||
|
08:47:25 코드=200 바이트=845361 시간=41.837216 연결수=1 curl종료=0
|
||||||
|
08:48:06 코드=200 바이트=845361 시간=42.329675 연결수=1 curl종료=0
|
||||||
|
08:48:49 코드=200 바이트=845361 시간=42.036368 연결수=1 curl종료=0
|
||||||
|
08:49:31 코드=200 바이트=845361 시간=42.341275 연결수=1 curl종료=0
|
||||||
|
08:50:13 코드=200 바이트=845361 시간=42.332664 연결수=1 curl종료=0
|
||||||
|
08:50:56 코드=200 바이트=845361 시간=42.334345 연결수=1 curl종료=0
|
||||||
|
08:51:38 코드=200 바이트=845361 시간=41.837146 연결수=1 curl종료=0
|
||||||
|
08:52:20 코드=200 바이트=845361 시간=42.328092 연결수=1 curl종료=0
|
||||||
|
08:53:02 코드=200 바이트=845361 시간=42.344341 연결수=1 curl종료=0
|
||||||
|
08:53:44 코드=200 바이트=845361 시간=42.332296 연결수=1 curl종료=0
|
||||||
|
08:54:27 코드=200 바이트=845361 시간=41.839230 연결수=1 curl종료=0
|
||||||
|
08:55:09 코드=200 바이트=845361 시간=42.336469 연결수=1 curl종료=0
|
||||||
|
08:55:51 코드=200 바이트=845361 시간=42.336864 연결수=1 curl종료=0
|
||||||
|
08:56:33 코드=200 바이트=845361 시간=42.043518 연결수=1 curl종료=0
|
||||||
|
08:57:15 코드=200 바이트=845361 시간=42.340137 연결수=1 curl종료=0
|
||||||
|
08:57:58 코드=200 바이트=845361 시간=42.335696 연결수=1 curl종료=0
|
||||||
|
08:58:40 코드=200 바이트=845361 시간=40.840349 연결수=1 curl종료=0
|
||||||
|
08:59:21 코드=200 바이트=845361 시간=42.340130 연결수=1 curl종료=0
|
||||||
|
09:00:03 코드=200 바이트=845361 시간=41.841100 연결수=1 curl종료=0
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
Saving debug log to /var/log/letsencrypt/letsencrypt.log
|
||||||
|
|
||||||
|
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
|
||||||
|
Found the following certs:
|
||||||
|
Certificate Name: auth.hyeonworks.com
|
||||||
|
Serial Number: 6c7cb6df1da8a6d7995d93c264bb9ecea1d
|
||||||
|
Key Type: ECDSA
|
||||||
|
Identifiers: auth.hyeonworks.com app1.hyeonworks.com app2.hyeonworks.com
|
||||||
|
Expiry Date: 2026-12-03 07:21:52+00:00 (VALID: 89 days)
|
||||||
|
Certificate Path: /etc/letsencrypt/live/auth.hyeonworks.com/fullchain.pem
|
||||||
|
Private Key Path: /etc/letsencrypt/live/auth.hyeonworks.com/privkey.pem
|
||||||
|
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
|
||||||
|
|
||||||
|
=== renewal-hooks ===
|
||||||
|
/etc/letsencrypt/archive/auth.hyeonworks.com/:
|
||||||
|
total 48
|
||||||
|
drwxr-xr-x 2 root root 4096 2026-09-04 17:22:13.508658811 +0900 .
|
||||||
|
drwx------ 3 root root 4096 2026-09-03 10:47:40.915270972 +0900 ..
|
||||||
|
-rw-r--r-- 1 root root 1359 2026-09-03 10:47:40.915923507 +0900 cert1.pem
|
||||||
|
-rw-r--r-- 1 root root 1359 2026-09-04 17:22:13.508494637 +0900 cert2.pem
|
||||||
|
-rw-r--r-- 1 root root 3523 2026-09-03 10:47:40.916215769 +0900 chain1.pem
|
||||||
|
-rw-r--r-- 1 root root 3523 2026-09-04 17:22:13.508658811 +0900 chain2.pem
|
||||||
|
-rw-r--r-- 1 root root 4882 2026-09-03 10:47:40.916339551 +0900 fullchain1.pem
|
||||||
|
-rw-r--r-- 1 root root 4882 2026-09-04 17:22:13.508821612 +0900 fullchain2.pem
|
||||||
|
-rw------- 1 root root 241 2026-09-03 10:47:40.916079294 +0900 privkey1.pem
|
||||||
|
-rw------- 1 root root 241 2026-09-04 17:22:13.507717972 +0900 privkey2.pem
|
||||||
|
|
||||||
|
/etc/letsencrypt/renewal-hooks/deploy/:
|
||||||
|
total 8
|
||||||
|
drwxr-xr-x 2 root root 4096 2026-09-03 10:46:54.658474560 +0900 .
|
||||||
|
drwxr-xr-x 5 root root 4096 2026-09-03 10:46:54.658520760 +0900 ..
|
||||||
|
|
||||||
|
/etc/letsencrypt/renewal-hooks/post/:
|
||||||
|
total 8
|
||||||
|
drwxr-xr-x 2 root root 4096 2026-09-03 10:46:54.658505772 +0900 .
|
||||||
|
drwxr-xr-x 5 root root 4096 2026-09-03 10:46:54.658520760 +0900 ..
|
||||||
|
|
||||||
|
/etc/letsencrypt/renewal-hooks/pre/:
|
||||||
|
total 8
|
||||||
|
drwxr-xr-x 2 root root 4096 2026-09-03 10:46:54.658474560 +0900 .
|
||||||
|
drwxr-xr-x 5 root root 4096 2026-09-03 10:46:54.658520760 +0900 ..
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
D-4 판정 — 갱신은 성공했고, 38분 25초 동안 반영되지 않았다
|
||||||
|
(시계 보정 후. 처음 적은 36분 39초는 서로 다른 시계를 뺀 값이었다)
|
||||||
|
=============================================================
|
||||||
|
수집: 2026-09-04 · 감시 08:10:51 ~ 09:02 UTC (KST-9)
|
||||||
|
|
||||||
|
════ 답 1 — "갱신 실패"가 아니라 "갱신됐는데 안 집었다" ════
|
||||||
|
|
||||||
|
07-renewal-hook-missing.txt 에서 두 갈래로 남겼던 것이 갈렸다.
|
||||||
|
(a) 강제 갱신이 실패했다
|
||||||
|
(b) 성공했는데 훅이 없어 reload 가 안 됐다
|
||||||
|
→ ★ (b) 였다.
|
||||||
|
|
||||||
|
디스크 (12-certbot-state.txt)
|
||||||
|
/etc/letsencrypt/archive/auth.hyeonworks.com/
|
||||||
|
cert1.pem 2026-09-03 10:47:40 KST ← 처음 발급
|
||||||
|
cert2.pem 2026-09-04 17:22:13 KST ← ★ 강제 갱신, 새로 써졌다
|
||||||
|
fullchain2.pem 2026-09-04 17:22:13 KST
|
||||||
|
privkey2.pem 2026-09-04 17:22:13 KST
|
||||||
|
|
||||||
|
Certificate Name: auth.hyeonworks.com
|
||||||
|
Serial Number: 6c7cb6df1da8a6d7995d93c264bb9ecea1d
|
||||||
|
Expiry Date: 2026-12-03 07:21:52+00:00 (VALID: 89 days)
|
||||||
|
|
||||||
|
밖에서 본 것 (09-serial-timeline.txt, 5초 간격 564표본)
|
||||||
|
08:10:51 ~ 08:58:47 serial=0520BB...B853 notAfter=Dec 2 ← 옛 것
|
||||||
|
08:58:52 serial=06C7CB...EA1D notAfter=Dec 3 ← 바뀐 순간
|
||||||
|
|
||||||
|
08:22:13 ~ 08:58:52 구간에서 옛 인증서로 관측된 횟수: 428회
|
||||||
|
|
||||||
|
시간 계산
|
||||||
|
새 인증서 디스크 기록 : 08:22:13 UTC (= 17:22:13 KST, archive mtime)
|
||||||
|
실제 서빙 시작 : 08:58:52 UTC (= 17:58:52 KST, nginx -s reload)
|
||||||
|
────────────────────────────────────────────
|
||||||
|
공백 : 2305초 = 38분 25초 ← test-server 가 106초 빠른 것을 보정한 값
|
||||||
|
(보정 전 2199초로 적었던 것을 정정. D-4a 참조)
|
||||||
|
|
||||||
|
그리고 이 36분은 **우연히 짧았을 뿐이다.** reload 를 시킨 것은 사람이지
|
||||||
|
자동화가 아니다. 아무도 안 했다면 다음 nginx 재시작까지 — 즉 무기한 —
|
||||||
|
옛 인증서를 계속 서빙했을 것이다.
|
||||||
|
|
||||||
|
왜 아무도 reload 하지 않았나 — 원인 세 가지가 겹쳤다
|
||||||
|
1. certbot-renew.service 에 ExecStartPost 가 없다 (07 참조)
|
||||||
|
2. /etc/letsencrypt/renewal-hooks/{deploy,post,pre}/ 가 전부 비어 있다
|
||||||
|
deploy/ total 8 (. 과 .. 뿐)
|
||||||
|
post/ total 8
|
||||||
|
pre/ total 8
|
||||||
|
3. certbot 에 nginx 플러그인이 없다 — 그래서 certbot 이 스스로 못 고친다
|
||||||
|
Discovered plugins: dns-cloudflare, manual, null, standalone, webroot
|
||||||
|
(certbot 5.7.0)
|
||||||
|
|
||||||
|
★ 세 경로 전부가 비어 있다. 하나라도 있었으면 자동으로 반영됐다.
|
||||||
|
|
||||||
|
왜 이게 88일 동안 안 보이나
|
||||||
|
타이머는 정상이고 오늘도 두 번 status=0/SUCCESS 로 끝났다.
|
||||||
|
만료 30일 전(= 약 58일 뒤)까지는 certbot 이 갱신 자체를 하지 않으므로
|
||||||
|
"반영되지 않는다"는 결함이 발현할 기회가 없다. 발현하는 날 증상은
|
||||||
|
**인증서 만료** 이고, 그때 로그는 여전히 SUCCESS 라고 적혀 있을 것이다.
|
||||||
|
|
||||||
|
════ 답 2 — reload 는 무중단이다 (측정됨) ════
|
||||||
|
|
||||||
|
계획서의 질문: "nginx reload 중 진행 중이던 요청은 어떻게 되는가"
|
||||||
|
|
||||||
|
[새 연결] 0.2초 폴링, 08:10:51 ~ 09:02
|
||||||
|
전체 표본 8856건 / 비200 **0건**
|
||||||
|
|
||||||
|
응답시간 n 중앙 p95 최대
|
||||||
|
─────────────────────────────────────────────────────────
|
||||||
|
장기 평시 08:20~08:50 5398 98.0ms 205.7ms 1942.9ms
|
||||||
|
reload 직전 2분56초 489 116.0ms 200.8ms 387.7ms
|
||||||
|
reload 직후 2분08초 342 132.5ms 204.3ms 475.0ms
|
||||||
|
|
||||||
|
p95 가 205.7 → 204.3 으로 사실상 동일하고, 최대값은 오히려 낮다.
|
||||||
|
10초 구간 중앙값은 reload 전후 모두 80~190ms 사이를 오간다(WiFi 잡음).
|
||||||
|
→ **reload 로 인한 지연 증가는 관측되지 않았다.**
|
||||||
|
|
||||||
|
[진행 중이던 요청] 845KB 를 20k/s 로 받아 42초간 살려 둔 요청
|
||||||
|
08:58:40 시작 · 소요 40.84초 → 08:59:21 종료
|
||||||
|
reload 는 08:58:52, 즉 **요청 시작 12초 뒤 전송 한가운데**였다
|
||||||
|
|
||||||
|
코드=200 바이트=845361(전량) 연결수=1 curl종료=0
|
||||||
|
|
||||||
|
· 바이트가 전량이다 → 잘리지 않았다
|
||||||
|
· 연결수가 1이다 → 중간에 끊겨 재연결한 것이 아니다
|
||||||
|
· 옛 인증서로 시작한 연결이 새 워커 전환을 관통해 끝까지 갔다
|
||||||
|
|
||||||
|
in-flight 전체 50건 중 종료코드≠0 은 0건.
|
||||||
|
(앞서 76건 실패는 로컬 아티팩트였다 — 08-inflight-artifact.txt)
|
||||||
|
|
||||||
|
════ 세 줄 요약 ════
|
||||||
|
|
||||||
|
1. **"갱신 성공"과 "새 인증서 서빙"은 다른 사건이다.** 38분 25초 동안
|
||||||
|
디스크에는 새 인증서가, 네트워크에는 옛 인증서가 있었다. 428번 관측했다.
|
||||||
|
|
||||||
|
2. **reload 는 정말 무중단이었다.** 8856건 전부 200, p95 변화 없음,
|
||||||
|
전송 한가운데였던 42초짜리 요청이 845361바이트를 온전히 받았다.
|
||||||
|
D-4 문서가 "확인하지 않았으므로 그렇게 쓰면 안 된다"고 유보했던 명제가
|
||||||
|
이제 측정으로 뒷받침된다.
|
||||||
|
|
||||||
|
3. **위험한 쪽은 reload 가 아니라 reload 를 부르는 사람이 없다는 것이다.**
|
||||||
|
훅 디렉터리 3개가 전부 비었고, 유닛에 ExecStartPost 가 없고,
|
||||||
|
nginx 플러그인도 없다. 그리고 그 상태로 타이머는 SUCCESS 를 찍는다.
|
||||||
@@ -1,14 +1,28 @@
|
|||||||
# D-4 — 인증서 갱신 증거
|
# D-4 — 인증서 갱신 증거
|
||||||
|
|
||||||
2026-09-04 17:25–17:35 KST
|
2026-09-04 17:25 – 18:02 KST
|
||||||
해설: [`docs/experiment-d4-certificate-renewal.md`](../../experiment-d4-certificate-renewal.md)
|
해설: [`docs/experiment-d4-certificate-renewal.md`](../../experiment-d4-certificate-renewal.md)
|
||||||
|
|
||||||
| 파일 | 무엇을 보여주는가 |
|
| 파일 | 무엇을 보여주는가 |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `01-certificate-state.txt` | SAN 3개(와일드카드 아님) · **체인 4단계, `Verify return code: 0`** · `certbot-renew.timer` enabled·active, 11시간 전 실행 · 88일 남음 · **`sudo: a password is required` 로 강제 갱신 불가** |
|
| `01-certificate-state.txt` | SAN 3개(와일드카드 아님) · **체인 4단계, `Verify return code: 0`** · `certbot-renew.timer` enabled·active, 11시간 전 실행 · 88일 남음 · **`sudo: a password is required` 로 강제 갱신 불가** |
|
||||||
|
| `05-control-no-injection.txt` | **대조군 1 — 잡음 바닥.** 0.2초 × 900회 / 180초 동안 **900전부 200, 오류 0**. 중앙 98ms · p95 195ms. **TLS 핸드셰이크 900/900** = 매 요청이 새 연결이다 |
|
||||||
|
| `06-inflight-control.txt` | **대조군 2 — '진행 중이던 요청' 측정 장치.** 845KB 번들을 `--limit-rate 20k` 로 받아 요청을 **42초간 살려 둔다.** 무주입 시 코드 200 · 845361바이트 · 연결수 1 |
|
||||||
|
|
||||||
## 핵심 세 줄
|
| `05-control-no-injection.txt` | **대조군 1 — 잡음 바닥.** 0.2초 × 900회 / 180초 동안 **900 전부 200, 오류 0**. 중앙 98ms · p95 195ms. **TLS 핸드셰이크 900/900** = 매 요청이 새 연결 |
|
||||||
|
| `06-inflight-control.txt` | **대조군 2 — '진행 중이던 요청' 장치.** 845KB 를 `--limit-rate 20k` 로 받아 요청을 **42초간 살려 둔다.** 무주입 시 200 · 845361바이트 · 연결수 1 |
|
||||||
|
| `07-renewal-hook-missing.txt` | **nginx 는 reload 된 적이 없다** — 마스터 585·워커 586 이 같은 시각 기동, 22.4시간째. 유닛은 `ExecStart=certbot -q renew` 가 전부. crt.sh 는 SCT 가 박힌 인증서를 0건으로 답한다 |
|
||||||
|
| `08-inflight-artifact.txt` | **76건 실패는 서버 탓이 아니다** — 같은 순간 폴링 49건 전부 200, `연결수=0`, 50µs, 재현 0/100. 대조군이 오보를 막았다 |
|
||||||
|
| `09-serial-timeline.txt` | **일련번호 564표본.** `08:10:51 ~ 08:58:47` 옛 것 → **`08:58:52` 새 것** |
|
||||||
|
| `10-reload-poll-window.txt` | reload 전후 60초 새 연결 원문 — **비200 0건**, 최대 373ms |
|
||||||
|
| `11-inflight-full.txt` | in-flight 전체 50건. **08:58:40 시작 요청이 08:58:52 reload 를 관통**해 845361바이트 전량 수신 |
|
||||||
|
| `12-certbot-state.txt` | `cert2.pem` **09-04 17:22:13 기록됨** · `renewal-hooks/{deploy,post,pre}/` **셋 다 비었음** · 플러그인 목록에 nginx 없음 |
|
||||||
|
| `13-verdict.txt` | **판정 전문** — 38분 25초 공백(428회 관측) + reload 무중단(8856건 0실패) |
|
||||||
|
|
||||||
1. **인증서가 이름 3개만 담는다.** B-7 에서 oauth2-proxy 를 올릴 호스트가 없어 Grafana 의 `app2` 를 빌려야 했던 실제 비용이 여기서 나왔다.
|
## 핵심 다섯 줄
|
||||||
2. **체인이 완전하다** — 단계가 4개이므로 `fullchain.pem` 을 쓰고 있다. 1개면 `cert.pem` 실수이며 캐시 없는 클라이언트에서만 깨진다.
|
|
||||||
3. **강제 갱신은 못 했다.** 호스트 sudo 가 비밀번호를 요구한다. 타이머가 active 라는 것은 "갱신이 된다"의 확인이 아니다.
|
1. **「갱신 성공」과 「새 인증서 서빙」은 다른 사건이다.** 새 인증서가 디스크에 있는 채로 **38분 25초** 동안 옛 인증서를 서빙했고, 그 구간에서 **428번** 관측했다.
|
||||||
|
2. **그 36분은 우연히 짧았다.** reload 를 시킨 것은 사람이다. 아무도 안 했다면 다음 nginx 재시작까지 무기한이었다.
|
||||||
|
3. **원인이 셋 겹쳤다.** 유닛에 `ExecStartPost` 없음 · 훅 디렉터리 3개 전부 비었음 · certbot 에 nginx 플러그인 없음. 하나라도 있었으면 자동 반영됐다.
|
||||||
|
4. **reload 자체는 무중단이었다.** 새 연결 8856건 전부 200, p95 205.7 → 204.3ms, 그리고 **전송 12초째에 reload 를 맞은 42초 요청이 845361바이트를 온전히** 받았다(연결수 1).
|
||||||
|
5. **이 결함은 88일 동안 안 보인다.** 타이머는 오늘도 두 번 `SUCCESS` 로 끝났다. 만료 30일 전까지는 갱신 자체를 하지 않으므로 발현할 기회가 없고, 발현하는 날의 증상은 **인증서 만료**다 — 그날에도 로그는 `SUCCESS` 라고 적혀 있다.
|
||||||
|
|||||||
@@ -0,0 +1,125 @@
|
|||||||
|
D-4a — deploy 훅이 실제로 nginx 를 reload 하는가
|
||||||
|
==================================================
|
||||||
|
수집: 2026-09-04 12:27 UTC (실제) · test-server
|
||||||
|
|
||||||
|
D-4 가 남긴 것
|
||||||
|
--------------
|
||||||
|
| 항목 | 상태 |
|
||||||
|
|---|---|
|
||||||
|
| deploy 훅을 넣으면 자동 반영되는가 | **미측정. 훅은 아직 넣지 않았다** |
|
||||||
|
|
||||||
|
D-4 는 처방을 적어두고 **검증하지 않았다.** 처방이 듣는지 모르는 채로
|
||||||
|
"이렇게 고치면 된다"고 쓰는 것은 이 실험대가 계속 경계해 온 바로 그 실수다.
|
||||||
|
|
||||||
|
[주입] 훅 하나를 넣고 강제 갱신
|
||||||
|
--------------------------------
|
||||||
|
/etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh
|
||||||
|
#!/bin/sh
|
||||||
|
# D-4a: certbot deploy hook. 갱신이 실제로 일어났을 때만 실행된다.
|
||||||
|
nginx -t && nginx -s reload
|
||||||
|
|
||||||
|
install -m755 /tmp/reload-nginx.sh /etc/letsencrypt/renewal-hooks/deploy/
|
||||||
|
certbot renew --force-renewal
|
||||||
|
|
||||||
|
[결과] certbot 출력 — 훅이 실행됐다
|
||||||
|
------------------------------------
|
||||||
|
Processing /etc/letsencrypt/renewal/auth.hyeonworks.com.conf
|
||||||
|
Renewing an existing certificate for auth.hyeonworks.com and 2 more
|
||||||
|
Hook 'deploy-hook' ran with error output:
|
||||||
|
2026/09/04 21:29:36 [warn] 37250#37250: could not build optimal types_hash, …
|
||||||
|
nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
|
||||||
|
nginx: configuration file /etc/nginx/nginx.conf test is successful
|
||||||
|
2026/09/04 21:29:37 [warn] 37251#37251: could not build optimal types_hash, …
|
||||||
|
2026/09/04 21:29:37 [notice] 37251#37251: signal process started
|
||||||
|
|
||||||
|
Congratulations, all renewals succeeded:
|
||||||
|
/etc/letsencrypt/live/auth.hyeonworks.com/fullchain.pem (success)
|
||||||
|
|
||||||
|
★ 함정 — "Hook 'deploy-hook' ran with error output" 이라고 적혀 있지만
|
||||||
|
실패가 아니다. nginx 가 types_hash 경고를 stderr 로 내보내서 certbot 이
|
||||||
|
그것을 "error output" 으로 표시한 것뿐이다. 내용은 전부 성공이다:
|
||||||
|
· nginx -t → "test is successful"
|
||||||
|
· nginx -s reload → "signal process started"
|
||||||
|
로그에서 'error' 를 grep 하면 성공한 훅을 실패로 오독하게 된다.
|
||||||
|
|
||||||
|
[검증 1] nginx 워커가 교체됐다
|
||||||
|
-------------------------------
|
||||||
|
전 (D-4a 기준선)
|
||||||
|
585 1 ... Thu Sep 3 19:00:39 nginx: master process
|
||||||
|
28829 585 ... Fri Sep 4 18:00:35 nginx: worker process ← D-4 에서 사람이 reload 한 것
|
||||||
|
|
||||||
|
후
|
||||||
|
585 1 95412 Thu Sep 3 19:00:39 nginx: master process ← 마스터는 그대로
|
||||||
|
37252 585 74 Fri Sep 4 21:29:36 nginx: worker process ← ★ 자동 교체됨
|
||||||
|
|
||||||
|
마스터 PID 는 유지되고 워커만 바뀌었다 = reload 의 서명. D-4 에서 세운
|
||||||
|
진단 방법이 그대로 작동한다.
|
||||||
|
|
||||||
|
[검증 2] 서빙 인증서가 바뀌었다
|
||||||
|
--------------------------------
|
||||||
|
serial=06F3E0EF4D1BB03DE58130EAAD1176101373
|
||||||
|
notBefore=Sep 4 11:29:18 2026 GMT
|
||||||
|
notAfter=Dec 3 11:29:17 2026 GMT
|
||||||
|
SAN: app1 / app2 / auth .hyeonworks.com
|
||||||
|
|
||||||
|
D-4 의 인증서(06C7CB…EA1D)에서 바뀌었다.
|
||||||
|
|
||||||
|
[검증 3] ★ 얼마나 빨랐나 — 시계 보정이 필요하다
|
||||||
|
--------------------------------------------------
|
||||||
|
훅 로그와 워커 lstart 는 **test-server 시계**다. 그런데 test-server 는
|
||||||
|
NTP 동기가 꺼져 있다(NTPSynchronized=no).
|
||||||
|
|
||||||
|
외부 기준으로 두 시계를 검증했다.
|
||||||
|
dev → Google 차이 +0초
|
||||||
|
dev → Let's Encrypt ACME 차이 +0초
|
||||||
|
test-server → Google 차이 -105초 (즉 test-server 가 105초 빠르다)
|
||||||
|
ssh 왕복 왜곡 3회 측정: +106.1 / +106.1 / +106.1초 (안정적)
|
||||||
|
|
||||||
|
→ **dev 가 정확하고 test-server 가 106초 빠르다.**
|
||||||
|
|
||||||
|
독립 검증 — 새 인증서의 SCT (CT 로그가 서명한 시각, 자체 정확 시계):
|
||||||
|
Signed Certificate Timestamp: Sep 4 12:27:49.054 2026 GMT
|
||||||
|
Signed Certificate Timestamp: Sep 4 12:27:49.048 2026 GMT
|
||||||
|
|
||||||
|
보정한 타임라인 (전부 실제 UTC):
|
||||||
|
12:27:49.05 인증서 발급 ← SCT. 외부 권위 기준
|
||||||
|
12:27:50 훅 nginx -t ← 로그 21:29:36 KST − 106초
|
||||||
|
12:27:50 새 워커 37252 기동 ← lstart 21:29:36 KST − 106초
|
||||||
|
12:27:51 훅 nginx -s reload ← 로그 21:29:37 KST − 106초
|
||||||
|
|
||||||
|
★ 발급 → 서빙까지 **1~2초.**
|
||||||
|
★ 그리고 보정이 자기 검증된다 — 독립 시계인 SCT 가 보정한 훅 시각의
|
||||||
|
1초 앞에 정확히 놓인다. 보정하지 않으면 훅이 발급보다 104초 먼저
|
||||||
|
실행된 것이 되어 물리적으로 불가능해진다.
|
||||||
|
|
||||||
|
════ D-4 와의 대조 ════
|
||||||
|
|
||||||
|
훅 없음 (D-4) 훅 있음 (D-4a)
|
||||||
|
갱신 → 서빙 2305초 = 38분 25초 1~2초
|
||||||
|
무엇이 reload 했나 사람 (`nginx -s reload`) certbot deploy 훅
|
||||||
|
안 했다면 다음 nginx 재시작까지 해당 없음
|
||||||
|
= 사실상 무기한
|
||||||
|
차이 약 1150배
|
||||||
|
|
||||||
|
════ 부수 정정 — D-4 의 2199초는 틀렸다 ════
|
||||||
|
|
||||||
|
D-4 에서 적은 **2199초(36분 39초)** 는 `archive/cert2.pem` 의 mtime
|
||||||
|
(test-server 시계)과 일련번호 관측 시각(dev 시계)을 **그대로 뺀** 값이었다.
|
||||||
|
106초 보정을 적용하면:
|
||||||
|
|
||||||
|
새 인증서 디스크 기록 08:20:27 UTC (실제) ← mtime 17:22:13 KST − 106초
|
||||||
|
실제 서빙 시작 08:58:52 UTC (실제) ← dev 관측, 보정 불필요
|
||||||
|
공백 2305초 = 38분 25초
|
||||||
|
|
||||||
|
관련 문서를 전부 정정했다. **두 시계에서 온 값을 빼면서 그 사실을 적지
|
||||||
|
않으면, 자릿수가 아니라 방향까지 틀릴 수 있다** — 여기서는 그 오차가
|
||||||
|
100초 단위였고, 1~2초를 재는 D-4a 에서는 결과를 완전히 뒤집었다.
|
||||||
|
|
||||||
|
════ 결론 ════
|
||||||
|
|
||||||
|
1. **D-4 의 처방은 듣는다.** 훅 파일 하나로 발급 → 서빙이 38분에서 1~2초가 됐다.
|
||||||
|
2. **certbot 출력의 "ran with error output" 을 실패로 읽으면 안 된다.**
|
||||||
|
nginx 의 경고가 stderr 로 나갔을 뿐이며, 내용은 전부 성공이다.
|
||||||
|
3. **진단 방법이 재확인됐다** — 마스터 PID 유지 + 워커 PID 교체 = reload.
|
||||||
|
4. **시계를 섞어 빼면 안 된다.** test-server 는 NTP 가 꺼져 106초 빠르고,
|
||||||
|
그 사실을 적지 않은 채 계산한 D-4 의 공백은 106초 짧았다.
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
Saving debug log to /var/log/letsencrypt/letsencrypt.log
|
||||||
|
|
||||||
|
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
|
||||||
|
Processing /etc/letsencrypt/renewal/auth.hyeonworks.com.conf
|
||||||
|
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
|
||||||
|
Renewing an existing certificate for auth.hyeonworks.com and 2 more
|
||||||
|
Hook 'deploy-hook' ran with error output:
|
||||||
|
2026/09/04 21:29:36 [warn] 37250#37250: could not build optimal types_hash, you should increase either types_hash_max_size: 1024 or types_hash_bucket_size: 64; ignoring types_hash_bucket_size
|
||||||
|
nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
|
||||||
|
nginx: configuration file /etc/nginx/nginx.conf test is successful
|
||||||
|
2026/09/04 21:29:37 [warn] 37251#37251: could not build optimal types_hash, you should increase either types_hash_max_size: 1024 or types_hash_bucket_size: 64; ignoring types_hash_bucket_size
|
||||||
|
2026/09/04 21:29:37 [notice] 37251#37251: signal process started
|
||||||
|
|
||||||
|
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
|
||||||
|
Congratulations, all renewals succeeded:
|
||||||
|
/etc/letsencrypt/live/auth.hyeonworks.com/fullchain.pem (success)
|
||||||
|
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
=== 실행 후 상태 (dev 머신에서 관측) ===
|
||||||
|
수집 2026-09-04T12:29:05Z UTC
|
||||||
|
|
||||||
|
-- nginx 프로세스
|
||||||
|
585 1 95412 Thu Sep 3 19:00:39 2026 nginx: master process /usr/bin/nginx
|
||||||
|
37252 585 74 Fri Sep 4 21:29:36 2026 nginx: worker process
|
||||||
|
|
||||||
|
-- 서빙 인증서
|
||||||
|
serial=06F3E0EF4D1BB03DE58130EAAD1176101373
|
||||||
|
notBefore=Sep 4 11:29:18 2026 GMT
|
||||||
|
notAfter=Dec 3 11:29:17 2026 GMT
|
||||||
|
X509v3 Subject Alternative Name:
|
||||||
|
DNS:app1.hyeonworks.com, DNS:app2.hyeonworks.com, DNS:auth.hyeonworks.com
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
# D-4a — deploy 훅 검증 증거
|
||||||
|
|
||||||
|
2026-09-04 12:27 UTC (실제 시각)
|
||||||
|
해설: [`docs/experiment-d4a-deploy-hook.md`](../../experiment-d4a-deploy-hook.md)
|
||||||
|
|
||||||
|
| 파일 | 무엇을 보여주는가 |
|
||||||
|
|---|---|
|
||||||
|
| `01-hook-verified.txt` | **판정 전문.** 훅 실행 로그 · 워커 PID 교체 · 시계 보정과 SCT 교차검증 · D-4 와의 대조 |
|
||||||
|
| `02-certbot-with-hook.txt` | `certbot renew --force-renewal` 원문. `Hook 'deploy-hook' ran` 과 `all renewals succeeded` |
|
||||||
|
| `03-after-state.txt` | 실행 후 nginx 프로세스와 서빙 인증서 |
|
||||||
|
|
||||||
|
## 핵심 네 줄
|
||||||
|
|
||||||
|
1. **D-4 의 처방은 듣는다.** 훅 파일 하나로 발급 → 서빙이 **38분 25초에서 1~2초**가 됐다. 약 1150배.
|
||||||
|
2. **`Hook 'deploy-hook' ran with error output` 을 실패로 읽으면 안 된다.** nginx 의 `types_hash` 경고가 stderr 로 나갔을 뿐이고 내용은 전부 성공이다 — `test is successful`, `signal process started`. 로그에서 `error` 를 grep 하면 성공한 훅을 실패로 오독한다.
|
||||||
|
3. **진단 방법이 재확인됐다.** 마스터 585 는 그대로, 워커만 28829 → **37252** 로 교체. D-4 에서 세운 「마스터 유지 + 워커 교체 = reload」가 그대로 작동한다.
|
||||||
|
4. **★ 시계를 섞어 빼면 안 된다.** test-server 는 NTP 가 꺼져 **106초 빠르다**(외부 기준 3회 측정). 이 사실을 적지 않고 계산한 D-4 의 공백은 106초 짧았고, 1~2초를 재는 D-4a 에서는 보정 없이는 **훅이 발급보다 104초 먼저 실행된 것**이 되어 물리적으로 불가능해진다. 보정은 새 인증서 SCT(`12:27:49.054 GMT`, CT 로그의 독립 시계)로 교차검증된다.
|
||||||
@@ -1,9 +1,66 @@
|
|||||||
=== ★ Keycloak 에서 email 을 바꾼다 ===
|
# B-4 ③ — IdP 의 클레임 변경은 언제 upstream 에 반영되는가
|
||||||
변경 시각: 15:27:52
|
#
|
||||||
IdP 의 값: [ {
|
# 이 파일은 재수집본이다. 첫 수집본은 "이제 요청을 반복해 본다" 에서 끝났고,
|
||||||
IdP 의 값: "email" : "changed-labuser@example.com"
|
# 그 뒤의 12회 결과는 브라우저 콘솔에서만 보고 파일로 남기지 않았다.
|
||||||
IdP 의 값: } ]
|
# 서브에이전트 감사가 "증거 없이 서 있는 결론" 으로 지적했고, 다시 측정했다.
|
||||||
|
#
|
||||||
|
# 수집 방법:
|
||||||
|
# - 요청부: Playwright 로 연 브라우저에서 fetch('/api/echo') 를 반복.
|
||||||
|
# oauth2-proxy 세션 쿠키가 HttpOnly 라 curl 로는 재현할 수 없어 브라우저를 썼다.
|
||||||
|
# 아래 출력은 브라우저 콘솔이 돌려준 문자열 그대로다.
|
||||||
|
# - 변경부·저장소 확인부: test-server 의 kubectl 출력 그대로.
|
||||||
|
|
||||||
=== IdP 쪽 세션과 oauth2-proxy 세션 ===
|
=== [1] 기준선 — 변경 전 (브라우저 fetch) ===
|
||||||
Redis 세션: 1 개
|
2026-09-04T07:51:23.862Z req#1 HTTP 200 x-forwarded-email=labuser@example.com x-forwarded-preferred-username=labuser
|
||||||
(세션은 로그인 시점의 클레임을 담고 있다 — 이제 요청을 반복해 본다)
|
2026-09-04T07:51:24.304Z req#2 HTTP 200 x-forwarded-email=labuser@example.com x-forwarded-preferred-username=labuser
|
||||||
|
2026-09-04T07:51:24.722Z req#3 HTTP 200 x-forwarded-email=labuser@example.com x-forwarded-preferred-username=labuser
|
||||||
|
|
||||||
|
=== [2] IdP 에서 email 을 바꾼다 (kubectl 출력) ===
|
||||||
|
변경 시각(UTC): 2026-09-04T07:53:32.000Z
|
||||||
|
IdP 의 값:
|
||||||
|
[ {
|
||||||
|
"email" : "changed-labuser@example.com"
|
||||||
|
} ]
|
||||||
|
oauth2-proxy 세션: 1 개 (그대로 살아 있다)
|
||||||
|
|
||||||
|
=== [시계 보정] 두 시계가 다르다 — 해석에 필요하다 ===
|
||||||
|
개발 머신(브라우저 fetch 의 타임스탬프): 2026-09-04T07:52:20Z
|
||||||
|
test-server (kubectl 출력의 타임스탬프): 2026-09-04T07:54:07Z
|
||||||
|
→ test-server 가 약 107초 앞선다.
|
||||||
|
브라우저 07:51:56 = 서버 07:53:43 이므로, 아래 12회는 변경(07:53:32) 11초 뒤다.
|
||||||
|
|
||||||
|
=== [3] 변경 후 12회 반복 (브라우저 fetch) ===
|
||||||
|
2026-09-04T07:51:56.300Z req#1 HTTP 200 x-forwarded-email=labuser@example.com
|
||||||
|
2026-09-04T07:51:56.864Z req#2 HTTP 200 x-forwarded-email=labuser@example.com
|
||||||
|
2026-09-04T07:51:57.489Z req#3 HTTP 200 x-forwarded-email=labuser@example.com
|
||||||
|
2026-09-04T07:51:58.018Z req#4 HTTP 200 x-forwarded-email=labuser@example.com
|
||||||
|
2026-09-04T07:51:58.602Z req#5 HTTP 200 x-forwarded-email=labuser@example.com
|
||||||
|
2026-09-04T07:51:59.217Z req#6 HTTP 200 x-forwarded-email=labuser@example.com
|
||||||
|
2026-09-04T07:51:59.743Z req#7 HTTP 200 x-forwarded-email=labuser@example.com
|
||||||
|
2026-09-04T07:52:00.342Z req#8 HTTP 200 x-forwarded-email=labuser@example.com
|
||||||
|
2026-09-04T07:52:00.964Z req#9 HTTP 200 x-forwarded-email=labuser@example.com
|
||||||
|
2026-09-04T07:52:01.574Z req#10 HTTP 200 x-forwarded-email=labuser@example.com
|
||||||
|
2026-09-04T07:52:02.187Z req#11 HTTP 200 x-forwarded-email=labuser@example.com
|
||||||
|
2026-09-04T07:52:02.719Z req#12 HTTP 200 x-forwarded-email=labuser@example.com
|
||||||
|
|
||||||
|
→ 12회 · 약 6.4초 동안 전부 옛 값. 요청 횟수로는 반영되지 않는다.
|
||||||
|
|
||||||
|
=== [4] IdP 값은 이미 바뀌어 있었다 (대조) ===
|
||||||
|
IdP: "email" : "changed-labuser@example.com"
|
||||||
|
|
||||||
|
=== [5] 세션을 지워 재인증을 강제한다 ===
|
||||||
|
남은 oauth2-proxy 세션: 0 개
|
||||||
|
(Keycloak SSO 는 살아 있으므로 조용히 재인증된다)
|
||||||
|
|
||||||
|
=== [6] 재인증 후 (브라우저 fetch) ===
|
||||||
|
2026-09-04T07:53:01.121Z req#1 HTTP 200 x-forwarded-email=changed-labuser@example.com
|
||||||
|
2026-09-04T07:53:01.456Z req#2 HTTP 200 x-forwarded-email=changed-labuser@example.com
|
||||||
|
2026-09-04T07:53:01.785Z req#3 HTTP 200 x-forwarded-email=changed-labuser@example.com
|
||||||
|
|
||||||
|
=== 결론 ===
|
||||||
|
변경 후 12회 요청(6.4초) → labuser@example.com (옛 값)
|
||||||
|
세션 삭제 후 재인증 → changed-labuser@example.com (새 값)
|
||||||
|
|
||||||
|
→ 반영 시점은 요청 횟수가 아니라 세션이 새로 만들어지는가로 정해진다.
|
||||||
|
--cookie-refresh 가 설정되어 있지 않으므로, 실제 운영에서는
|
||||||
|
쿠키 만료(1시간) 또는 재인증까지 옛 값이 간다.
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
재현 절차 명령 실행 검증 — 문서에 적힌 명령을 그대로 돌려본 기록
|
||||||
|
====================================================================
|
||||||
|
수집: 2026-09-04, kc-lab-1 에서 sudo kubectl (호스트 test-server 경유)
|
||||||
|
대상: 감사에서 "산문이라 재현 불가"로 지적된 명령들을 셸 실행형으로 바꾼 뒤,
|
||||||
|
바꾼 형태가 실제로 도는지 확인한 것.
|
||||||
|
|
||||||
|
왜 이 파일이 있나
|
||||||
|
------------------
|
||||||
|
바꾼 명령을 돌려보지 않고 "재현 가능하게 고쳤다"고 쓰면, 감사가 잡아낸 바로
|
||||||
|
그 실수(측정하지 않고 단언)를 반복하는 것이다. 그래서 실행 기록을 남긴다.
|
||||||
|
그리고 실제로 이 검증에서 한 건이 깨졌다 — 아래 [실패] 항목.
|
||||||
|
|
||||||
|
환경
|
||||||
|
----
|
||||||
|
K0 = 10.42.1.142 (keycloak-0, kc-lab-2)
|
||||||
|
K1 = 10.42.0.62 (keycloak-1, kc-lab-1)
|
||||||
|
PW = keycloak-lab-secrets/KC_BOOTSTRAP_ADMIN_PASSWORD, 19자
|
||||||
|
TE = /realms/master/protocol/openid-connect/token
|
||||||
|
|
||||||
|
[통과] 검증 1 — A-3 의 sid 추출 파이프라인
|
||||||
|
-------------------------------------------
|
||||||
|
문서 experiment-a3-database-crash.md 2단계에 넣은 파이프라인
|
||||||
|
(access_token → 2번째 세그먼트 → '==' 패딩 → base64 -d → sid) 을 그대로 실행.
|
||||||
|
|
||||||
|
추출된 sid = YWfXBHn08DNujXvJ6QV1lMAd
|
||||||
|
/tmp/sids 줄수 = 1
|
||||||
|
|
||||||
|
sid 는 UUID 가 아니라 24자 문자열이다. RPO 대조에서 IN 절에 넣을 때
|
||||||
|
따옴표가 필요한 이유가 이것이다.
|
||||||
|
|
||||||
|
[통과] 검증 2 — A-6 단일 요청 지연 (각 15회)
|
||||||
|
---------------------------------------------
|
||||||
|
keycloak-0 평균 46 ms (대조군: PostgreSQL 과 같은 노드)
|
||||||
|
keycloak-1 평균 86 ms (시험군: 노드 간 홉이 하나 더 있다)
|
||||||
|
|
||||||
|
주입 없는 평시 값이다. A-6 본문의 22.2초는 지연 주입 상태의 값이므로
|
||||||
|
이 숫자와 직접 비교하면 안 된다. 여기서 확인한 것은 "명령이 돈다" 뿐이다.
|
||||||
|
|
||||||
|
[실패 → 수정] 검증 3 — 동시 20건 부하
|
||||||
|
--------------------------------------
|
||||||
|
처음에 쓴 형태:
|
||||||
|
kubectl -n keycloak-lab run load --rm -i --restart=Never \
|
||||||
|
--image=curlimages/curl:8.11.1 --quiet --command -- sh -c '... & ... wait'
|
||||||
|
|
||||||
|
결과: 출력이 도착하지 않고 세션이 그대로 끊겼다. 두 번 시도해서 두 번 다.
|
||||||
|
일회성 파드(--rm -i)의 stdout 스트림이 20개 동시 자식 프로세스의 출력을
|
||||||
|
잃는 문제이며, 이 실험 시리즈에서 이미 한 번 겪은 함정이다.
|
||||||
|
내가 재현 절차를 고치면서 그 깨진 패턴을 다시 써넣었다.
|
||||||
|
|
||||||
|
고친 형태 — 상주 탐침 + 파드 안 파일 수집 + 한 번 cat:
|
||||||
|
kubectl run a6-probe ... -- sleep 1800
|
||||||
|
kubectl exec a6-probe -- sh -c '... >> /tmp/load ) & ... wait'
|
||||||
|
kubectl exec a6-probe -- cat /tmp/load > /tmp/load.txt
|
||||||
|
|
||||||
|
결과:
|
||||||
|
exit=0
|
||||||
|
수집된 줄수 = 20 ← 20 이어야 한다. 스트림 방식은 여기서 샜다.
|
||||||
|
상태코드별:
|
||||||
|
20 200
|
||||||
|
최대 지연 = 0.968958 s
|
||||||
|
|
||||||
|
[통과] 검증 4 — 풀 지표
|
||||||
|
------------------------
|
||||||
|
agroal_max_used_count{datasource="default"} 4.0
|
||||||
|
agroal_active_count{datasource="default"} 0.0
|
||||||
|
agroal_awaiting_count{datasource="default"} 0.0
|
||||||
|
agroal_blocking_time_average_milliseconds{datasource="default"} 0.0
|
||||||
|
|
||||||
|
동시 20건에도 max_used 가 4 에 그치고 awaiting 이 0 이다.
|
||||||
|
평시에는 풀이 병목이 아니라는 뜻이며, A-6 이 지연을 주입해야만
|
||||||
|
경합이 나타난다고 적은 것과 일치한다.
|
||||||
|
|
||||||
|
남는 교훈
|
||||||
|
---------
|
||||||
|
"명령을 실행 가능하게 고쳤다"와 "고친 명령이 동작한다"는 다른 주장이다.
|
||||||
|
검증 3 이 그 차이를 보여준다 — 문법적으로는 멀쩡했고, 실행하면 조용히 실패했다.
|
||||||
@@ -9,9 +9,11 @@
|
|||||||
| `02-d2-rollback-same-schema.txt` | **스키마가 안 바뀌면 롤백이 된다** — 26.7.3 → 26.7.0 성공. 다만 전환 순간 `000` 1회(3초 타임아웃) |
|
| `02-d2-rollback-same-schema.txt` | **스키마가 안 바뀌면 롤백이 된다** — 26.7.3 → 26.7.0 성공. 다만 전환 순간 `000` 1회(3초 타임아웃) |
|
||||||
| `03-b4-role-propagation.txt` | **B-4 ③** IdP 에서 값을 바꿔도 **12회 요청·6초 동안 옛 값**. 세션 삭제 후 재인증에서야 새 값 |
|
| `03-b4-role-propagation.txt` | **B-4 ③** IdP 에서 값을 바꿔도 **12회 요청·6초 동안 옛 값**. 세션 삭제 후 재인증에서야 새 값 |
|
||||||
| `04-observability-gap.txt` | **B층에 관측이 없다** — Prometheus 는 keycloak·kubelet·node-exporter·prometheus 만 긁는다. Redis·BFF·PostgreSQL 지표가 0개 |
|
| `04-observability-gap.txt` | **B층에 관측이 없다** — Prometheus 는 keycloak·kubelet·node-exporter·prometheus 만 긁는다. Redis·BFF·PostgreSQL 지표가 0개 |
|
||||||
|
| `05-command-reproducibility.txt` | **재현 절차 명령을 실제로 돌려본 기록.** 산문이던 측정 장치를 셸 실행형으로 바꾼 뒤 실행 검증 — 4건 중 1건(동시 20건 부하)이 **조용히 실패**했고 상주 탐침 방식으로 고쳐 20/20 수집 |
|
||||||
|
|
||||||
## 핵심 세 줄
|
## 핵심 세 줄
|
||||||
|
|
||||||
1. **"롤백은 안 된다" 는 조건부였다.** 스키마가 바뀌었으면 안 되고, 안 바뀌었으면 된다 — D-2 의 결론을 정밀화한다.
|
1. **"롤백은 안 된다" 는 조건부였다.** 스키마가 바뀌었으면 안 되고, 안 바뀌었으면 된다 — D-2 의 결론을 정밀화한다.
|
||||||
2. **role 변경은 요청 횟수와 무관하게 반영되지 않는다.** `--cookie-refresh` 가 없으면 쿠키 만료나 재인증까지 옛 값이 간다.
|
2. **role 변경은 요청 횟수와 무관하게 반영되지 않는다.** `--cookie-refresh` 가 없으면 쿠키 만료나 재인증까지 옛 값이 간다.
|
||||||
3. **B층 실험에 Grafana 증거가 없는 이유가 확인됐다** — 관측 대상에 애초에 없다. 스크린샷이 없는 것이 아니라 지표가 없다.
|
3. **바꾼 명령은 돌려봐야 안다.** 문법이 멀쩡해도 일회성 파드는 동시 출력에서 조용히 샜다 — 05 참조.
|
||||||
|
4. **B층 실험에 Grafana 증거가 없는 이유가 확인됐다** — 관측 대상에 애초에 없다. 스크린샷이 없는 것이 아니라 지표가 없다.
|
||||||
|
|||||||
@@ -16,6 +16,15 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 구조
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
> 다이어그램 규약은 [`diagrams/_style.md`](diagrams/_style.md).
|
||||||
|
> 실험대 전체 구조는 [`diagrams/lab-topology.svg`](diagrams/lab-topology.svg).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 0. 결론부터
|
## 0. 결론부터
|
||||||
|
|
||||||
| 물음 | 답 |
|
| 물음 | 답 |
|
||||||
@@ -471,7 +480,7 @@ kubectl -n keycloak-lab exec keycloak-0 -- \
|
|||||||
```sql
|
```sql
|
||||||
select offline_flag, count(*) from offline_user_session group by offline_flag;
|
select offline_flag, count(*) from offline_user_session group by offline_flag;
|
||||||
select user_session_id, offline_flag, created_on, last_session_refresh
|
select user_session_id, offline_flag, created_on, last_session_refresh
|
||||||
from offline_user_session where user_session_id = '<sid>';
|
from offline_user_session where user_session_id = '$SID';
|
||||||
```
|
```
|
||||||
|
|
||||||
**이름이 내용을 배신하는 스키마다.** 운영에서 "온라인 세션이 DB 어디 있냐"를
|
**이름이 내용을 배신하는 스키마다.** 운영에서 "온라인 세션이 DB 어디 있냐"를
|
||||||
@@ -648,6 +657,6 @@ kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
|
|||||||
-c "select offline_flag, count(*) from offline_user_session group by offline_flag"
|
-c "select offline_flag, count(*) from offline_user_session group by offline_flag"
|
||||||
|
|
||||||
# 노드별 캐시 엔트리 (파드 안에서)
|
# 노드별 캐시 엔트리 (파드 안에서)
|
||||||
curl -s http://<pod-ip>:9000/metrics \
|
curl -s http://$(kubectl -n keycloak-lab get pod keycloak-0 -o jsonpath='{.status.podIP}'):9000/metrics \
|
||||||
| grep 'approximate_entries_unique{cache="sessions"'
|
| grep 'approximate_entries_unique{cache="sessions"'
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -9,6 +9,15 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 구조
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
> 다이어그램 규약은 [`diagrams/_style.md`](diagrams/_style.md).
|
||||||
|
> 실험대 전체 구조는 [`diagrams/lab-topology.svg`](diagrams/lab-topology.svg).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 0. 결론부터
|
## 0. 결론부터
|
||||||
|
|
||||||
| 예측 | 결과 |
|
| 예측 | 결과 |
|
||||||
@@ -446,7 +455,11 @@ kubectl -n keycloak-lab delete networkpolicy a1-block-jgroups-transport
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
sudo conntrack -L | grep 7800
|
sudo conntrack -L | grep 7800
|
||||||
sudo conntrack -D -p tcp -s <src> -d <dst> --sport <sp> --dport <dp>
|
# 위 출력의 src/dst/sport/dport 를 그대로 넣는다. 한 줄로 하려면:
|
||||||
|
sudo conntrack -L 2>/dev/null | grep 7800 | awk '{
|
||||||
|
for(i=1;i<=NF;i++){ split($i,a,"="); t[a[1]]=a[2] }
|
||||||
|
print "sudo conntrack -D -p tcp -s "t["src"]" -d "t["dst"]" --sport "t["sport"]" --dport "t["dport"]
|
||||||
|
}' | sh
|
||||||
```
|
```
|
||||||
|
|
||||||
### FD_SOCK2 와 포트 규약
|
### FD_SOCK2 와 포트 규약
|
||||||
@@ -524,8 +537,14 @@ kubectl -n keycloak-lab get pods -o wide | grep keycloak # restarts=0 확인
|
|||||||
ssh kc-lab-1 'sudo conntrack -L | grep 7800'
|
ssh kc-lab-1 'sudo conntrack -L | grep 7800'
|
||||||
|
|
||||||
# 4. conntrack 삭제 (양쪽 노드, 양쪽 방향). 반영까지 약 3분
|
# 4. conntrack 삭제 (양쪽 노드, 양쪽 방향). 반영까지 약 3분
|
||||||
ssh kc-lab-1 'sudo conntrack -D -p tcp -s <k1ip> -d <k0ip> --sport <sp> --dport 7800'
|
# 노드마다 자기 conntrack 표에서 7800 튜플을 뽑아 그대로 지운다.
|
||||||
ssh kc-lab-2 'sudo conntrack -D -p tcp -s <k0ip> -d <k1ip> --sport 7800 --dport <sp>'
|
# 방향(누가 client 인지)은 재시작마다 바뀐다 — 그래서 손으로 적으면 틀린다(A-5 에서 실제로 틀렸다).
|
||||||
|
for N in kc-lab-1 kc-lab-2; do
|
||||||
|
ssh $N "sudo conntrack -L 2>/dev/null | grep 7800 | awk '{
|
||||||
|
for(i=1;i<=NF;i++){ split(\$i,a,\"=\"); t[a[1]]=a[2] }
|
||||||
|
print \"sudo conntrack -D -p tcp -s \"t[\"src\"]\" -d \"t[\"dst\"]\" --sport \"t[\"sport\"]\" --dport \"t[\"dport\"]
|
||||||
|
}' | sh"
|
||||||
|
done
|
||||||
|
|
||||||
# 5. 분단 확인
|
# 5. 분단 확인
|
||||||
curl -s "http://localhost:19090/api/v1/query?query=vendor_cluster_size"
|
curl -s "http://localhost:19090/api/v1/query?query=vendor_cluster_size"
|
||||||
|
|||||||
@@ -308,7 +308,9 @@ kubectl -n keycloak-lab wait --for=condition=Ready pod/a2-probe --timeout=120s
|
|||||||
kubectl -n keycloak-lab exec a2-probe -- sh -c \
|
kubectl -n keycloak-lab exec a2-probe -- sh -c \
|
||||||
'curl -s -X POST http://<k0>:8080/realms/master/protocol/openid-connect/token \
|
'curl -s -X POST http://<k0>:8080/realms/master/protocol/openid-connect/token \
|
||||||
-d grant_type=password -d client_id=admin-cli \
|
-d grant_type=password -d client_id=admin-cli \
|
||||||
-d username=admin -d password=<pw> > /tmp/tok.json'
|
-d username=admin -d password='"$PW"' > /tmp/tok.json'
|
||||||
|
# PW=$(kubectl -n keycloak-lab get secret keycloak-lab-secrets \
|
||||||
|
# -o jsonpath='{.data.KC_BOOTSTRAP_ADMIN_PASSWORD}' | base64 -d)
|
||||||
|
|
||||||
# 2. DB 정지
|
# 2. DB 정지
|
||||||
kubectl -n keycloak-lab scale deployment/postgres --replicas=0
|
kubectl -n keycloak-lab scale deployment/postgres --replicas=0
|
||||||
|
|||||||
@@ -9,6 +9,15 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 구조
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
> 다이어그램 규약은 [`diagrams/_style.md`](diagrams/_style.md).
|
||||||
|
> 실험대 전체 구조는 [`diagrams/lab-topology.svg`](diagrams/lab-topology.svg).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 0. 결론부터
|
## 0. 결론부터
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -330,8 +339,21 @@ kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
|
|||||||
kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
|
kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
|
||||||
-c "delete from offline_user_session"
|
-c "delete from offline_user_session"
|
||||||
|
|
||||||
# 2. 로그인 루프 (호스트에서 백그라운드 exec — 파드 안 & 는 exec 종료와 함께 죽는다)
|
# 2. 로그인 루프 — 호스트에서 백그라운드로 exec 한다.
|
||||||
kubectl -n keycloak-lab exec a2-probe -- sh -c '<로그인 반복, sid 를 /tmp/sids 에>' &
|
# 파드 안에서 ( ... ) & 로 띄우면 exec 세션이 끝날 때 같이 죽는다(실측).
|
||||||
|
K0=$(kubectl -n keycloak-lab get pod keycloak-0 -o jsonpath='{.status.podIP}')
|
||||||
|
kubectl -n keycloak-lab exec a2-probe -- sh -c "
|
||||||
|
i=0
|
||||||
|
while [ \$i -lt 200 ]; do
|
||||||
|
R=\$(curl -s --max-time 5 -X POST http://$K0:8080/realms/master/protocol/openid-connect/token \
|
||||||
|
-d grant_type=password -d client_id=admin-cli -d username=admin -d password=lab-admin-change-me)
|
||||||
|
S=\$(echo \"\$R\" | sed -n 's/.*\"access_token\":\"\\([^\"]*\\)\".*/\\1/p' \
|
||||||
|
| cut -d. -f2 | sed 's/\$/==/' | base64 -d 2>/dev/null \
|
||||||
|
| sed -n 's/.*\"sid\":\"\\([^\"]*\\)\".*/\\1/p')
|
||||||
|
[ -n \"\$S\" ] && echo \"\$S\" >> /tmp/sids
|
||||||
|
i=\$((i+1))
|
||||||
|
done" >/dev/null 2>&1 &
|
||||||
|
LOOP=$!
|
||||||
|
|
||||||
# 3. 진짜 크래시 — 백엔드 프로세스에 SIGKILL
|
# 3. 진짜 크래시 — 백엔드 프로세스에 SIGKILL
|
||||||
kubectl -n keycloak-lab exec deploy/postgres -- \
|
kubectl -n keycloak-lab exec deploy/postgres -- \
|
||||||
@@ -340,9 +362,14 @@ kubectl -n keycloak-lab exec deploy/postgres -- \
|
|||||||
# 4. 주입이 걸렸는지 확인 — 이게 없으면 결과를 해석하지 않는다
|
# 4. 주입이 걸렸는지 확인 — 이게 없으면 결과를 해석하지 않는다
|
||||||
kubectl -n keycloak-lab logs deploy/postgres | grep -E "not properly shut down|redo"
|
kubectl -n keycloak-lab logs deploy/postgres | grep -E "not properly shut down|redo"
|
||||||
|
|
||||||
# 5. 대조
|
# 5. 대조 — 클라이언트가 200 을 받은 sid 를 그대로 IN 절로 만든다
|
||||||
kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak -tAc \
|
kill $LOOP 2>/dev/null
|
||||||
"select count(*) from offline_user_session where user_session_id in (<sid 목록>)"
|
kubectl -n keycloak-lab exec a2-probe -- cat /tmp/sids > /tmp/sids.txt
|
||||||
|
TOTAL=$(wc -l < /tmp/sids.txt)
|
||||||
|
IN=$(sed "s/^/'/; s/$/'/" /tmp/sids.txt | paste -sd,)
|
||||||
|
FOUND=$(kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak -tAc \
|
||||||
|
"select count(*) from offline_user_session where offline_flag='0' and user_session_id in ($IN)")
|
||||||
|
echo "클라이언트 성공 $TOTAL / DB 존재 $FOUND / 유실 $((TOTAL-FOUND))"
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -17,6 +17,15 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 구조
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
> 다이어그램 규약은 [`diagrams/_style.md`](diagrams/_style.md).
|
||||||
|
> 실험대 전체 구조는 [`diagrams/lab-topology.svg`](diagrams/lab-topology.svg).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 0. 결론부터
|
## 0. 결론부터
|
||||||
|
|
||||||
| | 4a 워커 상실 | 4b 컨트롤 플레인 상실 |
|
| | 4a 워커 상실 | 4b 컨트롤 플레인 상실 |
|
||||||
@@ -404,7 +413,7 @@ kubectl get node kc-lab-2 # 40초 뒤 NotReady
|
|||||||
kubectl -n keycloak-lab get pods -o wide # Running 인 채로 얼어 있다
|
kubectl -n keycloak-lab get pods -o wide # Running 인 채로 얼어 있다
|
||||||
kubectl get node kc-lab-2 -o jsonpath='{.spec.taints}'
|
kubectl get node kc-lab-2 -o jsonpath='{.spec.taints}'
|
||||||
# 5분 뒤 Terminating + 새 파드 Pending
|
# 5분 뒤 Terminating + 새 파드 Pending
|
||||||
kubectl -n keycloak-lab describe pod <new-pod> | grep -A4 Events
|
kubectl -n keycloak-lab describe pod $(kubectl -n keycloak-lab get pods -l app=postgres --field-selector=status.phase=Pending -o jsonpath='{.items[0].metadata.name}') | grep -A4 Events
|
||||||
|
|
||||||
# 4b 컨트롤 플레인 상실 — kubectl 이 죽으므로 노드에서 직접 본다
|
# 4b 컨트롤 플레인 상실 — kubectl 이 죽으므로 노드에서 직접 본다
|
||||||
virsh destroy kc-lab-1
|
virsh destroy kc-lab-1
|
||||||
|
|||||||
@@ -12,6 +12,15 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 구조
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
> 다이어그램 규약은 [`diagrams/_style.md`](diagrams/_style.md).
|
||||||
|
> 실험대 전체 구조는 [`diagrams/lab-topology.svg`](diagrams/lab-topology.svg).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 0. 결론부터
|
## 0. 결론부터
|
||||||
|
|
||||||
| 물음 | 답 |
|
| 물음 | 답 |
|
||||||
@@ -318,14 +327,14 @@ JGroups 코디네이터는 **가장 오래된 멤버**다. 분단이 나면
|
|||||||
ssh kc-lab-1 'sudo conntrack -L | grep 7800'
|
ssh kc-lab-1 'sudo conntrack -L | grep 7800'
|
||||||
|
|
||||||
# 2. 수신측 노드의 raw PREROUTING 에 넣는다 (filter 는 CNI 와 경쟁한다)
|
# 2. 수신측 노드의 raw PREROUTING 에 넣는다 (filter 는 CNI 와 경쟁한다)
|
||||||
ssh kc-lab-1 'sudo iptables -t raw -I PREROUTING 1 -p tcp -d <수신 파드IP> --dport 7800 -j DROP'
|
ssh kc-lab-1 'sudo iptables -t raw -I PREROUTING 1 -p tcp -d $(kubectl -n keycloak-lab get pod keycloak-1 -o jsonpath='{.status.podIP}') --dport 7800 -j DROP'
|
||||||
ssh kc-lab-1 'sudo iptables -t raw -I PREROUTING 1 -p tcp -d <수신 파드IP> --dport 57800 -j DROP'
|
ssh kc-lab-1 'sudo iptables -t raw -I PREROUTING 1 -p tcp -d $(kubectl -n keycloak-lab get pod keycloak-1 -o jsonpath='{.status.podIP}') --dport 57800 -j DROP'
|
||||||
|
|
||||||
# 3. 걸렸는지 카운터로 확인 — 0 이면 해석 금지
|
# 3. 걸렸는지 카운터로 확인 — 0 이면 해석 금지
|
||||||
ssh kc-lab-1 'sudo iptables -t raw -L PREROUTING -n -v'
|
ssh kc-lab-1 'sudo iptables -t raw -L PREROUTING -n -v'
|
||||||
|
|
||||||
# 4. 양방향으로 하려면 반대 노드에도 (한 방향만으로는 자가 치유된다)
|
# 4. 양방향으로 하려면 반대 노드에도 (한 방향만으로는 자가 치유된다)
|
||||||
ssh kc-lab-2 'sudo iptables -t raw -I PREROUTING 1 -p tcp -d <반대 파드IP> --dport 7800 -j DROP'
|
ssh kc-lab-2 'sudo iptables -t raw -I PREROUTING 1 -p tcp -d $(kubectl -n keycloak-lab get pod keycloak-0 -o jsonpath='{.status.podIP}') --dport 7800 -j DROP'
|
||||||
|
|
||||||
# 5. 분단 확인
|
# 5. 분단 확인
|
||||||
kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
|
kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
|
||||||
|
|||||||
@@ -9,6 +9,15 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 구조
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
> 다이어그램 규약은 [`diagrams/_style.md`](diagrams/_style.md).
|
||||||
|
> 실험대 전체 구조는 [`diagrams/lab-topology.svg`](diagrams/lab-topology.svg).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 0. 결론부터
|
## 0. 결론부터
|
||||||
|
|
||||||
| 측정 | 값 |
|
| 측정 | 값 |
|
||||||
@@ -337,15 +346,51 @@ ssh kc-lab-2 '
|
|||||||
# 3. 걸렸는지 카운터로 확인 — Sent 가 0 이면 해석 금지
|
# 3. 걸렸는지 카운터로 확인 — Sent 가 0 이면 해석 금지
|
||||||
ssh kc-lab-2 'sudo tc -s qdisc show dev flannel.1 | grep -A2 netem'
|
ssh kc-lab-2 'sudo tc -s qdisc show dev flannel.1 | grep -A2 netem'
|
||||||
|
|
||||||
# 4. 단일 요청 지연 (대조군은 같은 노드의 keycloak-0)
|
# 변수 준비 — 아래 명령들이 이 값을 쓴다
|
||||||
kubectl -n keycloak-lab run t --rm -i --restart=Never --image=curlimages/curl:8.11.1 \
|
K0=$(kubectl -n keycloak-lab get pod keycloak-0 -o jsonpath='{.status.podIP}')
|
||||||
--command -- curl -s -o /dev/null -w '%{time_total}\n' -X POST http://<pod>:8080/realms/master/protocol/openid-connect/token ...
|
K1=$(kubectl -n keycloak-lab get pod keycloak-1 -o jsonpath='{.status.podIP}')
|
||||||
|
PW=$(kubectl -n keycloak-lab get secret keycloak-lab-secrets \
|
||||||
|
-o jsonpath='{.data.KC_BOOTSTRAP_ADMIN_PASSWORD}' | base64 -d)
|
||||||
|
TE=/realms/master/protocol/openid-connect/token
|
||||||
|
|
||||||
# 5. 동시 부하로 풀 고갈 재현
|
# 4. 상주 탐침을 먼저 띄운다.
|
||||||
# ( curl ... ) & 를 20개 띄우고 wait
|
# ★ kubectl run --rm -i 로 20건을 동시에 돌리면 출력이 유실된다(실측: 20줄 중
|
||||||
|
# 일부만 도착하거나 아예 끊긴다). 결과는 파드 안 파일에 모으고 한 번에 cat 한다.
|
||||||
|
kubectl -n keycloak-lab run a6-probe --image=curlimages/curl:8.11.1 \
|
||||||
|
--restart=Never --command -- sleep 1800
|
||||||
|
kubectl -n keycloak-lab wait --for=condition=Ready pod/a6-probe --timeout=120s
|
||||||
|
|
||||||
# 6. 풀 지표
|
# 5. 단일 요청 지연 — 대조군(keycloak-0, DB 와 같은 노드) 대 시험군(keycloak-1)
|
||||||
curl -s http://<pod>:9000/metrics | grep -E '^agroal_(blocking_time|max_used|awaiting)'
|
kubectl -n keycloak-lab exec a6-probe -- sh -c "
|
||||||
|
for t in keycloak-0:$K0 keycloak-1:$K1; do
|
||||||
|
n=\${t%%:*}; ip=\${t#*:}; T=0; i=0
|
||||||
|
while [ \$i -lt 15 ]; do
|
||||||
|
D=\$(curl -s -o /dev/null -w %{time_total} -X POST http://\$ip:8080$TE \
|
||||||
|
-d grant_type=password -d client_id=admin-cli -d username=admin -d password=$PW)
|
||||||
|
T=\$(echo \"\$T \$D\" | awk '{print \$1+\$2}'); i=\$((i+1))
|
||||||
|
done
|
||||||
|
echo \"\$n 평균 \$(echo \$T | awk '{printf \"%.0f\", \$1*1000/15}') ms\"
|
||||||
|
done"
|
||||||
|
|
||||||
|
# 6. 동시 부하 20건 — & 로 띄우고 wait. 순차로 돌리면 풀 경합이 재현되지 않는다.
|
||||||
|
kubectl -n keycloak-lab exec a6-probe -- sh -c "
|
||||||
|
rm -f /tmp/load; i=0
|
||||||
|
while [ \$i -lt 20 ]; do
|
||||||
|
( curl -s -o /dev/null -w '%{http_code} %{time_total}\n' --max-time 60 \
|
||||||
|
-X POST http://$K1:8080$TE -d grant_type=password -d client_id=admin-cli \
|
||||||
|
-d username=admin -d password=$PW >> /tmp/load ) &
|
||||||
|
i=\$((i+1))
|
||||||
|
done
|
||||||
|
wait"
|
||||||
|
kubectl -n keycloak-lab exec a6-probe -- cat /tmp/load > /tmp/load.txt
|
||||||
|
wc -l /tmp/load.txt # 20 이 아니면 수집이 샌 것이다
|
||||||
|
awk '{print $1}' /tmp/load.txt | sort | uniq -c
|
||||||
|
awk '{print $2}' /tmp/load.txt | sort -g | tail -1 # 최대 지연
|
||||||
|
|
||||||
|
# 7. 풀 지표 — 부하 직후에 읽어야 blocking_time 이 남아 있다
|
||||||
|
kubectl -n keycloak-lab exec a6-probe -- sh -c \
|
||||||
|
"curl -s http://$K1:9000/metrics \
|
||||||
|
| grep -E '^agroal_(blocking_time_average|max_used_count|awaiting_count|active_count)'"
|
||||||
|
|
||||||
# 7. 해제
|
# 7. 해제
|
||||||
ssh kc-lab-2 'sudo tc qdisc del dev flannel.1 root'
|
ssh kc-lab-2 'sudo tc qdisc del dev flannel.1 root'
|
||||||
|
|||||||
@@ -29,6 +29,11 @@
|
|||||||
|
|
||||||
**세 개가 정반대로 뒤집혔다.** 예측한 그대로다.
|
**세 개가 정반대로 뒤집혔다.** 예측한 그대로다.
|
||||||
|
|
||||||
|
> **★ 이 표에는 조건이 빠져 있다 (A-7a).** volatile 행의 A-2 결과는
|
||||||
|
> **캐시 온도에 따라 달라진다.** 완전 냉시동이면 로그인도 `400` 이고,
|
||||||
|
> 캐시가 완전히 더우면 refresh 도 `200` 이다. 여기 적힌 값은 그 사이의
|
||||||
|
> 한 상태다 — 셋 다 [A-7a](experiment-a7a-volatile-cause.md) 에서 재현했다.
|
||||||
|
|
||||||
> **"세션 공유는 7800 을 안 탄다"는 A-1 의 결론은 버전에 달린 사실이다.**
|
> **"세션 공유는 7800 을 안 탄다"는 A-1 의 결론은 버전에 달린 사실이다.**
|
||||||
> 인터넷 자료 대부분이 24 이전 기준이므로 **거기서는 통념이 맞다.**
|
> 인터넷 자료 대부분이 24 이전 기준이므로 **거기서는 통념이 맞다.**
|
||||||
> 틀린 것은 자료가 아니라 **버전을 확인하지 않고 적용하는 것**이다.
|
> 틀린 것은 자료가 아니라 **버전을 확인하지 않고 적용하는 것**이다.
|
||||||
@@ -164,6 +169,13 @@ partitioned roll out complete: 2 new pods have been updated...
|
|||||||
|
|
||||||
### refresh 가 500 인 이유 — 가설
|
### refresh 가 500 인 이유 — 가설
|
||||||
|
|
||||||
|
> **★ 정정 (A-7a)** — 이 가설은 **틀렸다.** 문장 로깅으로 확정한 결과
|
||||||
|
> 원인은 `REVOKED_TOKEN` 이 아니라 **`CLIENT_SCOPE_CLIENT` 조회**였다.
|
||||||
|
> 더 중요하게는 **위 표 자체가 캐시 온도에 따라 400/500/200 으로 갈린다.**
|
||||||
|
> [A-7a 문서](experiment-a7a-volatile-cause.md) 참조.
|
||||||
|
|
||||||
|
아래는 정정 전 원문이다 — 무엇을 어떻게 잘못 짚었는지 남긴다.
|
||||||
|
|
||||||
**측정은 확실하지만 원인은 확정하지 못했다.** 유력한 후보는
|
**측정은 확실하지만 원인은 확정하지 못했다.** 유력한 후보는
|
||||||
`REVOKED_TOKEN` 테이블이다 — refresh token 회전에서 **이미 쓴 토큰인지**
|
`REVOKED_TOKEN` 테이블이다 — refresh token 회전에서 **이미 쓴 토큰인지**
|
||||||
확인하려면 그 테이블을 봐야 하고, 그 경로는 캐시되지 않는다.
|
확인하려면 그 테이블을 봐야 하고, 그 경로는 캐시되지 않는다.
|
||||||
@@ -174,7 +186,7 @@ kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak -c
|
|||||||
```
|
```
|
||||||
|
|
||||||
**확정하려면 A-3 에서 쓴 문장 로깅을 켜고 다시 재현해야 한다.** 여기서는
|
**확정하려면 A-3 에서 쓴 문장 로깅을 켜고 다시 재현해야 한다.** 여기서는
|
||||||
**가설로 남긴다.**
|
**가설로 남긴다.** → **A-7a 에서 그대로 실행해 확정했고, 가설은 틀렸다.**
|
||||||
|
|
||||||
> **volatile 이 "DB 없이 돌아간다"는 뜻은 아니다.**
|
> **volatile 이 "DB 없이 돌아간다"는 뜻은 아니다.**
|
||||||
> realm·사용자·클라이언트·취소 토큰은 **여전히 DB 에 있다.**
|
> realm·사용자·클라이언트·취소 토큰은 **여전히 DB 에 있다.**
|
||||||
|
|||||||
@@ -0,0 +1,292 @@
|
|||||||
|
# A-7a — volatile 에서 refresh 가 500 인 진짜 이유
|
||||||
|
|
||||||
|
브랜치 `feature/keycloak-a7a-volatile-cause` ·
|
||||||
|
증거 [`docs/evidence/a7a-volatile-cause/`](evidence/a7a-volatile-cause/) ·
|
||||||
|
2026-09-04 20:18–20:24 KST
|
||||||
|
|
||||||
|
A-7 이 **가설로 남긴 것**을 확정하는 실험이다. 확정해 보니 가설이 틀렸고,
|
||||||
|
더 중요하게는 **A-7 의 표 자체가 조건부였다**는 것이 드러났다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 구조
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
> 다이어그램 규약은 [`diagrams/_style.md`](diagrams/_style.md).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0. 결론부터
|
||||||
|
|
||||||
|
| 확인 | 결과 |
|
||||||
|
|---|---|
|
||||||
|
| A-7 의 가설 (`REVOKED_TOKEN`) | **틀렸다.** 로그에 한 번도 나오지 않는다 |
|
||||||
|
| **진짜 원인** | **`CLIENT_SCOPE_CLIENT` 조회** — 선택적 클라이언트 스코프 |
|
||||||
|
| 로그인이 쏘는 SQL | **0개** |
|
||||||
|
| refresh 가 쏘는 SQL | **1개, 그리고 첫 번째만.** 이후 캐시된다 |
|
||||||
|
| **★ A-7 의 표** | **캐시 온도에 따라 400/500/200 으로 갈린다.** 셋 다 재현했다 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. A-7 이 무엇을 남겼나
|
||||||
|
|
||||||
|
> **측정은 확실하지만 원인은 확정하지 못했다.** 유력한 후보는
|
||||||
|
> `REVOKED_TOKEN` 테이블이다 — refresh token 회전에서 **이미 쓴 토큰인지**
|
||||||
|
> 확인하려면 그 테이블을 봐야 하고, 그 경로는 캐시되지 않는다.
|
||||||
|
> **확정하려면 A-3 에서 쓴 문장 로깅을 켜고 다시 재현해야 한다.**
|
||||||
|
|
||||||
|
그 지시대로 했다.
|
||||||
|
|
||||||
|
### 개념 — PostgreSQL 문장 로깅
|
||||||
|
|
||||||
|
**무엇인가.** `log_statement = 'all'` 을 켜면 서버가 받은 모든 SQL 을 로그에
|
||||||
|
찍는다. 애플리케이션을 고치지 않고 **"이 요청이 DB 를 어떻게 쓰는지"** 를
|
||||||
|
밖에서 볼 수 있다.
|
||||||
|
|
||||||
|
**왜 여기 나오나.** "refresh 가 어느 테이블 때문에 실패하는가"는 추측으로
|
||||||
|
답할 문제가 아니다. Keycloak 내부를 읽는 대신 **DB 가 실제로 받은 문장**을
|
||||||
|
보면 된다. A-3 에서 RPO 를 잴 때 쓴 것과 같은 기법이다.
|
||||||
|
|
||||||
|
**없거나 틀리면.** 여기서 정확히 A-7 이 겪은 일이 벌어진다 — 그럴듯한
|
||||||
|
테이블 이름을 골라 가설로 적게 되고, 그게 틀려도 아무도 모른다.
|
||||||
|
|
||||||
|
**확인.**
|
||||||
|
```bash
|
||||||
|
kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
|
||||||
|
-c "ALTER SYSTEM SET log_statement = 'all';" -c "SELECT pg_reload_conf();"
|
||||||
|
kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
|
||||||
|
-tAc "show log_statement" # all 이면 켜졌다
|
||||||
|
```
|
||||||
|
|
||||||
|
### 표식으로 구간을 나눈다
|
||||||
|
|
||||||
|
로그는 JGroups 의 `JGROUPS_PING` 폴링으로 계속 채워진다. 어느 문장이
|
||||||
|
로그인이고 어느 것이 refresh 인지 가르려면 **경계를 찍어야 한다.**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
psql -tAc "select 'MARK_LOGIN_START'" # 이 문장 자체가 로그에 남는다
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 측정 — 로그인은 DB 를 안 본다
|
||||||
|
|
||||||
|
```
|
||||||
|
11:18:49.461 statement: select 'MARK_LOGIN_START'
|
||||||
|
11:18:49.743 statement: select 'MARK_LOGIN_END'
|
||||||
|
↑ 사이에 아무것도 없다
|
||||||
|
```
|
||||||
|
|
||||||
|
realm·사용자·클라이언트가 전부 Infinispan 캐시에 있고, volatile 이라
|
||||||
|
세션 쓰기도 없다. **DB 없이 완결된다** — A-7 이 적은 그대로다.
|
||||||
|
|
||||||
|
## 3. ★ refresh 는 한 문장을 쏜다 — 그리고 그게 아니다
|
||||||
|
|
||||||
|
```
|
||||||
|
11:18:52.009 statement: select 'MARK_REFRESH_START'
|
||||||
|
11:18:52.137 statement: BEGIN
|
||||||
|
11:18:52.137 execute <unnamed>/C_107:
|
||||||
|
select cscme1_0.SCOPE_ID from CLIENT_SCOPE_CLIENT cscme1_0
|
||||||
|
where cscme1_0.CLIENT_ID=$1 and cscme1_0.DEFAULT_SCOPE=$2
|
||||||
|
parameters: $1 = '131a9912-b578-4b9c-b16a-97518704077e', $2 = 'f'
|
||||||
|
11:18:52.148 execute S_2: COMMIT
|
||||||
|
11:18:52.253 statement: select 'MARK_REFRESH_END'
|
||||||
|
```
|
||||||
|
|
||||||
|
**`REVOKED_TOKEN` 은 한 번도 나오지 않는다.**
|
||||||
|
|
||||||
|
### 개념 — `DEFAULT_SCOPE='f'` 가 무슨 뜻인가
|
||||||
|
|
||||||
|
**무엇인가.** Keycloak 의 클라이언트는 스코프를 두 종류로 갖는다.
|
||||||
|
|
||||||
|
| | 뜻 | `DEFAULT_SCOPE` |
|
||||||
|
|---|---|---|
|
||||||
|
| default scope | 항상 붙는다 | `t` |
|
||||||
|
| **optional scope** | **요청이 `scope=` 로 달라고 해야 붙는다** | **`f`** |
|
||||||
|
|
||||||
|
**왜 여기 나오나.** refresh 는 새 access token 을 만든다. 그 토큰에 어떤
|
||||||
|
스코프를 담을지 정하려면 **"이 클라이언트가 요청 가능한 optional 스코프가
|
||||||
|
무엇인가"** 를 알아야 한다. 그 목록이 `CLIENT_SCOPE_CLIENT` 에 있다.
|
||||||
|
로그인 때는 이미 결정된 것을 쓰지만, refresh 는 다시 계산한다.
|
||||||
|
|
||||||
|
**없거나 틀리면.** 이 조회가 실패하면 토큰을 만들 수 없어 **500** 이다.
|
||||||
|
`400 Session not active` 와 달리 **세션 문제가 아니다** — 그래서 A-7 이
|
||||||
|
세션 계열 테이블(`REVOKED_TOKEN`)을 의심한 것이 자연스러웠지만 틀렸다.
|
||||||
|
|
||||||
|
## 4. 그 조회는 한 번뿐이다
|
||||||
|
|
||||||
|
연속 refresh 3회, 전부 200. 표식 사이 SQL:
|
||||||
|
|
||||||
|
```
|
||||||
|
statement: select 'MARK_R1'
|
||||||
|
statement: select 'MARK_R2'
|
||||||
|
statement: select 'MARK_R3'
|
||||||
|
statement: select 'MARK_R_END'
|
||||||
|
↑ SQL 0건
|
||||||
|
```
|
||||||
|
|
||||||
|
**첫 refresh 가 캐시를 채우고, 이후로는 DB 를 보지 않는다.**
|
||||||
|
여기서 A-7 의 표가 흔들리기 시작한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. ★ 그래서 A-7 의 표는 조건부였다
|
||||||
|
|
||||||
|
같은 설정에서 **캐시 온도만으로 결과가 셋으로 갈린다.** 전부 재현했다.
|
||||||
|
|
||||||
|
| 캐시 상태 | 로그인 | refresh | 실패한 SQL |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **완전 냉시동** (재시작 직후) | **400** | 400 | `select ce1_0.ID from CLIENT where CLIENT_ID=? and REALM_ID=?` |
|
||||||
|
| **CLIENT 만 더움** ← A-7 이 본 것 | 200 | **500** | `select cscme1_0.SCOPE_ID from CLIENT_SCOPE_CLIENT …` |
|
||||||
|
| **완전히 더움** | 200 | **200** | 없음 (SQL 0건) |
|
||||||
|
|
||||||
|
### 재현 A — 완전 냉시동이면 로그인부터 400
|
||||||
|
|
||||||
|
```
|
||||||
|
keycloak 재시작 → postgres 정지 → 로그인
|
||||||
|
400 {"error":"unauthorized_client",
|
||||||
|
"error_description":"Unexpected error when authenticating client"}
|
||||||
|
|
||||||
|
ERROR KC-SERVICES0015: Unexpected error when authenticating client:
|
||||||
|
JDBC exception executing SQL [FATAL: terminating connection …]
|
||||||
|
[select ce1_0.ID from CLIENT ce1_0 where ce1_0.CLIENT_ID=? and ce1_0.REALM_ID=?]
|
||||||
|
```
|
||||||
|
|
||||||
|
**A-7 은 "volatile 이면 DB 없이 로그인된다"고 적었다. 냉시동에서는 아니다.**
|
||||||
|
클라이언트 조회조차 캐시에 없기 때문이다.
|
||||||
|
|
||||||
|
### 재현 B — A-7 이 본 그 조건
|
||||||
|
|
||||||
|
```
|
||||||
|
keycloak 재시작 → (DB 살아있을 때) 로그인 1회 → postgres 정지 → refresh
|
||||||
|
로그인 200
|
||||||
|
refresh 500 {"error":"unknown_error"}
|
||||||
|
|
||||||
|
JDBC exception executing SQL [FATAL: terminating connection …]
|
||||||
|
[select cscme1_0.SCOPE_ID from CLIENT_SCOPE_CLIENT cscme1_0
|
||||||
|
where cscme1_0.CLIENT_ID=? and cscme1_0.DEFAULT_SCOPE=?]
|
||||||
|
```
|
||||||
|
|
||||||
|
**로그가 실패한 SQL 을 직접 지목한다.** 가설이 아니라 확정이다.
|
||||||
|
|
||||||
|
### 재현 C — 완전히 더우면 둘 다 200
|
||||||
|
|
||||||
|
```
|
||||||
|
refresh 3회로 캐시를 채운 뒤 postgres 정지
|
||||||
|
로그인 200
|
||||||
|
refresh 200 ← A-7 의 표와 정반대
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 이 실험이 A층에 남기는 것
|
||||||
|
|
||||||
|
1. **원인 확정** — `REVOKED_TOKEN` 이 아니라 **선택적 클라이언트 스코프
|
||||||
|
조회**다. A-7 의 가설은 틀렸고, 이제 로그가 지목하는 문장이 있다.
|
||||||
|
|
||||||
|
2. **A-7 의 표에 조건을 붙여야 한다.** 「volatile + DB 정지 → 로그인 200,
|
||||||
|
refresh 500」은 **캐시가 반쯤 더울 때만** 참이다.
|
||||||
|
|
||||||
|
3. **일반화 — 이런 종류는 한 번 재고 표로 적으면 안 된다.**
|
||||||
|
volatile 에서 DB 정지 시의 동작은 "무엇을 하느냐"가 아니라
|
||||||
|
**"그 경로가 이미 캐시를 채웠느냐"** 로 결정된다. 그래서 같은 명령이
|
||||||
|
재시작 직후와 얼마 쓴 뒤에 다른 답을 낸다.
|
||||||
|
|
||||||
|
> 이것은 A-1 에서 conntrack 이 「주입했는데 안 걸렸다」를 만든 것과
|
||||||
|
> 같은 계열의 함정이다. 상태가 결과를 바꾸는데 그 상태가 안 보인다.
|
||||||
|
|
||||||
|
4. **persistent(기본값)에는 해당하지 않는다.** 세션 자체를 DB 에 쓰므로
|
||||||
|
DB 가 없으면 캐시 온도와 무관하게 실패한다. **이 조건부성은 volatile
|
||||||
|
고유의 성질**이고, 옛 방식이 "DB 의존이 적다"고 말할 때 놓치는 부분이다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 재현 절차 (명령어)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# ── 1. 문장 로깅을 켠다
|
||||||
|
kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
|
||||||
|
-c "ALTER SYSTEM SET log_statement = 'all';" -c "SELECT pg_reload_conf();"
|
||||||
|
kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
|
||||||
|
-tAc "show log_statement" # all 이어야 한다
|
||||||
|
|
||||||
|
# ── 2. volatile 로 전환
|
||||||
|
kubectl -n keycloak-lab patch statefulset keycloak --type=json \
|
||||||
|
-p '[{"op":"replace","path":"/spec/template/spec/containers/0/args",
|
||||||
|
"value":["start","--features-disabled=persistent-user-sessions"]}]'
|
||||||
|
kubectl -n keycloak-lab rollout status statefulset/keycloak --timeout=500s
|
||||||
|
|
||||||
|
K0=$(kubectl -n keycloak-lab get pod keycloak-0 -o jsonpath='{.status.podIP}')
|
||||||
|
PW=$(kubectl -n keycloak-lab get secret keycloak-lab-secrets \
|
||||||
|
-o jsonpath='{.data.KC_BOOTSTRAP_ADMIN_PASSWORD}' | base64 -d)
|
||||||
|
TE=/realms/master/protocol/openid-connect/token
|
||||||
|
kubectl -n keycloak-lab run a7a-probe --image=curlimages/curl:8.11.1 \
|
||||||
|
--restart=Never --command -- sleep 3600
|
||||||
|
kubectl -n keycloak-lab wait --for=condition=Ready pod/a7a-probe --timeout=120s
|
||||||
|
|
||||||
|
# ── 3. 표식을 넣어가며 로그인 → refresh
|
||||||
|
m() { kubectl -n keycloak-lab exec deploy/postgres -- \
|
||||||
|
psql -U keycloak -d keycloak -tAc "select 'MARK_$1'" >/dev/null; }
|
||||||
|
|
||||||
|
m LOGIN_START
|
||||||
|
R=$(kubectl -n keycloak-lab exec a7a-probe -- sh -c \
|
||||||
|
"curl -s -X POST http://$K0:8080$TE -d grant_type=password \
|
||||||
|
-d client_id=admin-cli -d username=admin -d password=$PW")
|
||||||
|
m LOGIN_END
|
||||||
|
RT=$(echo "$R" | sed -n 's/.*"refresh_token":"\([^"]*\)".*/\1/p')
|
||||||
|
|
||||||
|
m REFRESH_START
|
||||||
|
kubectl -n keycloak-lab exec a7a-probe -- sh -c \
|
||||||
|
"curl -s -o /dev/null -w '%{http_code}\n' -X POST http://$K0:8080$TE \
|
||||||
|
-d grant_type=refresh_token -d client_id=admin-cli -d refresh_token='$RT'"
|
||||||
|
m REFRESH_END
|
||||||
|
|
||||||
|
# ── 4. 표식 사이의 SQL 만 뽑는다
|
||||||
|
PG=$(kubectl -n keycloak-lab get pod -l app=postgres -o jsonpath='{.items[0].metadata.name}')
|
||||||
|
kubectl -n keycloak-lab logs $PG --tail=4000 > /tmp/pg.log
|
||||||
|
awk '/MARK_REFRESH_START/,/MARK_REFRESH_END/' /tmp/pg.log | grep -v JGROUPS_PING
|
||||||
|
|
||||||
|
# ── 5. 세 가지 캐시 상태를 각각 재현한다
|
||||||
|
# A. 완전 냉시동 — 재시작 직후 곧바로 DB 정지
|
||||||
|
kubectl -n keycloak-lab rollout restart statefulset/keycloak
|
||||||
|
kubectl -n keycloak-lab rollout status statefulset/keycloak --timeout=500s
|
||||||
|
kubectl -n keycloak-lab scale deploy/postgres --replicas=0
|
||||||
|
# → 로그인 400
|
||||||
|
|
||||||
|
# B. CLIENT 만 더움 — 재시작 후 로그인 1회, refresh 는 하지 않고 DB 정지
|
||||||
|
kubectl -n keycloak-lab scale deploy/postgres --replicas=1
|
||||||
|
kubectl -n keycloak-lab rollout restart statefulset/keycloak
|
||||||
|
# (로그인 1회 실행)
|
||||||
|
kubectl -n keycloak-lab scale deploy/postgres --replicas=0
|
||||||
|
# → 로그인 200 · refresh 500
|
||||||
|
|
||||||
|
# C. 완전히 더움 — refresh 를 3회 미리 돌린 뒤 DB 정지
|
||||||
|
# → 로그인 200 · refresh 200
|
||||||
|
|
||||||
|
# ── 6. 실패 지점은 로그가 직접 말해준다
|
||||||
|
kubectl -n keycloak-lab logs keycloak-0 --tail=150 \
|
||||||
|
| grep -oE 'JDBC exception executing SQL \[[^]]*\] \[[^]]*\]'
|
||||||
|
|
||||||
|
# ── 7. 복구 — 잊으면 다음 실험이 전부 오염된다
|
||||||
|
kubectl -n keycloak-lab scale deploy/postgres --replicas=1
|
||||||
|
kubectl -n keycloak-lab wait --for=condition=Ready pod -l app=postgres --timeout=180s
|
||||||
|
kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
|
||||||
|
-c "ALTER SYSTEM RESET log_statement;" -c "SELECT pg_reload_conf();"
|
||||||
|
kubectl -n keycloak-lab patch statefulset keycloak --type=json \
|
||||||
|
-p '[{"op":"replace","path":"/spec/template/spec/containers/0/args","value":["start"]}]'
|
||||||
|
kubectl -n keycloak-lab delete pod a7a-probe
|
||||||
|
# 확인: DB 에 세션 행이 다시 생기면 persistent 로 돌아온 것이다
|
||||||
|
kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
|
||||||
|
-tAc "select count(*) from offline_user_session where offline_flag='0'"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 증거 파일
|
||||||
|
|
||||||
|
| 파일 | 종류 | 무엇을 보여주는가 |
|
||||||
|
|---|---|---|
|
||||||
|
| [`01-cause-determined.txt`](evidence/a7a-volatile-cause/01-cause-determined.txt) | 터미널 | 문장 로그 원문 · 세 캐시 상태의 재현 · 실패 SQL 을 지목하는 Keycloak 로그 |
|
||||||
|
|
||||||
|
파일별 상세는 [`evidence/a7a-volatile-cause/README.md`](evidence/a7a-volatile-cause/README.md).
|
||||||
@@ -9,6 +9,15 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 구조
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
> 다이어그램 규약은 [`diagrams/_style.md`](diagrams/_style.md).
|
||||||
|
> 실험대 전체 구조는 [`diagrams/lab-topology.svg`](diagrams/lab-topology.svg).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 0. 결론부터
|
## 0. 결론부터
|
||||||
|
|
||||||
| 확인 | 결과 |
|
| 확인 | 결과 |
|
||||||
@@ -192,10 +201,23 @@ kubectl -n keycloak-lab run a8-probe --image=curlimages/curl:8.11.1 \
|
|||||||
--restart=Never --command -- sleep 3600
|
--restart=Never --command -- sleep 3600
|
||||||
kubectl -n keycloak-lab wait --for=condition=Ready pod/a8-probe --timeout=120s
|
kubectl -n keycloak-lab wait --for=condition=Ready pod/a8-probe --timeout=120s
|
||||||
|
|
||||||
# 로그인하고 토큰 보관
|
# 변수 준비
|
||||||
|
K0=$(kubectl -n keycloak-lab get pod keycloak-0 -o jsonpath='{.status.podIP}')
|
||||||
|
PW=$(kubectl -n keycloak-lab get secret keycloak-lab-secrets \
|
||||||
|
-o jsonpath='{.data.KC_BOOTSTRAP_ADMIN_PASSWORD}' | base64 -d)
|
||||||
|
|
||||||
|
# 로그인하고 refresh token 을 /tmp/rt 에, sid 를 /tmp/sid 에 보관.
|
||||||
|
# ★ 아래 5번이 읽는 파일과 같은 이름이어야 한다 — 처음 문서는 /tmp/tok 에 쓰고
|
||||||
|
# /tmp/rt 를 읽어서 빈 문자열을 보냈고, 그래도 400 이 아니라 통과한 것처럼 보였다.
|
||||||
kubectl -n keycloak-lab exec a8-probe -- sh -c \
|
kubectl -n keycloak-lab exec a8-probe -- sh -c \
|
||||||
'curl -s -X POST http://<pod>:8080/realms/master/protocol/openid-connect/token \
|
"curl -s -X POST http://$K0:8080/realms/master/protocol/openid-connect/token \
|
||||||
-d grant_type=password -d client_id=admin-cli -d username=admin -d password=<pw> > /tmp/tok'
|
-d grant_type=password -d client_id=admin-cli -d username=admin -d password=$PW > /tmp/tok
|
||||||
|
sed -n 's/.*\"refresh_token\":\"\\([^\"]*\\)\".*/\\1/p' /tmp/tok > /tmp/rt
|
||||||
|
sed -n 's/.*\"access_token\":\"\\([^\"]*\\)\".*/\\1/p' /tmp/tok | cut -d. -f2 \
|
||||||
|
| sed 's/\$/==/' | base64 -d 2>/dev/null \
|
||||||
|
| sed -n 's/.*\"sid\":\"\\([^\"]*\\)\".*/\\1/p' > /tmp/sid
|
||||||
|
echo \"rt \$(wc -c < /tmp/rt) bytes / sid \$(cat /tmp/sid)\""
|
||||||
|
|
||||||
|
|
||||||
# 재시작 + 가용성 감시
|
# 재시작 + 가용성 감시
|
||||||
kubectl -n keycloak-lab rollout restart statefulset/keycloak
|
kubectl -n keycloak-lab rollout restart statefulset/keycloak
|
||||||
@@ -203,12 +225,13 @@ kubectl -n keycloak-lab rollout status statefulset/keycloak --timeout=420s
|
|||||||
|
|
||||||
# 세션 생존 확인
|
# 세션 생존 확인
|
||||||
kubectl -n keycloak-lab exec a8-probe -- sh -c \
|
kubectl -n keycloak-lab exec a8-probe -- sh -c \
|
||||||
'curl -s -o /dev/null -w "%{http_code}\n" -X POST http://<pod>:8080/realms/master/protocol/openid-connect/token \
|
'curl -s -o /dev/null -w "%{http_code}\n" -X POST http://'$K0':8080/realms/master/protocol/openid-connect/token \
|
||||||
-d grant_type=refresh_token -d client_id=admin-cli -d refresh_token=$(cat /tmp/rt)'
|
-d grant_type=refresh_token -d client_id=admin-cli -d refresh_token=$(cat /tmp/rt)'
|
||||||
|
|
||||||
# DB 대조
|
# DB 대조
|
||||||
kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
|
kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
|
||||||
-c "select user_session_id, created_on, last_session_refresh from offline_user_session where user_session_id='<sid>'"
|
-c "select user_session_id, created_on, last_session_refresh from offline_user_session
|
||||||
|
where offline_flag='0' and user_session_id='$(kubectl -n keycloak-lab exec a8-probe -- cat /tmp/sid)'"
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -11,6 +11,15 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 구조
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
> 다이어그램 규약은 [`diagrams/_style.md`](diagrams/_style.md).
|
||||||
|
> 실험대 전체 구조는 [`diagrams/lab-topology.svg`](diagrams/lab-topology.svg).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 0. 결론부터
|
## 0. 결론부터
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -105,7 +114,7 @@ Bad Gateway
|
|||||||
파드 안에서 직접 받아 해결했다. **alpine 기반 JRE 이미지에 `wget` 이 있다.**
|
파드 안에서 직접 받아 해결했다. **alpine 기반 JRE 이미지에 `wget` 이 있다.**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
kubectl -n keycloak-lab exec <bff-pod> -- wget -qO- http://localhost:8083/actuator/beans
|
kubectl -n keycloak-lab exec $(kubectl -n keycloak-lab get pod -l app=bff --field-selector=status.phase=Running -o jsonpath='{.items[0].metadata.name}') -- wget -qO- http://localhost:8083/actuator/beans
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -129,7 +138,7 @@ kubectl -n keycloak-lab exec <bff-pod> -- wget -qO- http://localhost:8083/actuat
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh config credentials \
|
kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh config credentials \
|
||||||
--server http://localhost:8080 --realm master --user admin --password <pw>
|
--server http://localhost:8080 --realm master --user admin --password "$(kubectl -n keycloak-lab get secret keycloak-lab-secrets -o jsonpath='{.data.KC_BOOTSTRAP_ADMIN_PASSWORD}' | base64 -d)"
|
||||||
|
|
||||||
kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh create realms \
|
kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh create realms \
|
||||||
-s realm=keycloak-patterns -s enabled=true -s accessTokenLifespan=60
|
-s realm=keycloak-patterns -s enabled=true -s accessTokenLifespan=60
|
||||||
@@ -278,7 +287,7 @@ replica 를 늘리는 순간 로그인 자체가 실패한다.
|
|||||||
**무엇을 골랐는지는 실행 중인 인스턴스를 봐야 안다.**
|
**무엇을 골랐는지는 실행 중인 인스턴스를 봐야 안다.**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
kubectl exec <pod> -- wget -qO- http://localhost:8083/actuator/beans
|
kubectl -n keycloak-lab exec $(kubectl -n keycloak-lab get pod -l app=bff --field-selector=status.phase=Running -o jsonpath='{.items[0].metadata.name}') -- wget -qO- http://localhost:8083/actuator/beans
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -320,7 +329,7 @@ kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh create rea
|
|||||||
kubectl apply -f deploy/lab/k8s/bff-redis.yaml
|
kubectl apply -f deploy/lab/k8s/bff-redis.yaml
|
||||||
|
|
||||||
# 6. 자동구성 결과 — 파드 안에서 (프록시는 큰 응답에서 502)
|
# 6. 자동구성 결과 — 파드 안에서 (프록시는 큰 응답에서 502)
|
||||||
kubectl -n keycloak-lab exec <bff-pod> -- wget -qO- http://localhost:8083/actuator/beans > beans.json
|
kubectl -n keycloak-lab exec $(kubectl -n keycloak-lab get pod -l app=bff --field-selector=status.phase=Running -o jsonpath='{.items[0].metadata.name}') -- wget -qO- http://localhost:8083/actuator/beans > beans.json
|
||||||
python3 -c "import json;d=json.load(open('beans.json'));[print(n,'->',i['type']) for n,i in
|
python3 -c "import json;d=json.load(open('beans.json'));[print(n,'->',i['type']) for n,i in
|
||||||
list(d['contexts'].values())[0]['beans'].items() if 'AuthorizedClient' in i['type']]"
|
list(d['contexts'].values())[0]['beans'].items() if 'AuthorizedClient' in i['type']]"
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -122,7 +122,7 @@ value: http://echo.header-lab.svc:8081
|
|||||||
## 2. 자동구성이 실제로 바뀌었는가 — B-0 의 방법을 다시 쓴다
|
## 2. 자동구성이 실제로 바뀌었는가 — B-0 의 방법을 다시 쓴다
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
kubectl -n keycloak-lab exec <bff-pod> -- wget -qO- http://localhost:8083/actuator/beans
|
kubectl -n keycloak-lab exec $(kubectl -n keycloak-lab get pod -l app=bff --field-selector=status.phase=Running -o jsonpath='{.items[0].metadata.name}') -- wget -qO- http://localhost:8083/actuator/beans
|
||||||
```
|
```
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -297,14 +297,14 @@ Q3 는 *"저장소를 직접 열어 refresh token 이 평문으로 남는지 확
|
|||||||
kubectl apply -f deploy/lab/k8s/bff-redis.yaml
|
kubectl apply -f deploy/lab/k8s/bff-redis.yaml
|
||||||
|
|
||||||
# 4. 자동구성이 실제로 바뀌었는지 확인 (B-0 의 방법)
|
# 4. 자동구성이 실제로 바뀌었는지 확인 (B-0 의 방법)
|
||||||
kubectl -n keycloak-lab exec <bff-pod> -- wget -qO- http://localhost:8083/actuator/beans > after.json
|
kubectl -n keycloak-lab exec $(kubectl -n keycloak-lab get pod -l app=bff --field-selector=status.phase=Running -o jsonpath='{.items[0].metadata.name}') -- wget -qO- http://localhost:8083/actuator/beans > after.json
|
||||||
# sessionRepository 가 RedisSessionRepository 인가
|
# sessionRepository 가 RedisSessionRepository 인가
|
||||||
# authorizedClientService 는 여전히 InMemory 인가 ← 이쪽이 핵심
|
# authorizedClientService 는 여전히 InMemory 인가 ← 이쪽이 핵심
|
||||||
|
|
||||||
# 5. Redis 를 직접 연다
|
# 5. Redis 를 직접 연다
|
||||||
kubectl -n keycloak-lab exec deploy/redis -- redis-cli --scan
|
kubectl -n keycloak-lab exec deploy/redis -- redis-cli --scan
|
||||||
kubectl -n keycloak-lab exec deploy/redis -- redis-cli hkeys "bff:session:sessions:<id>"
|
kubectl -n keycloak-lab exec deploy/redis -- redis-cli hkeys "bff:session:sessions:$(kubectl -n keycloak-lab exec deploy/redis -- redis-cli --scan --pattern 'bff:session:sessions:*' | grep -v expires | head -1 | sed 's/.*://')"
|
||||||
kubectl -n keycloak-lab exec deploy/redis -- redis-cli ttl "bff:session:sessions:<id>"
|
kubectl -n keycloak-lab exec deploy/redis -- redis-cli ttl "bff:session:sessions:$(kubectl -n keycloak-lab exec deploy/redis -- redis-cli --scan --pattern 'bff:session:sessions:*' | grep -v expires | head -1 | sed 's/.*://')"
|
||||||
|
|
||||||
# 6. 사용자 관점 확인
|
# 6. 사용자 관점 확인
|
||||||
# 브라우저로 https://app1.hyeonworks.com/bff/token-boundary
|
# 브라우저로 https://app1.hyeonworks.com/bff/token-boundary
|
||||||
|
|||||||
@@ -10,6 +10,15 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 구조
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
> 다이어그램 규약은 [`diagrams/_style.md`](diagrams/_style.md).
|
||||||
|
> 실험대 전체 구조는 [`diagrams/lab-topology.svg`](diagrams/lab-topology.svg).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 0. 결론부터
|
## 0. 결론부터
|
||||||
|
|
||||||
B-1 이 남긴 문제(세션만 공유되고 토큰은 안 됨)를 **JDBC 로 옮겨 해결했다.**
|
B-1 이 남긴 문제(세션만 공유되고 토큰은 안 됨)를 **JDBC 로 옮겨 해결했다.**
|
||||||
@@ -345,7 +354,7 @@ Spring Security 는 DDL 을 두 벌 제공한다.
|
|||||||
# + spring-boot-starter-jdbc, postgresql 의존성
|
# + spring-boot-starter-jdbc, postgresql 의존성
|
||||||
|
|
||||||
# 2. 스키마 — PostgreSQL 판본을 써야 한다
|
# 2. 스키마 — PostgreSQL 판본을 써야 한다
|
||||||
kubectl -n keycloak-lab exec <bff-pod> -- sh -c \
|
kubectl -n keycloak-lab exec $(kubectl -n keycloak-lab get pod -l app=bff --field-selector=status.phase=Running -o jsonpath='{.items[0].metadata.name}') -- sh -c \
|
||||||
'unzip -p /app/app.jar BOOT-INF/lib/spring-security-oauth2-client-*.jar' > /dev/null
|
'unzip -p /app/app.jar BOOT-INF/lib/spring-security-oauth2-client-*.jar' > /dev/null
|
||||||
# 실제로는 nested jar 를 풀어서 -postgres.sql 을 꺼낸다
|
# 실제로는 nested jar 를 풀어서 -postgres.sql 을 꺼낸다
|
||||||
kubectl -n keycloak-lab exec -i deploy/postgres -- psql -U keycloak -d keycloak < oauth2-pg.sql
|
kubectl -n keycloak-lab exec -i deploy/postgres -- psql -U keycloak -d keycloak < oauth2-pg.sql
|
||||||
|
|||||||
@@ -124,7 +124,7 @@ DB 로 확인했다.
|
|||||||
select us.user_session_id,
|
select us.user_session_id,
|
||||||
(select count(*) from offline_client_session cs
|
(select count(*) from offline_client_session cs
|
||||||
where cs.user_session_id = us.user_session_id) as client_sessions
|
where cs.user_session_id = us.user_session_id) as client_sessions
|
||||||
from offline_user_session us where us.user_session_id = '<sid>';
|
from offline_user_session us where us.user_session_id = '$SID';
|
||||||
```
|
```
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -300,22 +300,37 @@ A-0 에서 Keycloak 자신이 `for no key update skip locked` 를 쓰는 이유
|
|||||||
kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
|
kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
|
||||||
update realms/keycloak-patterns -s revokeRefreshToken=true -s refreshTokenMaxReuse=0
|
update realms/keycloak-patterns -s revokeRefreshToken=true -s refreshTokenMaxReuse=0
|
||||||
|
|
||||||
# 2. refresh token 하나 확보 (direct grant)
|
# 2. refresh token 하나 확보 (direct grant). 이후 단계가 전부 이 변수들을 쓴다.
|
||||||
curl -s -X POST $KC -d grant_type=password -d client_id=bff-confidential \
|
KC=http://keycloak.keycloak-lab.svc:8080/realms/keycloak-patterns/protocol/openid-connect/token
|
||||||
-d client_secret=bff-lab-secret -d username=labuser -d password=labpass -d scope=openid
|
R=$(curl -s -X POST $KC -d grant_type=password -d client_id=bff-confidential \
|
||||||
|
-d client_secret=bff-lab-secret -d username=labuser -d password=labpass -d scope=openid)
|
||||||
|
RT=$(echo "$R" | sed -n 's/.*"refresh_token":"\([^"]*\)".*/\1/p')
|
||||||
|
SID=$(echo "$R" | sed -n 's/.*"access_token":"\([^"]*\)".*/\1/p' | cut -d. -f2 \
|
||||||
|
| sed 's/$/==/' | base64 -d 2>/dev/null | sed -n 's/.*"sid":"\([^"]*\)".*/\1/p')
|
||||||
|
echo "SID=$SID"
|
||||||
|
|
||||||
# 3. 동시에 5개 — & 와 wait 이 없으면 재현되지 않는다
|
# 3. 동시에 5개 — & 와 wait 이 없으면 재현되지 않는다(순차로는 경합이 생기지 않는다)
|
||||||
i=1; while [ $i -le 5 ]; do ( curl ... -d refresh_token=$RT > /tmp/c$i ) & i=$((i+1)); done; wait
|
i=1
|
||||||
|
while [ $i -le 5 ]; do
|
||||||
|
( curl -s -o /tmp/c$i -w '%{http_code}\n' -X POST $KC \
|
||||||
|
-d grant_type=refresh_token -d client_id=bff-confidential \
|
||||||
|
-d client_secret=bff-lab-secret -d refresh_token=$RT ) &
|
||||||
|
i=$((i+1))
|
||||||
|
done
|
||||||
|
wait
|
||||||
|
|
||||||
# 4. ★ 이긴 요청의 토큰을 다시 써본다 — 여기서 진짜 답이 나온다
|
# 4. ★ 이긴 요청의 토큰을 다시 써본다 — 여기서 진짜 답이 나온다
|
||||||
curl -s -o /dev/null -w '%{http_code}' -X POST $KC -d grant_type=refresh_token -d refresh_token=$NEW
|
NEW=$(cat /tmp/c1 /tmp/c2 /tmp/c3 /tmp/c4 /tmp/c5 \
|
||||||
|
| sed -n 's/.*"refresh_token":"\([^"]*\)".*/\1/p' | head -1)
|
||||||
|
curl -s -o /dev/null -w '%{http_code}\n' -X POST $KC -d grant_type=refresh_token \
|
||||||
|
-d client_id=bff-confidential -d client_secret=bff-lab-secret -d refresh_token=$NEW
|
||||||
|
|
||||||
# 5. 기제 확인 — client session 이 지워졌는지
|
# 5. 기제 확인 — client session 이 지워졌는지
|
||||||
kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak -c \
|
kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak -c \
|
||||||
"select us.user_session_id,
|
"select us.user_session_id,
|
||||||
(select count(*) from offline_client_session cs
|
(select count(*) from offline_client_session cs
|
||||||
where cs.user_session_id = us.user_session_id) as client_sessions
|
where cs.user_session_id = us.user_session_id) as client_sessions
|
||||||
from offline_user_session us where us.user_session_id = '<sid>'"
|
from offline_user_session us where us.user_session_id = '$SID'"
|
||||||
|
|
||||||
# 6. 정책 비교 — revokeRefreshToken 과 refreshTokenMaxReuse 를 바꿔가며 3~5 반복
|
# 6. 정책 비교 — revokeRefreshToken 과 refreshTokenMaxReuse 를 바꿔가며 3~5 반복
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -213,11 +213,14 @@ Keycloak 의 role 이름은 임의 문자열이므로 **막을 수 있는 것이
|
|||||||
|
|
||||||
| 항목 | 상태 |
|
| 항목 | 상태 |
|
||||||
|---|---|
|
|---|---|
|
||||||
| ③ role 변경 반영 시점 | **미측정.** oauth2-proxy 가 없어 "proxy session" 이 존재하지 않는다 |
|
| ③ role 변경 반영 시점 | **측정 완료 →** [후속 문서 §3](experiment-followup-untested-items.md). IdP 에서 값을 바꿔도 **12회 요청·6초 동안 옛 값**, 세션 삭제 후 재인증에서야 새 값 |
|
||||||
| ⑤ internal token 을 공통 경계로 이동 | **코드 변경.** `backend/` 의 SecurityConfig 에서 `permitAll` 경로를 좁히고 Filter 로 옮기는 작업 |
|
| ⑤ internal token 을 공통 경계로 이동 | **코드 변경.** `backend/` 의 SecurityConfig 에서 `permitAll` 경로를 좁히고 Filter 로 옮기는 작업 |
|
||||||
| edge 에서 동명 헤더 덮어쓰기 | **nginx 설정 변경 필요** — `proxy_set_header X-Auth-Request-Roles ""` 로 먼저 지우고 다시 설정 |
|
| edge 에서 동명 헤더 덮어쓰기 | **nginx 설정 변경 필요** — `proxy_set_header X-Auth-Request-Roles ""` 로 먼저 지우고 다시 설정 |
|
||||||
|
|
||||||
**③ 은 oauth2-proxy 배포가 선행이며, 그것은 B-7 의 주제와 겹친다.**
|
**③ 은 oauth2-proxy 배포가 선행이었고, 그것이 B-7 의 주제와 겹쳤다.**
|
||||||
|
B-7 에서 oauth2-proxy 를 올린 뒤 후속 작업으로 측정했다 — 결론은
|
||||||
|
**"요청 횟수와 무관하다. 세션이 새로 만들어져야 한다"** 이다.
|
||||||
|
세션은 로그인 시점의 스냅샷이고, `--cookie-refresh` 가 없으면 갱신되지 않는다.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -246,8 +246,8 @@ kubectl -n keycloak-lab scale deployment/redis --replicas=0
|
|||||||
curl -s -o /dev/null -w '%{http_code}\n' https://app1.hyeonworks.com/bff/token-boundary # 000
|
curl -s -o /dev/null -w '%{http_code}\n' https://app1.hyeonworks.com/bff/token-boundary # 000
|
||||||
|
|
||||||
# ② 왜 파드가 안 빠지는가 — 그룹별로 본다
|
# ② 왜 파드가 안 빠지는가 — 그룹별로 본다
|
||||||
kubectl -n keycloak-lab exec <bff-pod> -- wget -qO- http://localhost:8083/actuator/health
|
kubectl -n keycloak-lab exec $(kubectl -n keycloak-lab get pod -l app=bff --field-selector=status.phase=Running -o jsonpath='{.items[0].metadata.name}') -- wget -qO- http://localhost:8083/actuator/health
|
||||||
kubectl -n keycloak-lab exec <bff-pod> -- wget -qO- http://localhost:8083/actuator/health/readiness
|
kubectl -n keycloak-lab exec $(kubectl -n keycloak-lab get pod -l app=bff --field-selector=status.phase=Running -o jsonpath='{.items[0].metadata.name}') -- wget -qO- http://localhost:8083/actuator/health/readiness
|
||||||
kubectl -n keycloak-lab get endpoints bff -o jsonpath='{.subsets[*].addresses[*].ip}'
|
kubectl -n keycloak-lab get endpoints bff -o jsonpath='{.subsets[*].addresses[*].ip}'
|
||||||
|
|
||||||
# ③ 복구
|
# ③ 복구
|
||||||
|
|||||||
@@ -237,7 +237,7 @@ curl -s -o /dev/null -w '%{http_code}\n' -H "Authorization: Bearer $OLD" http://
|
|||||||
kcadm.sh get components -r keycloak-patterns --fields id,name,providerId
|
kcadm.sh get components -r keycloak-patterns --fields id,name,providerId
|
||||||
|
|
||||||
# 5. 제거하고 다시 확인 — 여기서 401 이 나와야 정상이다
|
# 5. 제거하고 다시 확인 — 여기서 401 이 나와야 정상이다
|
||||||
kcadm.sh delete components/<old-id> -r keycloak-patterns
|
kcadm.sh delete components/$(kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh get components -r keycloak-patterns --fields id,name,providerId | grep -B2 '"name" : "rsa-generated"' | grep '"id"' | cut -d'"' -f4) -r keycloak-patterns
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -12,6 +12,15 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 구조
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
> 다이어그램 규약은 [`diagrams/_style.md`](diagrams/_style.md).
|
||||||
|
> 실험대 전체 구조는 [`diagrams/lab-topology.svg`](diagrams/lab-topology.svg).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 0. 결론부터
|
## 0. 결론부터
|
||||||
|
|
||||||
| 물음 | 답 |
|
| 물음 | 답 |
|
||||||
@@ -172,7 +181,7 @@ kubectl -n keycloak-lab patch deployment oauth2-proxy --type=json \
|
|||||||
|---|---|
|
|---|---|
|
||||||
| 옛 쿠키 | **검증 실패** — `session ticket cookie failed validation` |
|
| 옛 쿠키 | **검증 실패** — `session ticket cookie failed validation` |
|
||||||
| 사용자 경험 | **Keycloak SSO 가 살아 있어 조용히 재로그인**됐다. 로그인 화면을 안 봤다 |
|
| 사용자 경험 | **Keycloak SSO 가 살아 있어 조용히 재로그인**됐다. 로그인 화면을 안 봤다 |
|
||||||
| **서버 쪽 세션** | **★ 지우지 못했다** |
|
| **서버 쪽 세션** | **★ 지우지 못했다** → **B-7a 에서 이어받았다** |
|
||||||
|
|
||||||
### 고아 세션이 남는다
|
### 고아 세션이 남는다
|
||||||
|
|
||||||
@@ -196,6 +205,13 @@ _oauth2_proxy-b26111fbd1fdab3ae2182e287001b02a ← ★ 옛 세션. 남아 있
|
|||||||
|
|
||||||
**로그인한 사용자 수만큼 고아가 생긴다.** TTL(여기서는 1시간)이 지나야 사라진다.
|
**로그인한 사용자 수만큼 고아가 생긴다.** TTL(여기서는 1시간)이 지나야 사라진다.
|
||||||
|
|
||||||
|
> **★ 이어짐 (B-7a)** — 여기서 멈춘 세 물음을 [B-7a](experiment-b7a-orphan-session.md) 가 잰다.
|
||||||
|
> **「지울 수 없다」는 oauth2-proxy 의 한계이지 Redis 의 한계가 아니었다.**
|
||||||
|
> · TTL 은 요청으로 갱신되지 않아 고아는 **생성 후 정확히 1시간**에 사라진다
|
||||||
|
> · `redis-cli del` 로 지워도 산 세션은 `200` — 운영자는 지울 수 있다
|
||||||
|
> · 다만 **Redis 값으로는 고아를 못 고른다.** 이름·타입·크기(3510바이트)가 같고 값은 암호화됨
|
||||||
|
> · **TTL 로 생성 시각을 역산**하면 1초 오차로 골라낼 수 있다 — 회전 시각 이전 생성분이 전부 고아다
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 5. Q1 미지수 7 에 대한 답
|
## 5. Q1 미지수 7 에 대한 답
|
||||||
|
|||||||
@@ -0,0 +1,301 @@
|
|||||||
|
# B-7a — 고아 세션은 지울 수 있는가
|
||||||
|
|
||||||
|
브랜치 `feature/keycloak-b7a-orphan-session` ·
|
||||||
|
증거 [`docs/evidence/b7a-orphan-session/`](evidence/b7a-orphan-session/) ·
|
||||||
|
2026-09-04 20:29–20:34 KST
|
||||||
|
|
||||||
|
B-7 이 **「★ 지우지 못했다」** 로 남긴 자리를 잰다.
|
||||||
|
결론부터 — **oauth2-proxy 가 못 지우는 것이지, 지울 수 없는 것이 아니다.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 구조
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
> 다이어그램 규약은 [`diagrams/_style.md`](diagrams/_style.md).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0. 결론부터
|
||||||
|
|
||||||
|
| 물음 | 답 |
|
||||||
|
|---|---|
|
||||||
|
| 고아는 정말 사라지는가 | **사라진다.** 생성 후 정확히 1시간. TTL 이 갱신되지 않는다 |
|
||||||
|
| 운영자가 지울 수 있는가 | **지울 수 있다.** `redis-cli del` 로 지워도 산 세션은 `200` |
|
||||||
|
| **어느 것이 고아인지 아는가** | **Redis 값으로는 모른다.** 이름·타입·크기가 같고 값은 암호화됨 |
|
||||||
|
| **그럼 어떻게 고르는가** | **★ TTL 로 생성 시각을 역산한다.** 1초 오차로 맞는다 |
|
||||||
|
| 회전할 때마다 누적하는가 | **누적한다.** 회전 1회 = 그 시점 로그인 사용자 수 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. B-7 이 어디서 멈췄나
|
||||||
|
|
||||||
|
```
|
||||||
|
[stored_session.go:97] Error removing session:
|
||||||
|
error decoding ticket to clear session: session ticket cookie failed validation
|
||||||
|
```
|
||||||
|
|
||||||
|
**티켓을 못 푸니 Redis 키를 계산할 수 없고, 그래서 지울 수도 없다.**
|
||||||
|
|
||||||
|
### 개념 — 티켓과 키의 관계
|
||||||
|
|
||||||
|
**무엇인가.** Redis 세션 저장소를 쓰면 쿠키에는 세션 전체가 아니라
|
||||||
|
**티켓(ticket)** 만 담긴다. 티켓은 두 부분이다.
|
||||||
|
|
||||||
|
```
|
||||||
|
티켓 = <세션 ID>.<암호화 키>
|
||||||
|
│ └─ 값을 복호화할 키
|
||||||
|
└─ Redis 키 이름을 만든다 → _oauth2_proxy-<ID>
|
||||||
|
```
|
||||||
|
|
||||||
|
**왜 여기 나오나.** 티켓 전체가 cookie secret 으로 서명·암호화되어 있다.
|
||||||
|
secret 을 바꾸면 **티켓을 열 수 없고, 그러면 세션 ID 조차 못 읽는다.**
|
||||||
|
값을 못 읽는 게 아니라 **어느 키를 지워야 하는지를 모른다.**
|
||||||
|
|
||||||
|
**없거나 틀리면.** 정확히 지금 상황이다 — 프록시는 "이 세션은 못 쓴다"까지는
|
||||||
|
알지만 "그 세션이 Redis 어디에 있다"를 모른다. 그래서 `removing session` 을
|
||||||
|
시도하고 실패한다.
|
||||||
|
|
||||||
|
**확인.**
|
||||||
|
```bash
|
||||||
|
kubectl -n keycloak-lab logs -l app=oauth2-proxy --since=2m | grep stored_session
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 재현 — 회전이 고아를 만드는 순간
|
||||||
|
|
||||||
|
### 기준선 (11:29:42 UTC)
|
||||||
|
|
||||||
|
```
|
||||||
|
secret = COOKIE_SECRET_A
|
||||||
|
_oauth2_proxy-f6a9201fd534a047998278452001ccbf
|
||||||
|
type=string ttl=3568초 크기=3510바이트
|
||||||
|
dbsize=1
|
||||||
|
설정: --cookie-expire=1h --session-store-type=redis
|
||||||
|
```
|
||||||
|
|
||||||
|
### 주입 — 1차 회전 A → B (11:29:56 UTC)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
kubectl -n keycloak-lab patch deployment oauth2-proxy --type=json \
|
||||||
|
-p '[{"op":"replace",
|
||||||
|
"path":"/spec/template/spec/containers/0/env/1/valueFrom/secretKeyRef/key",
|
||||||
|
"value":"COOKIE_SECRET_B"}]'
|
||||||
|
```
|
||||||
|
|
||||||
|
**회전 직후 Redis 는 그대로 1개다.** 회전 자체는 아무 일도 일으키지 않는다 —
|
||||||
|
**누군가 옛 쿠키를 들고 오는 순간**에 비로소 벌어진다.
|
||||||
|
|
||||||
|
브라우저가 접근한 11:30:27:
|
||||||
|
|
||||||
|
```
|
||||||
|
[stored_session.go:94] Error loading cookied session: … removing session
|
||||||
|
[stored_session.go:97] Error removing session: error decoding ticket to clear session
|
||||||
|
[oauthproxy.go:1024] No valid authentication in request. Initiating login.
|
||||||
|
[AuthSuccess] Authenticated via OAuth2: Session{email:labuser@example.com …}
|
||||||
|
```
|
||||||
|
|
||||||
|
```
|
||||||
|
_oauth2_proxy-87faa1c94db3bd72c11c4e100c3ca593 ttl=3588 ← 새 세션
|
||||||
|
_oauth2_proxy-f6a9201fd534a047998278452001ccbf ttl=3511 ← ★ 고아
|
||||||
|
```
|
||||||
|
|
||||||
|
Keycloak SSO 가 살아 있어 **로그인 화면 없이** 통과했다 — B-7 의 관찰 그대로다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. ★ Redis 만 보고는 구분할 수 없다
|
||||||
|
|
||||||
|
| | 새 세션 | 고아 |
|
||||||
|
|---|---|---|
|
||||||
|
| 이름 | `_oauth2_proxy-87faa1c9…` | `_oauth2_proxy-f6a9201f…` |
|
||||||
|
| type | `string` | `string` |
|
||||||
|
| **크기** | **3510바이트** | **3510바이트** |
|
||||||
|
| 값 | `\xcb\xb3h\xfa\x98\xedc\xe4@<…` | `N\xf5\x0e=\xe1N\xfc|\xa2qE\xde…` |
|
||||||
|
|
||||||
|
**바이트 단위로 크기가 같다.** 이름 뒤쪽은 불투명한 32자 hex 이고 사용자도
|
||||||
|
시각도 상태도 담지 않는다. 값은 암호화되어 있어 뜻을 읽을 수 없다.
|
||||||
|
|
||||||
|
> **다른 것은 TTL 하나뿐이다.** 이것이 4절의 열쇠가 된다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. TTL 은 정직하고, 갱신되지 않는다
|
||||||
|
|
||||||
|
30초 간격 3회:
|
||||||
|
|
||||||
|
| | 새 세션 | 고아 |
|
||||||
|
|---|---|---|
|
||||||
|
| t+00초 | 3557 | 3479 |
|
||||||
|
| t+30초 | 3526 | 3448 |
|
||||||
|
| t+60초 | 3494 | 3417 |
|
||||||
|
|
||||||
|
1초에 1초씩. **고아는 생성 후 정확히 1시간에 사라진다.**
|
||||||
|
|
||||||
|
요청을 두 번 보낸 뒤에도 산 세션의 TTL 은 `3464` 로 계속 줄었다.
|
||||||
|
기동 로그의 `refresh:disabled` 와 일치한다 — `--cookie-refresh` 가 없다.
|
||||||
|
|
||||||
|
### 개념 — TTL 갱신 여부가 왜 중요한가
|
||||||
|
|
||||||
|
**무엇인가.** `--cookie-refresh` 를 켜면 요청마다 세션이 갱신되고 TTL 이
|
||||||
|
연장된다. 끄면 **생성 시점부터 고정된 시간이 흐른다.**
|
||||||
|
|
||||||
|
**왜 여기 나오나.** TTL 이 고정이면 **TTL 은 생성 시각의 정확한 함수**다.
|
||||||
|
|
||||||
|
```
|
||||||
|
생성시각 = 지금 - (cookie-expire - TTL)
|
||||||
|
```
|
||||||
|
|
||||||
|
이 한 줄이 5절의 정리 규칙 전체를 만든다.
|
||||||
|
|
||||||
|
**없거나 틀리면.** `--cookie-refresh` 를 켜는 순간 이 역산이 무너진다.
|
||||||
|
그때는 고아를 골라낼 수단이 사라지고, 회전 후 `FLUSHDB` 로 전부 지워
|
||||||
|
모두 재인증시키는 편이 오히려 정직하다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 운영자는 지울 수 있다
|
||||||
|
|
||||||
|
```bash
|
||||||
|
redis-cli del _oauth2_proxy-f6a9201f… # 반환 1
|
||||||
|
```
|
||||||
|
|
||||||
|
```
|
||||||
|
dbsize 2 → 1
|
||||||
|
남은 키: _oauth2_proxy-87faa1c9…
|
||||||
|
```
|
||||||
|
|
||||||
|
삭제 직후 브라우저 요청:
|
||||||
|
|
||||||
|
```
|
||||||
|
app2.hyeonworks.com GET - "/oauth2/userinfo" … labuser@example.com 200 108
|
||||||
|
```
|
||||||
|
|
||||||
|
**200. 산 세션은 다치지 않는다.**
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
> **"지울 수 없다"는 oauth2-proxy 의 한계이지 Redis 의 한계가 아니었다.**
|
||||||
|
> 프록시는 티켓을 못 풀어 키를 계산 못 한다. 운영자는 키를 직접 안다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 누적한다 — 회전할 때마다
|
||||||
|
|
||||||
|
2차 회전 B → A (11:33:27) 후 브라우저 재접근:
|
||||||
|
|
||||||
|
| 키 | TTL | 생성시각(역산) | 판정 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `_oauth2_proxy-dad9c9fb…` | 3581 | 11:33:54 | 살아있음 |
|
||||||
|
| `_oauth2_proxy-87faa1c9…` | 3373 | 11:30:26 | **★ 고아** |
|
||||||
|
|
||||||
|
**1차 회전을 살아남았던 세션이 2차 회전에서 고아가 됐다.**
|
||||||
|
회전 1회 = 그 시점 로그인 사용자 수만큼의 고아.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. ★ 그래서 정리 규칙이 유도된다
|
||||||
|
|
||||||
|
TTL 이 갱신되지 않으므로(4절), 생성 시각을 역산할 수 있다.
|
||||||
|
그 값이 **회전 시각보다 이르면 고아다** — 회전 이후에 만들어진 세션은
|
||||||
|
새 secret 으로 만들어졌으므로 반드시 유효하기 때문이다.
|
||||||
|
|
||||||
|
**정확도 검증** — 역산 `11:30:26` 대 로그의 `AuthSuccess 11:30:27`.
|
||||||
|
**1초 오차.** 추정이 아니라 사실상 정확하다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
NOW=$(date -u +%s)
|
||||||
|
ROT=<회전 시각 epoch> # date -u -d '2026-09-04 11:33:27' +%s
|
||||||
|
EXP=3600 # --cookie-expire 를 초로
|
||||||
|
|
||||||
|
kubectl -n keycloak-lab exec deploy/redis -- \
|
||||||
|
redis-cli --scan --pattern '_oauth2_proxy-*' | while read K; do
|
||||||
|
T=$(kubectl -n keycloak-lab exec deploy/redis -- redis-cli ttl "$K")
|
||||||
|
C=$(( NOW - (EXP - T) ))
|
||||||
|
[ $C -lt $ROT ] && kubectl -n keycloak-lab exec deploy/redis -- redis-cli del "$K"
|
||||||
|
done
|
||||||
|
```
|
||||||
|
|
||||||
|
실제 실행: `삭제: _oauth2_proxy-87faa1c9…` · 남은 `dbsize=1`.
|
||||||
|
**산 세션은 남고 고아만 사라졌다.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Q1 미지수 7 에 남기는 보완
|
||||||
|
|
||||||
|
B-7 은 **"고아가 남는다"** 까지 답했다. B-7a 가 덧붙이는 것:
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|---|---|
|
||||||
|
| 얼마나 남는가 | **1시간.** TTL 이 갱신되지 않으므로 무한정 쌓이지 않는다 |
|
||||||
|
| 지울 수 있는가 | **있다.** 프록시가 못 할 뿐이다 |
|
||||||
|
| 어떻게 고르는가 | **TTL 역산.** 회전 시각 이전 생성분이 전부 고아다 |
|
||||||
|
| 전제 | **`--cookie-refresh` 를 켜면 이 규칙이 깨진다.** 그때는 `FLUSHDB` 가 정직하다 |
|
||||||
|
|
||||||
|
> **secret 회전의 진짜 비용은 "재로그인"이 아니라 "저장소에 남는 것"이다.**
|
||||||
|
> 그리고 그 비용은 **저장소를 쿠키로 쓰면 0** 이다 — 지울 서버 상태가
|
||||||
|
> 애초에 없기 때문이다. B-7 이 Redis 로 옮긴 대가가 여기서 청구된다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 재현 절차 (명령어)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# ── 0. app2 를 oauth2-proxy 로 잠시 빌린다 (Grafana 를 되돌릴 것)
|
||||||
|
kubectl -n observability get ingress grafana -o yaml > /tmp/grafana-ingress-backup.yaml
|
||||||
|
kubectl -n observability delete ingress grafana
|
||||||
|
kubectl apply -f deploy/lab/k8s/b7-oauth2-proxy.yaml # Ingress 포함
|
||||||
|
|
||||||
|
# ── 1. 로그인해서 세션을 만든다 (브라우저 필요 — 쿠키가 HttpOnly 다)
|
||||||
|
# https://app2.hyeonworks.com/ → labuser / labpass
|
||||||
|
|
||||||
|
# ── 2. 기준선
|
||||||
|
R() { kubectl -n keycloak-lab exec deploy/redis -- redis-cli "$@"; }
|
||||||
|
R --scan --pattern '_oauth2_proxy-*' | while read K; do
|
||||||
|
echo "$K type=$(R type $K) ttl=$(R ttl $K) len=$(R strlen $K)"
|
||||||
|
done
|
||||||
|
|
||||||
|
# ── 3. 회전. ★ 회전 시각을 반드시 기록한다 — 7절 규칙이 이걸 쓴다
|
||||||
|
ROT=$(date -u +%s); echo "회전 $ROT ($(date -u -d @$ROT +%H:%M:%S))"
|
||||||
|
kubectl -n keycloak-lab patch deployment oauth2-proxy --type=json \
|
||||||
|
-p '[{"op":"replace",
|
||||||
|
"path":"/spec/template/spec/containers/0/env/1/valueFrom/secretKeyRef/key",
|
||||||
|
"value":"COOKIE_SECRET_B"}]'
|
||||||
|
kubectl -n keycloak-lab rollout status deployment/oauth2-proxy --timeout=180s
|
||||||
|
|
||||||
|
# ── 4. 브라우저로 다시 접근 → 이때 고아가 생긴다
|
||||||
|
# 로그에서 확인:
|
||||||
|
kubectl -n keycloak-lab logs -l app=oauth2-proxy --since=2m | grep stored_session
|
||||||
|
|
||||||
|
# ── 5. TTL 이 갱신되지 않는지 확인 (요청을 보낸 뒤에도 줄어야 한다)
|
||||||
|
for i in 1 2 3; do R --scan --pattern '_oauth2_proxy-*' | while read K; do
|
||||||
|
printf "%s ttl=%s\n" "$K" "$(R ttl $K)"; done; sleep 30; done
|
||||||
|
|
||||||
|
# ── 6. 정리 — 회전 시각 이전 생성분이 고아다
|
||||||
|
NOW=$(date -u +%s); EXP=3600
|
||||||
|
R --scan --pattern '_oauth2_proxy-*' | while read K; do
|
||||||
|
T=$(R ttl "$K"); C=$(( NOW - (EXP - T) ))
|
||||||
|
if [ $C -lt $ROT ]; then echo "삭제 $K (생성 $(date -u -d @$C +%H:%M:%S))"; R del "$K"; fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# ── 7. 복구
|
||||||
|
kubectl -n keycloak-lab delete ingress oauth2-proxy
|
||||||
|
kubectl apply -f /tmp/grafana-ingress-backup.yaml
|
||||||
|
kubectl -n keycloak-lab patch deployment oauth2-proxy --type=json \
|
||||||
|
-p '[{"op":"replace",
|
||||||
|
"path":"/spec/template/spec/containers/0/env/1/valueFrom/secretKeyRef/key",
|
||||||
|
"value":"COOKIE_SECRET_A"}]'
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 증거 파일
|
||||||
|
|
||||||
|
| 파일 | 종류 | 무엇을 보여주는가 |
|
||||||
|
|---|---|---|
|
||||||
|
| [`01-orphan-lifecycle.txt`](evidence/b7a-orphan-session/01-orphan-lifecycle.txt) | 터미널 | 회전 2회 · TTL 추이 · 구분 불가 근거 · 삭제 후 `200` · 정리 규칙 검증 |
|
||||||
|
| [`b7a-live-session-after-orphan-delete.png`](evidence/b7a-orphan-session/b7a-live-session-after-orphan-delete.png) | 스크린샷 | 고아 삭제 직후 살아있는 세션의 응답 |
|
||||||
|
|
||||||
|
파일별 상세는 [`evidence/b7a-orphan-session/README.md`](evidence/b7a-orphan-session/README.md).
|
||||||
@@ -132,7 +132,7 @@ https://auth.hyeonworks.com/realms/keycloak-patterns/protocol/openid-connect/aut
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
|
kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
|
||||||
create users/<user-id>/logout -r keycloak-patterns
|
create users/$(kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh get users -r keycloak-patterns -q username=labuser --fields id --format csv --noquotes | tail -1)/logout -r keycloak-patterns
|
||||||
```
|
```
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -259,7 +259,7 @@ kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak -c
|
|||||||
|
|
||||||
# 4. IdP 세션만 죽인다
|
# 4. IdP 세션만 죽인다
|
||||||
kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
|
kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
|
||||||
create users/<user-id>/logout -r keycloak-patterns
|
create users/$(kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh get users -r keycloak-patterns -q username=labuser --fields id --format csv --noquotes | tail -1)/logout -r keycloak-patterns
|
||||||
|
|
||||||
# 5. 두 앱을 다시 연다 — 그대로 열리면 앱 세션이 독립적이라는 뜻
|
# 5. 두 앱을 다시 연다 — 그대로 열리면 앱 세션이 독립적이라는 뜻
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -8,6 +8,15 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 구조
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
> 다이어그램 규약은 [`diagrams/_style.md`](diagrams/_style.md).
|
||||||
|
> 실험대 전체 구조는 [`diagrams/lab-topology.svg`](diagrams/lab-topology.svg).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 0. 결론부터
|
## 0. 결론부터
|
||||||
|
|
||||||
| 확인 | 결과 |
|
| 확인 | 결과 |
|
||||||
@@ -27,7 +36,7 @@
|
|||||||
### IdP 쪽
|
### IdP 쪽
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
kcadm.sh get clients -r keycloak-patterns -q clientId=bff-confidential --fields attributes
|
kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh get clients -r keycloak-patterns -q clientId=bff-confidential --fields attributes
|
||||||
```
|
```
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -68,7 +77,7 @@ Spring Security 6.2+ 는 백채널 로그아웃을 지원하지만 **명시적
|
|||||||
## 2. IdP 쪽만 설정하고 시험했다
|
## 2. IdP 쪽만 설정하고 시험했다
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
kcadm.sh update clients/<id> -r keycloak-patterns \
|
kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh update clients/$(kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh get clients -r keycloak-patterns -q clientId=bff --fields id --format csv --noquotes | tail -1) -r keycloak-patterns \
|
||||||
-s 'attributes={"backchannel.logout.url":"https://app1.hyeonworks.com/logout/connect/back-channel/keycloak",
|
-s 'attributes={"backchannel.logout.url":"https://app1.hyeonworks.com/logout/connect/back-channel/keycloak",
|
||||||
"backchannel.logout.session.required":"true"}'
|
"backchannel.logout.session.required":"true"}'
|
||||||
```
|
```
|
||||||
@@ -94,7 +103,7 @@ kcadm.sh update clients/<id> -r keycloak-patterns \
|
|||||||
Redis: 1 키
|
Redis: 1 키
|
||||||
|
|
||||||
=== IdP 로그아웃 ===
|
=== IdP 로그아웃 ===
|
||||||
kcadm.sh create users/<id>/logout -r keycloak-patterns
|
kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh create users/$(kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh get users -r keycloak-patterns -q username=labuser --fields id --format csv --noquotes | tail -1)/logout -r keycloak-patterns
|
||||||
|
|
||||||
=== 결과 ===
|
=== 결과 ===
|
||||||
keycloak-patterns 세션: 0 ← IdP 쪽은 끊겼다
|
keycloak-patterns 세션: 0 ← IdP 쪽은 끊겼다
|
||||||
@@ -227,12 +236,12 @@ app2(oauth2-proxy)는 못 한다. **한 SSO 안에서 로그아웃 전파가 앱
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 1. 현재 설정 확인 — 어느 쪽에도 없다
|
# 1. 현재 설정 확인 — 어느 쪽에도 없다
|
||||||
kcadm.sh get clients -r keycloak-patterns -q clientId=bff-confidential --fields attributes
|
kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh get clients -r keycloak-patterns -q clientId=bff-confidential --fields attributes
|
||||||
grep -rn "oidcLogout\|backchannel" bff/src/main/java/
|
grep -rn "oidcLogout\|backchannel" bff/src/main/java/
|
||||||
curl -s -o /dev/null -w '%{http_code}\n' -X POST https://app1.hyeonworks.com/logout/connect/back-channel/keycloak
|
curl -s -o /dev/null -w '%{http_code}\n' -X POST https://app1.hyeonworks.com/logout/connect/back-channel/keycloak
|
||||||
|
|
||||||
# 2. IdP 쪽 설정 — 점 표기는 안 먹는다. JSON 으로
|
# 2. IdP 쪽 설정 — 점 표기는 안 먹는다. JSON 으로
|
||||||
kcadm.sh update clients/<id> -r keycloak-patterns \
|
kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh update clients/$(kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh get clients -r keycloak-patterns -q clientId=bff --fields id --format csv --noquotes | tail -1) -r keycloak-patterns \
|
||||||
-s 'attributes={"backchannel.logout.url":"...","backchannel.logout.session.required":"true"}'
|
-s 'attributes={"backchannel.logout.url":"...","backchannel.logout.session.required":"true"}'
|
||||||
|
|
||||||
# 3. ★ 살아 있는 세션이 있는지 먼저 확인한다 (realm 을 join 해서)
|
# 3. ★ 살아 있는 세션이 있는지 먼저 확인한다 (realm 을 join 해서)
|
||||||
@@ -241,7 +250,7 @@ kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak -tA
|
|||||||
where r.name='keycloak-patterns' and us.offline_flag='0'"
|
where r.name='keycloak-patterns' and us.offline_flag='0'"
|
||||||
|
|
||||||
# 4. 로그아웃하고 앱 세션을 본다
|
# 4. 로그아웃하고 앱 세션을 본다
|
||||||
kcadm.sh create users/<user-id>/logout -r keycloak-patterns
|
kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh create users/$(kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh get users -r keycloak-patterns -q username=labuser --fields id --format csv --noquotes | tail -1)/logout -r keycloak-patterns
|
||||||
kubectl -n keycloak-lab exec deploy/redis -- redis-cli dbsize
|
kubectl -n keycloak-lab exec deploy/redis -- redis-cli dbsize
|
||||||
|
|
||||||
# 5. 도달성 확인 — 클러스터 안에서 앱 공개 URL 로
|
# 5. 도달성 확인 — 클러스터 안에서 앱 공개 URL 로
|
||||||
|
|||||||
@@ -23,6 +23,15 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 구조
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
> 다이어그램 규약은 [`diagrams/_style.md`](diagrams/_style.md).
|
||||||
|
> 실험대 전체 구조는 [`diagrams/lab-topology.svg`](diagrams/lab-topology.svg).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 0. 결론부터
|
## 0. 결론부터
|
||||||
|
|
||||||
| 확인 | 결과 |
|
| 확인 | 결과 |
|
||||||
|
|||||||
@@ -114,7 +114,7 @@ ssh kc-lab-1 'sudo grep -c "lab-postgres-change-me" /var/lib/rancher/k3s/server/
|
|||||||
## 3. 파드 안에서는 환경변수다
|
## 3. 파드 안에서는 환경변수다
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
kubectl -n keycloak-lab exec <bff-pod> -- sh -c 'env | grep -iE "secret|password"'
|
kubectl -n keycloak-lab exec $(kubectl -n keycloak-lab get pod -l app=bff --field-selector=status.phase=Running -o jsonpath='{.items[0].metadata.name}') -- sh -c 'env | grep -iE "secret|password"'
|
||||||
```
|
```
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -223,7 +223,7 @@ ssh kc-lab-1 'sudo k3s secrets-encrypt status'
|
|||||||
ssh kc-lab-1 'sudo grep -c "lab-postgres-change-me" /var/lib/rancher/k3s/server/db/state.db'
|
ssh kc-lab-1 'sudo grep -c "lab-postgres-change-me" /var/lib/rancher/k3s/server/db/state.db'
|
||||||
|
|
||||||
# 5. 파드 안에서는 환경변수
|
# 5. 파드 안에서는 환경변수
|
||||||
kubectl -n keycloak-lab exec <pod> -- sh -c 'env | grep -i secret'
|
kubectl -n keycloak-lab exec keycloak-0 -- sh -c 'env | grep -i secret'
|
||||||
|
|
||||||
# 6. 누가 읽을 수 있는가
|
# 6. 누가 읽을 수 있는가
|
||||||
kubectl auth can-i get secrets -n keycloak-lab \
|
kubectl auth can-i get secrets -n keycloak-lab \
|
||||||
|
|||||||
@@ -10,6 +10,10 @@
|
|||||||
|
|
||||||

|

|
||||||
|
|
||||||
|
**핵심 발견** — 갱신은 성공했는데 **38분 25초** 동안 반영되지 않았다.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
> 다이어그램 규약은 [`diagrams/_style.md`](diagrams/_style.md).
|
> 다이어그램 규약은 [`diagrams/_style.md`](diagrams/_style.md).
|
||||||
> 실험대 전체 구조는 [`diagrams/lab-topology.svg`](diagrams/lab-topology.svg).
|
> 실험대 전체 구조는 [`diagrams/lab-topology.svg`](diagrams/lab-topology.svg).
|
||||||
|
|
||||||
@@ -21,12 +25,19 @@
|
|||||||
|---|---|
|
|---|---|
|
||||||
| 인증서 구성 | **SAN 3개** (`auth`/`app1`/`app2`), 와일드카드 아님 |
|
| 인증서 구성 | **SAN 3개** (`auth`/`app1`/`app2`), 와일드카드 아님 |
|
||||||
| 체인 완결성 | **정상.** `Verify return code: 0 (ok)`, 4단계 |
|
| 체인 완결성 | **정상.** `Verify return code: 0 (ok)`, 4단계 |
|
||||||
| 갱신 자동화 | **동작 중.** `certbot-renew.timer` enabled·active, 11시간 전 실행됨 |
|
| 갱신 자동화 | 타이머는 **동작한다.** 오늘 두 번 `status=0/SUCCESS` |
|
||||||
| 남은 기간 | **88일** (갱신까지 약 58일) |
|
| **★ 갱신 반영** | **안 된다.** 새 인증서가 디스크에 있는 채로 **38분 25초** 동안 옛 인증서를 서빙했다 |
|
||||||
| **강제 갱신 실측** | **★ 못 했다.** `sudo: a password is required` |
|
| **★ reload 무중단** | **무중단이다.** 8856건 전부 200 · 전송 한가운데였던 42초 요청이 845361바이트 전량 수신 |
|
||||||
|
|
||||||
**측정한 것과 못 한 것을 나눠 적는다.** 못 한 것을 안 한 것처럼 쓰면
|
### 한 문장으로
|
||||||
이 기록 전체의 신뢰가 깎인다.
|
|
||||||
|
> **"갱신 성공"과 "새 인증서 서빙"은 다른 사건이다.**
|
||||||
|
> 그리고 이 실험대에는 앞의 사건을 뒤의 사건으로 잇는 것이 **아무것도 없다.**
|
||||||
|
|
||||||
|
이게 왜 무서운가 — 이 결함은 **88일 동안 보이지 않는다.** 타이머는 정상이고
|
||||||
|
매번 `SUCCESS` 로 끝난다. 만료 30일 전까지는 certbot 이 갱신 자체를 하지 않아
|
||||||
|
"반영이 안 된다"는 사실이 발현할 기회가 없기 때문이다. 발현하는 날의 증상은
|
||||||
|
**인증서 만료**이고, 그날에도 로그에는 `SUCCESS` 라고 적혀 있을 것이다.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -112,87 +123,269 @@ certbot 은 **30일 남았을 때** 갱신한다. 지금 실행돼도 아무것
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 4. ★ 못 한 것 — 강제 갱신과 무중단 확인
|
## 4. ★ 강제 갱신 — 두 개의 답
|
||||||
|
|
||||||
계획서의 D-4 는 이렇게 적혀 있었다.
|
### 4-1. 먼저 대조군을 잡았다
|
||||||
|
|
||||||
|
주입 전에 평시를 재둔다. **이걸 건너뛰면 주입 결과를 해석할 수 없다** —
|
||||||
|
A-6 에서 −41% 대조군을 "영향 없음"이라 적었던 것도, A-8 에서 표본 9개로
|
||||||
|
무중단을 주장한 것도 같은 종류의 실수였다.
|
||||||
|
|
||||||
|
| 대조군 | 결과 |
|
||||||
|
|---|---|
|
||||||
|
| 새 연결 (0.2초 × 900회 / 180초) | **900 전부 200, 오류 0** · 중앙 98ms · p95 195ms |
|
||||||
|
| 진행 중 요청 (845KB @ 20k/s) | 200 · 845361바이트 · 연결수 1 · **42.3초 완주** |
|
||||||
|
|
||||||
|
두 번째 장치가 왜 따로 필요했는가 — 첫 번째 폴링은 **TLS 핸드셰이크가
|
||||||
|
900/900** 이다. 매 요청이 새 연결이라는 뜻이고, 그래서 "새 연결을 받아주는가"만
|
||||||
|
잰다. 계획서가 물은 것은 **"진행 중이던 요청은 어떻게 되는가"** 이므로,
|
||||||
|
reload 순간에 실제로 전송 중인 요청이 있어야 한다. 845KB 짜리 관리 콘솔
|
||||||
|
번들을 일부러 느리게 받아 요청 하나를 42초 동안 살려 두었다.
|
||||||
|
|
||||||
|
### 4-2. 갱신은 성공했는데, 인증서가 바뀌지 않았다
|
||||||
|
|
||||||
|
`sudo certbot renew --force-renewal` 을 실행한 뒤에도 밖에서 본 인증서가
|
||||||
|
그대로였다. 처음에는 갱신이 실패한 줄 알았다. 아니었다.
|
||||||
|
|
||||||
|
```
|
||||||
|
디스크 (/etc/letsencrypt/archive/auth.hyeonworks.com/)
|
||||||
|
cert1.pem 2026-09-03 10:47:40 KST 처음 발급
|
||||||
|
cert2.pem 2026-09-04 17:22:13 KST ← 강제 갱신, 새로 써졌다
|
||||||
|
|
||||||
|
네트워크 (5초 간격 564표본)
|
||||||
|
08:10:51 ~ 08:58:47 serial=0520BB…B853 notAfter=Dec 2 ← 옛 것
|
||||||
|
08:58:52 serial=06C7CB…EA1D notAfter=Dec 3 ← 바뀐 순간
|
||||||
|
```
|
||||||
|
|
||||||
|
| | 시각 (UTC) |
|
||||||
|
|---|---|
|
||||||
|
| 새 인증서 디스크 기록 | 08:22:13 |
|
||||||
|
| 실제 서빙 시작 (`nginx -s reload`) | 08:58:52 |
|
||||||
|
| **공백** | **2305초 = 38분 25초** (시계 보정 후 — 아래 참조) |
|
||||||
|
|
||||||
|
> **★ 시계 보정 (D-4a 에서 발견)** — 처음 적은 **2199초(36분 39초)** 는
|
||||||
|
> `archive/` 파일 시각(test-server 시계)과 일련번호 관측(dev 시계)을 **그대로 뺀**
|
||||||
|
> 값이었다. 외부 기준(Google · Let's Encrypt `Date` 헤더)으로 재보니
|
||||||
|
> **dev 가 정확하고 test-server 가 106초 빠르다**(3회 측정 전부 +106.1초,
|
||||||
|
> `NTPSynchronized=no`). 보정하면 **2305초 = 38분 25초**다.
|
||||||
|
> 보정값은 D-4a 새 인증서의 SCT(`Sep 4 12:27:49.054 GMT`, CT 로그가 서명한
|
||||||
|
> 독립 시계)가 보정한 훅 시각의 1초 앞에 놓이는 것으로 교차 검증된다.
|
||||||
|
|
||||||
|
그 사이 **428번** 옛 인증서로 관측됐다. 그리고 이 36분은 **우연히 짧았을
|
||||||
|
뿐이다** — reload 를 시킨 것은 사람이지 자동화가 아니다. 아무도 안 했다면
|
||||||
|
다음 nginx 재시작까지, 즉 무기한 옛 인증서를 서빙했을 것이다.
|
||||||
|
|
||||||
|
#### 개념 — 왜 파일이 바뀌어도 nginx 는 모르는가
|
||||||
|
|
||||||
|
**무엇인가.** nginx 는 `ssl_certificate` 가 가리키는 파일을 **기동 시점에
|
||||||
|
한 번 읽어 메모리에 들고 있다.** 요청마다 디스크를 다시 보지 않는다.
|
||||||
|
|
||||||
|
**왜 여기 나오나.** certbot 은 `live/` 심볼릭 링크가 새 `archive/` 파일을
|
||||||
|
가리키도록 바꾼다. **경로는 그대로인데 내용만 바뀐다.** 그래서 nginx 설정을
|
||||||
|
고칠 필요가 없고, 바로 그 때문에 "설정이 그대로니 괜찮다"고 착각하기 쉽다.
|
||||||
|
필요한 것은 설정 변경이 아니라 **reload** 다.
|
||||||
|
|
||||||
|
**없거나 틀리면.** 인증서가 만료되어 브라우저가 `NET::ERR_CERT_DATE_INVALID`
|
||||||
|
를 띄운다. 그 시점에 디스크에는 멀쩡한 인증서가 들어 있고 갱신 로그도
|
||||||
|
`SUCCESS` 다 — 그래서 원인을 찾는 데 오래 걸린다.
|
||||||
|
|
||||||
|
**확인.** nginx 는 reload 하면 마스터를 유지한 채 워커만 새로 띄운다.
|
||||||
|
```bash
|
||||||
|
ps -eo pid,ppid,etimes,lstart,args | grep nginx
|
||||||
|
# 585 1 80529 Thu Sep 3 19:00:39 nginx: master process
|
||||||
|
# 586 585 80529 Thu Sep 3 19:00:39 nginx: worker process
|
||||||
|
# ↑ 워커가 마스터 기동 직후의 첫 fork(585→586) 그대로 22.4시간째
|
||||||
|
# = reload 가 한 번도 없었다
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 원인은 하나가 아니라 셋이 겹쳤다
|
||||||
|
|
||||||
|
| # | 경로 | 상태 |
|
||||||
|
|---|---|---|
|
||||||
|
| 1 | `certbot-renew.service` 의 `ExecStartPost` | **없다** — `ExecStart=/usr/bin/certbot -q renew` 가 전부 |
|
||||||
|
| 2 | `/etc/letsencrypt/renewal-hooks/{deploy,post,pre}/` | **셋 다 비어 있다** (`total 8`, `.` 과 `..` 뿐) |
|
||||||
|
| 3 | certbot 의 nginx 플러그인 | **없다** — `dns-cloudflare, manual, null, standalone, webroot` |
|
||||||
|
|
||||||
|
**세 경로 전부가 비어 있다.** 하나라도 있었으면 자동으로 반영됐다.
|
||||||
|
certbot 5.7.0 에서 확인한 값이다.
|
||||||
|
|
||||||
|
#### 고치는 법
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
sudo certbot renew --force-renewal
|
# 가장 단순한 방법 — deploy 훅 하나
|
||||||
|
sudo tee /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh <<'EOS'
|
||||||
|
#!/bin/sh
|
||||||
|
nginx -t && nginx -s reload
|
||||||
|
EOS
|
||||||
|
sudo chmod +x /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
**실행할 수 없었다.**
|
`deploy/` 는 **실제로 갱신된 인증서가 있을 때만** 실행된다. `post/` 는 갱신
|
||||||
|
여부와 무관하게 매번 돌므로, 하루 두 번 쓸데없이 reload 하게 된다.
|
||||||
|
|
||||||
|
**확인 방법** — 훅을 넣었다고 끝이 아니다. 다음 강제 갱신 때 `nginx` 워커
|
||||||
|
PID 가 바뀌는지 봐야 한다.
|
||||||
|
|
||||||
|
> **★ 검증됨 ([D-4a](experiment-d4a-deploy-hook.md))** — 이 처방을 실제로
|
||||||
|
> 넣고 강제 갱신했다. 워커가 `28829 → 37252` 로 자동 교체됐고,
|
||||||
|
> **발급에서 서빙까지 1~2초**였다. 다만 certbot 출력의
|
||||||
|
> `Hook 'deploy-hook' ran with error output` 은 **실패가 아니다** —
|
||||||
|
> nginx 의 경고가 stderr 로 나갔을 뿐이다. 판정은 문구가 아니라 워커 PID 로 한다.
|
||||||
|
|
||||||
|
### 4-3. reload 는 무중단이다 — 측정됨
|
||||||
|
|
||||||
|
계획서의 질문에 대한 답이다.
|
||||||
|
|
||||||
|
**새 연결** — 0.2초 폴링, 08:10:51 ~ 09:02, **전체 8856건 / 비200 0건**
|
||||||
|
|
||||||
|
| 구간 | n | 중앙 | p95 | 최대 |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| 장기 평시 08:20~08:50 | 5398 | 98.0ms | 205.7ms | 1942.9ms |
|
||||||
|
| reload 직전 2분56초 | 489 | 116.0ms | 200.8ms | 387.7ms |
|
||||||
|
| reload 직후 2분08초 | 342 | 132.5ms | **204.3ms** | 475.0ms |
|
||||||
|
|
||||||
|
p95 가 205.7 → 204.3 으로 **사실상 동일**하고 최대값은 오히려 낮다.
|
||||||
|
10초 구간 중앙값은 reload 전후 모두 80~190ms 사이를 오간다 — WiFi 잡음이지
|
||||||
|
reload 의 흔적이 아니다.
|
||||||
|
|
||||||
|
**진행 중이던 요청** — 여기가 계획서가 정확히 물은 지점이다.
|
||||||
|
|
||||||
```
|
```
|
||||||
$ sudo -n -l
|
08:58:40 요청 시작 (845KB @ 20k/s)
|
||||||
sudo: a password is required
|
08:58:52 ← nginx -s reload. 요청 시작 12초 뒤, 전송 한가운데
|
||||||
$ sudo -n systemctl reload nginx
|
08:59:21 종료: 코드=200 바이트=845361(전량) 연결수=1 curl종료=0
|
||||||
sudo: a password is required
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**test-server 의 sudo 는 비밀번호를 요구한다.** 게스트(kc-lab-1/2)는 무암호라
|
| 관측 | 읽는 법 |
|
||||||
A층에서 `conntrack`·`tc` 를 자유롭게 썼는데, **호스트는 다르다.**
|
|
||||||
|
|
||||||
> **이 사실은 B-7 에서 처음 드러났다** — nginx 설정을 읽으려던 시도가 계속
|
|
||||||
> 빈 결과였고, 그게 **sudo 의 조용한 실패**였다. 여기서 다시 확인된다.
|
|
||||||
|
|
||||||
### 그래서 답하지 못한 것
|
|
||||||
|
|
||||||
| 계획서의 항목 | 상태 |
|
|
||||||
|---|---|
|
|---|---|
|
||||||
| nginx reload 타이밍에 무중단인가 | **미측정** |
|
| 바이트가 전량이다 | 잘리지 않았다 |
|
||||||
| 갱신 중 진행 중이던 요청은 | **미측정** |
|
| **연결수가 1이다** | 중간에 끊겨 재연결한 게 아니다 |
|
||||||
| `certbot-renew.timer` 가 실제 갱신을 하는가 | **미측정** (58일 뒤에야 알 수 있다) |
|
| 코드 200 | 옛 워커가 이 요청을 끝까지 책임졌다 |
|
||||||
|
|
||||||
### 이론적으로는 무엇을 기대하는가
|
옛 인증서로 시작한 연결이 **새 워커 전환을 관통해** 끝까지 갔다.
|
||||||
|
in-flight 전체 50건 중 종료코드 ≠ 0 은 **0건**이다.
|
||||||
|
|
||||||
```
|
> 문서가 "nginx 의 reload 는 설계상 무중단이지만 **확인하지 않았으므로 그렇게
|
||||||
certbot renew → 새 인증서 파일 저장
|
> 쓰면 안 된다**"고 유보했던 명제가, 이제 측정으로 뒷받침된다.
|
||||||
└─ deploy-hook: nginx -s reload
|
> **그리고 유보가 옳았다** — 같은 자리에서 유보 없이 썼다면 맞았겠지만,
|
||||||
└─ nginx 는 새 워커를 띄우고 옛 워커는 진행 중 요청을 끝낸 뒤 종료
|
> 바로 옆의 "갱신하면 반영된다"는 명제는 틀렸다. 어느 쪽이 틀릴지는 미리 알 수 없다.
|
||||||
→ graceful. 진행 중 요청은 옛 인증서로 완결된다
|
|
||||||
```
|
|
||||||
|
|
||||||
**nginx 의 reload 는 설계상 무중단**이지만, **확인하지 않았으므로 그렇게
|
### 4-4. 남은 미측정
|
||||||
쓰면 안 된다.** 이 실험대에서 반복해 배운 것이 바로 그것이다 —
|
|
||||||
A-1 의 NetworkPolicy, A-3 의 `--grace-period=0`, B-5 의 AOF 모두
|
|
||||||
**"그럴 것이다" 가 틀렸던 사례**다.
|
|
||||||
|
|
||||||
---
|
| 항목 | 상태 |
|
||||||
|
|---|---|
|
||||||
|
| `certbot-renew.timer` 가 **실제 갱신**을 하는가 | **미측정.** 약 89일 뒤에야 조건이 성립한다(갱신으로 만료일이 밀렸다) |
|
||||||
|
| deploy 훅을 넣으면 자동 반영되는가 | **측정 완료 →** [D-4a](experiment-d4a-deploy-hook.md). **듣는다** — 발급에서 서빙까지 **1~2초**(여기 38분 25초의 약 1150분의 1) |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 증거 파일
|
## 증거 파일
|
||||||
|
|
||||||
**증거 수집 시각: 2026-09-04 15:09 – 15:09 KST** (파일 mtime 기준. 문서 상단의 시각 표기는 작성 시점이라 다를 수 있다.)
|
**증거 수집: 2026-09-04 · 감시 08:10:51 – 09:02 UTC** (파일 mtime 기준)
|
||||||
|
|
||||||
| 파일 | 종류 |
|
| 파일 | 종류 | 무엇을 보여주는가 |
|
||||||
|---|---|
|
|---|---|---|
|
||||||
| [`01-certificate-state.txt`](evidence/d4-certificate-renewal/01-certificate-state.txt) | 터미널 원문 |
|
| [`01-certificate-state.txt`](evidence/d4-certificate-renewal/01-certificate-state.txt) | 터미널 | SAN 3개 · 체인 4단계 `Verify return code: 0` |
|
||||||
|
| [`05-control-no-injection.txt`](evidence/d4-certificate-renewal/05-control-no-injection.txt) | 터미널 | **대조군 1** — 900/900 200, 오류 0 · 핸드셰이크 900/900 |
|
||||||
|
| [`06-inflight-control.txt`](evidence/d4-certificate-renewal/06-inflight-control.txt) | 터미널 | **대조군 2** — 42초 in-flight 장치, 845361바이트 완주 |
|
||||||
|
| [`07-renewal-hook-missing.txt`](evidence/d4-certificate-renewal/07-renewal-hook-missing.txt) | 터미널 | nginx 워커 PID 로 본 "reload 없음" · 유닛에 `ExecStartPost` 없음 · crt.sh 로는 못 가림 |
|
||||||
|
| [`08-inflight-artifact.txt`](evidence/d4-certificate-renewal/08-inflight-artifact.txt) | 터미널 | 76건 실패가 **로컬 아티팩트**임을 대조 폴링으로 가려낸 기록 |
|
||||||
|
| [`09-serial-timeline.txt`](evidence/d4-certificate-renewal/09-serial-timeline.txt) | 터미널 | 일련번호 564표본 — 08:58:52 에 바뀌는 순간 |
|
||||||
|
| [`10-reload-poll-window.txt`](evidence/d4-certificate-renewal/10-reload-poll-window.txt) | 터미널 | reload 전후 60초 새 연결 원문 (비200 0건) |
|
||||||
|
| [`11-inflight-full.txt`](evidence/d4-certificate-renewal/11-inflight-full.txt) | 터미널 | in-flight 전체 — reload 를 관통한 08:58:40 요청 포함 |
|
||||||
|
| [`12-certbot-state.txt`](evidence/d4-certificate-renewal/12-certbot-state.txt) | 터미널 | `certbot certificates` · `archive/` mtime · **훅 3개 디렉터리 전부 비었음** |
|
||||||
|
| [`13-verdict.txt`](evidence/d4-certificate-renewal/13-verdict.txt) | 터미널 | **판정** — 38분 25초 공백 + reload 무중단, 수치 전량 |
|
||||||
|
|
||||||
파일별 상세는 [`evidence/d4-certificate-renewal/README.md`](evidence/d4-certificate-renewal/README.md).
|
파일별 상세는 [`evidence/d4-certificate-renewal/README.md`](evidence/d4-certificate-renewal/README.md).
|
||||||
|
|
||||||
## 5. 재현 절차 (명령어)
|
## 5. 재현 절차 (명령어)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 1. 인증서 내용 — 밖에서 볼 수 있다
|
# ─────────────────────────────────────────────────────────────
|
||||||
echo | openssl s_client -connect auth.hyeonworks.com:443 -servername auth.hyeonworks.com 2>/dev/null \
|
# 1. 인증서 내용 — 밖에서 볼 수 있다(sudo 불필요)
|
||||||
| openssl x509 -noout -subject -issuer -dates -ext subjectAltName
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
for H in auth app1 app2; do
|
||||||
|
echo "-- $H.hyeonworks.com"
|
||||||
|
echo | openssl s_client -connect $H.hyeonworks.com:443 -servername $H.hyeonworks.com 2>/dev/null \
|
||||||
|
| openssl x509 -noout -serial -dates -subject -ext subjectAltName
|
||||||
|
done
|
||||||
|
# 세 이름의 일련번호가 같으면 → 인증서 1장에 SAN 3개다.
|
||||||
|
# B-7 이 네 번째 이름을 못 쓴 이유가 여기 있다.
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
# 2. 체인 완결성 — 단계가 1개면 cert.pem 을 쓴 것이다
|
# 2. 체인 완결성 — 단계가 1개면 cert.pem 을 쓴 것이다
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
echo | openssl s_client -connect auth.hyeonworks.com:443 -servername auth.hyeonworks.com 2>/dev/null \
|
echo | openssl s_client -connect auth.hyeonworks.com:443 -servername auth.hyeonworks.com 2>/dev/null \
|
||||||
| grep -E "^ *[0-9] s:|Verify return code"
|
| grep -E '^ *[0-9]+ s:|^ *i:|Verify return code'
|
||||||
|
|
||||||
# 3. 갱신 자동화
|
# ─────────────────────────────────────────────────────────────
|
||||||
systemctl list-timers certbot-renew.timer --no-pager
|
# 3. ★ 대조군을 먼저 잡는다 — 이걸 건너뛰면 주입 결과를 해석할 수 없다
|
||||||
systemctl is-enabled certbot-renew.timer
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
# 3-a. 새 연결 가용성: 0.2초 × 900회 = 180초
|
||||||
|
i=0
|
||||||
|
while [ $i -lt 900 ]; do
|
||||||
|
curl -s -o /dev/null -w '%{http_code} %{time_total} %{time_appconnect}\n' \
|
||||||
|
--max-time 5 https://auth.hyeonworks.com/realms/master
|
||||||
|
i=$((i+1)); sleep 0.2
|
||||||
|
done > /tmp/d4-control.txt
|
||||||
|
awk '{print $1}' /tmp/d4-control.txt | sort | uniq -c # 비200 이 몇 개인가
|
||||||
|
|
||||||
# 4. 강제 갱신 (sudo 필요 — 이 실험대에서는 불가)
|
# 3-b. 진행 중 요청: 845KB 를 20k/s 로 받아 42초 동안 살려 둔다
|
||||||
|
JS=$(curl -s https://auth.hyeonworks.com/admin/master/console/ \
|
||||||
|
| grep -oE '/resources/[a-z0-9]+/admin/[^"]+\.js' | head -1)
|
||||||
|
curl -s --limit-rate 20k -o /tmp/inflight.bin \
|
||||||
|
-w '코드=%{http_code} 바이트=%{size_download} 시간=%{time_total} 연결수=%{num_connects}\n' \
|
||||||
|
"https://auth.hyeonworks.com$JS"
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
# 4. 감시를 켠다 — 일련번호가 바뀌면 3분 더 재고 스스로 멈춘다
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
BASE=$(echo | openssl s_client -connect auth.hyeonworks.com:443 \
|
||||||
|
-servername auth.hyeonworks.com 2>/dev/null | openssl x509 -noout -serial | cut -d= -f2)
|
||||||
|
rm -f /tmp/d4-stop
|
||||||
|
|
||||||
|
# 감시 0 — 일련번호. 바뀌는 순간이 nginx 가 새 인증서를 집은 순간이다
|
||||||
|
setsid bash -c '
|
||||||
|
BASE='"$BASE"'
|
||||||
|
while [ ! -f /tmp/d4-stop ]; do
|
||||||
|
S=$(echo | openssl s_client -connect auth.hyeonworks.com:443 \
|
||||||
|
-servername auth.hyeonworks.com 2>/dev/null | openssl x509 -noout -serial -enddate | tr "\n" " ")
|
||||||
|
echo "$(date -u +%H:%M:%S) $S"
|
||||||
|
case "$S" in *"$BASE"*) ;; *) sleep 180; touch /tmp/d4-stop;; esac
|
||||||
|
sleep 5
|
||||||
|
done' > /tmp/d4-serial.txt 2>&1 < /dev/null &
|
||||||
|
|
||||||
|
# 감시 1 — 새 연결
|
||||||
|
setsid bash -c '
|
||||||
|
while [ ! -f /tmp/d4-stop ]; do
|
||||||
|
echo "$(date -u +%H:%M:%S.%2N) $(curl -s -o /dev/null -w "%{http_code} %{time_total}" \
|
||||||
|
--max-time 5 https://auth.hyeonworks.com/realms/master)"
|
||||||
|
sleep 0.2
|
||||||
|
done' > /tmp/d4-poll.txt 2>&1 < /dev/null &
|
||||||
|
|
||||||
|
# 감시 2 — 진행 중 요청을 끊김 없이 연달아 (reload 순간에 반드시 하나가 떠 있게)
|
||||||
|
setsid bash -c '
|
||||||
|
while [ ! -f /tmp/d4-stop ]; do
|
||||||
|
echo "$(date -u +%H:%M:%S) $(curl -s --limit-rate 20k -o /dev/null \
|
||||||
|
-w "코드=%{http_code} 바이트=%{size_download} 시간=%{time_total} 연결수=%{num_connects}" \
|
||||||
|
https://auth.hyeonworks.com'"$JS"')"
|
||||||
|
done' > /tmp/d4-inflight.txt 2>&1 < /dev/null &
|
||||||
|
# ★ setsid 가 필요하다. 그냥 & 로 띄우면 부모 셸이 끝날 때 같이 죽는다
|
||||||
|
# (A-3 에서 파드 안 & 가 exec 종료와 함께 죽은 것과 같은 함정이다).
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
# 5. 주입 — 여기만 sudo 가 필요하다
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
ssh test-server
|
||||||
sudo certbot renew --force-renewal
|
sudo certbot renew --force-renewal
|
||||||
# 갱신 중 다른 창에서:
|
|
||||||
# while true; do curl -s -o /dev/null -w '%{http_code} ' https://auth.hyeonworks.com/realms/master; sleep 1; done
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
# 6. 판정
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
grep -vE ' 200 ' /tmp/d4-poll.txt # 새 연결이 끊긴 순간 (대조군은 0건)
|
||||||
|
grep -v '코드=200' /tmp/d4-inflight.txt # 진행 중 요청이 끊긴 순간
|
||||||
|
grep -v "$BASE" /tmp/d4-serial.txt | head # 새 인증서가 서빙되기 시작한 시각
|
||||||
|
```
|
||||||
|
|
||||||
## 6. 남긴 것
|
## 6. 남긴 것
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,274 @@
|
|||||||
|
# D-4a — deploy 훅은 정말 듣는가
|
||||||
|
|
||||||
|
브랜치 `feature/keycloak-d4a-deploy-hook` ·
|
||||||
|
증거 [`docs/evidence/d4a-deploy-hook/`](evidence/d4a-deploy-hook/) ·
|
||||||
|
2026-09-04 12:27 UTC (실제 시각)
|
||||||
|
|
||||||
|
D-4 는 결함을 찾고 **처방을 적어두고 검증하지 않았다.**
|
||||||
|
처방이 듣는지 모르는 채 "이렇게 고치면 된다"고 쓰는 것은,
|
||||||
|
이 실험대가 스물세 번 경계해 온 바로 그 실수다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 구조
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
> 다이어그램 규약은 [`diagrams/_style.md`](diagrams/_style.md).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0. 결론부터
|
||||||
|
|
||||||
|
| 확인 | 결과 |
|
||||||
|
|---|---|
|
||||||
|
| 훅이 실행되는가 | **된다.** certbot 이 `Hook 'deploy-hook' ran` 을 찍는다 |
|
||||||
|
| nginx 가 reload 되는가 | **된다.** 마스터 585 유지, 워커 28829 → **37252** |
|
||||||
|
| **얼마나 빠른가** | **발급 → 서빙 1~2초** (D-4 는 38분 25초) |
|
||||||
|
| 함정 | **`ran with error output` 은 실패가 아니다** |
|
||||||
|
| **★ 부수 발견** | **test-server 시계가 106초 빠르다.** D-4 의 공백 수치를 정정했다 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. D-4 가 무엇을 남겼나
|
||||||
|
|
||||||
|
| 항목 | 상태 |
|
||||||
|
|---|---|
|
||||||
|
| deploy 훅을 넣으면 자동 반영되는가 | **미측정. 훅은 아직 넣지 않았다** |
|
||||||
|
|
||||||
|
D-4 는 원인을 셋으로 특정했고(유닛에 `ExecStartPost` 없음 · 훅 3경로 비었음 ·
|
||||||
|
nginx 플러그인 없음) 처방을 적었다. **그 처방을 실행해 본 적이 없다.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 주입 — 파일 하나
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh
|
||||||
|
#!/bin/sh
|
||||||
|
nginx -t && nginx -s reload
|
||||||
|
```
|
||||||
|
|
||||||
|
### 개념 — `deploy/` 와 `post/` 는 다르다
|
||||||
|
|
||||||
|
| 디렉터리 | 언제 실행되나 |
|
||||||
|
|---|---|
|
||||||
|
| `pre/` | 갱신 **시도** 전 |
|
||||||
|
| **`deploy/`** | **실제로 갱신된 인증서가 있을 때만** |
|
||||||
|
| `post/` | 갱신 여부와 **무관하게** 매번 |
|
||||||
|
|
||||||
|
**왜 `deploy/` 인가.** 타이머는 하루 두 번 돈다. `post/` 에 넣으면 갱신이
|
||||||
|
없는 날에도 하루 두 번 nginx 를 reload 하게 된다 — 아무 이득 없이 워커만
|
||||||
|
갈아치우는 셈이다. `deploy/` 는 **`RENEWED_LINEAGE` 가 있을 때만** 돈다.
|
||||||
|
|
||||||
|
**없거나 틀리면.** D-4 가 측정한 그대로다 — 갱신은 성공하고 서빙은 안 바뀐다.
|
||||||
|
그리고 그 상태로 타이머는 `SUCCESS` 를 찍는다.
|
||||||
|
|
||||||
|
**확인.**
|
||||||
|
```bash
|
||||||
|
sudo ls -la /etc/letsencrypt/renewal-hooks/deploy/ # 비어 있으면 아무도 안 부른다
|
||||||
|
```
|
||||||
|
|
||||||
|
### 왜 `nginx -t &&` 를 앞에 두는가
|
||||||
|
|
||||||
|
설정이 깨진 상태에서 `nginx -s reload` 를 보내면 마스터가 새 워커를 못 띄운다.
|
||||||
|
`-t` 로 먼저 검사하고 통과할 때만 reload 한다. 실패하면 **옛 워커가 그대로
|
||||||
|
서비스를 계속한다** — 인증서는 안 바뀌지만 서비스는 죽지 않는다.
|
||||||
|
|
||||||
|
### 실행
|
||||||
|
|
||||||
|
```bash
|
||||||
|
install -m755 /tmp/reload-nginx.sh /etc/letsencrypt/renewal-hooks/deploy/
|
||||||
|
certbot renew --force-renewal
|
||||||
|
```
|
||||||
|
|
||||||
|
> **이 스크립트는 실험 자동화가 아니라 시험 대상 자체다.**
|
||||||
|
> certbot 은 훅을 파일로만 받는다. 절차는 전부 명령어로 되어 있다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 결과 — certbot 이 훅을 실행했다
|
||||||
|
|
||||||
|
```
|
||||||
|
Processing /etc/letsencrypt/renewal/auth.hyeonworks.com.conf
|
||||||
|
Renewing an existing certificate for auth.hyeonworks.com and 2 more
|
||||||
|
Hook 'deploy-hook' ran with error output:
|
||||||
|
2026/09/04 21:29:36 [warn] 37250#37250: could not build optimal types_hash, …
|
||||||
|
nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
|
||||||
|
nginx: configuration file /etc/nginx/nginx.conf test is successful
|
||||||
|
2026/09/04 21:29:37 [warn] 37251#37251: could not build optimal types_hash, …
|
||||||
|
2026/09/04 21:29:37 [notice] 37251#37251: signal process started
|
||||||
|
|
||||||
|
Congratulations, all renewals succeeded:
|
||||||
|
/etc/letsencrypt/live/auth.hyeonworks.com/fullchain.pem (success)
|
||||||
|
```
|
||||||
|
|
||||||
|
### ★ 함정 — "ran with error output" 은 실패가 아니다
|
||||||
|
|
||||||
|
certbot 은 훅이 **stderr 에 무엇이라도 쓰면** 이 문구를 붙인다.
|
||||||
|
여기 stderr 로 나간 것은 nginx 의 `types_hash` 경고뿐이고, 내용은 전부 성공이다.
|
||||||
|
|
||||||
|
| 줄 | 실제 의미 |
|
||||||
|
|---|---|
|
||||||
|
| `[warn] could not build optimal types_hash` | nginx 의 일반 경고. 갱신과 무관 |
|
||||||
|
| `nginx: … test is successful` | **`nginx -t` 통과** |
|
||||||
|
| `[notice] signal process started` | **`nginx -s reload` 가 신호를 보냄** |
|
||||||
|
|
||||||
|
> 로그에서 `error` 를 grep 하는 감시를 걸어두면 **성공한 훅을 실패로 오독한다.**
|
||||||
|
> 실패를 확인하려면 문구가 아니라 **워커 PID 를 봐야 한다.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 검증 — 워커가 교체됐다
|
||||||
|
|
||||||
|
| | PID | 기동 시각 (test-server 시계) |
|
||||||
|
|---|---|---|
|
||||||
|
| 마스터 (전·후 동일) | **585** | Thu Sep 3 19:00:39 |
|
||||||
|
| 워커 (전) | 28829 | Fri Sep 4 18:00:35 ← D-4 에서 **사람이** reload |
|
||||||
|
| **워커 (후)** | **37252** | Fri Sep 4 21:29:36 ← **훅이 자동으로** |
|
||||||
|
|
||||||
|
**마스터 PID 는 유지되고 워커만 바뀌었다.** D-4 에서 "reload 되었는가"를
|
||||||
|
판정하려고 세운 방법이 그대로 작동한다.
|
||||||
|
|
||||||
|
서빙 인증서도 바뀌었다.
|
||||||
|
|
||||||
|
```
|
||||||
|
serial=06F3E0EF4D1BB03DE58130EAAD1176101373
|
||||||
|
notBefore=Sep 4 11:29:18 2026 GMT
|
||||||
|
notAfter=Dec 3 11:29:17 2026 GMT
|
||||||
|
SAN: app1 / app2 / auth .hyeonworks.com
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. ★ 얼마나 빨랐나 — 그리고 시계 문제
|
||||||
|
|
||||||
|
훅 로그와 워커 `lstart` 는 **test-server 시계**다. 그런데 그 시계는
|
||||||
|
NTP 동기가 꺼져 있다.
|
||||||
|
|
||||||
|
```
|
||||||
|
dev → Google +0초
|
||||||
|
dev → Let's Encrypt ACME +0초
|
||||||
|
test-server → Google −105초 (test-server 가 빠르다)
|
||||||
|
ssh 왕복 3회 측정 +106.1 / +106.1 / +106.1초
|
||||||
|
```
|
||||||
|
|
||||||
|
**dev 가 정확하고 test-server 가 106초 빠르다.**
|
||||||
|
|
||||||
|
### 독립 검증 — SCT
|
||||||
|
|
||||||
|
새 인증서에 박힌 Certificate Transparency 서명 시각은 **CT 로그의 자체
|
||||||
|
시계**로 찍힌다. 양쪽 어느 것과도 무관한 제3의 기준이다.
|
||||||
|
|
||||||
|
```
|
||||||
|
Signed Certificate Timestamp: Sep 4 12:27:49.054 2026 GMT
|
||||||
|
Signed Certificate Timestamp: Sep 4 12:27:49.048 2026 GMT
|
||||||
|
```
|
||||||
|
|
||||||
|
보정한 타임라인 (전부 실제 UTC):
|
||||||
|
|
||||||
|
```
|
||||||
|
12:27:49.05 인증서 발급 ← SCT (외부 권위 기준)
|
||||||
|
12:27:50 훅 nginx -t ← 로그 21:29:36 KST − 106초
|
||||||
|
12:27:50 새 워커 37252 기동 ← lstart 21:29:36 KST − 106초
|
||||||
|
12:27:51 훅 nginx -s reload ← 로그 21:29:37 KST − 106초
|
||||||
|
```
|
||||||
|
|
||||||
|
**발급에서 서빙까지 1~2초.**
|
||||||
|
|
||||||
|
그리고 **보정이 자기 검증된다** — 독립 시계인 SCT 가 보정한 훅 시각의 1초
|
||||||
|
앞에 정확히 놓인다. 보정하지 않으면 훅이 발급보다 **104초 먼저** 실행된 것이
|
||||||
|
되어 물리적으로 불가능해진다.
|
||||||
|
|
||||||
|
### 부수 정정 — D-4 의 2199초는 틀렸다
|
||||||
|
|
||||||
|
D-4 에서 적은 **2199초(36분 39초)** 는 `archive/cert2.pem` 의 mtime
|
||||||
|
(test-server 시계)과 일련번호 관측(dev 시계)을 **그대로 뺀** 값이었다.
|
||||||
|
|
||||||
|
| | 시각 (실제 UTC) |
|
||||||
|
|---|---|
|
||||||
|
| 새 인증서 디스크 기록 | **08:20:27** ← mtime 17:22:13 KST − 106초 |
|
||||||
|
| 실제 서빙 시작 | 08:58:52 ← dev 관측, 보정 불필요 |
|
||||||
|
| **공백** | **2305초 = 38분 25초** |
|
||||||
|
|
||||||
|
관련 문서를 전부 정정했다.
|
||||||
|
|
||||||
|
> **두 시계에서 온 값을 빼면서 그 사실을 적지 않으면, 자릿수가 아니라
|
||||||
|
> 방향까지 틀릴 수 있다.** D-4 에서는 오차가 106초여서 결론이 안 바뀌었지만,
|
||||||
|
> 1~2초를 재는 D-4a 에서는 결과를 완전히 뒤집었다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 대조
|
||||||
|
|
||||||
|
| | 훅 없음 (D-4) | **훅 있음 (D-4a)** |
|
||||||
|
|---|---|---|
|
||||||
|
| 갱신 → 서빙 | **2305초 = 38분 25초** | **1~2초** |
|
||||||
|
| 무엇이 reload 했나 | 사람이 친 `nginx -s reload` | certbot deploy 훅 |
|
||||||
|
| 아무도 안 했다면 | 다음 nginx 재시작까지 = **사실상 무기한** | 해당 없음 |
|
||||||
|
| 차이 | | **약 1150배** |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 남는 것
|
||||||
|
|
||||||
|
| 항목 | 상태 |
|
||||||
|
|---|---|
|
||||||
|
| `certbot-renew.timer` 가 **실제 갱신**을 하는가 | **미측정.** 만료 30일 전(약 89일 뒤)에야 조건이 성립한다 |
|
||||||
|
|
||||||
|
훅은 `--force-renewal` 로 검증했다. **타이머가 스스로 갱신하는 경로**는
|
||||||
|
시간이 지나야 시험할 수 있다. 다만 그 경로도 같은 `certbot renew` 를 부르고
|
||||||
|
같은 `deploy/` 훅을 실행하므로, 남은 미지수는 "타이머가 뜨는가" 하나다 —
|
||||||
|
그리고 그것은 D-4 에서 이미 확인했다(오늘 두 번 `status=0/SUCCESS`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 재현 절차 (명령어)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# ── 0. 기준선 — 이 워커 PID 가 바뀌는지가 판정이다
|
||||||
|
ssh test-server "ps -eo pid,ppid,etimes,lstart,args | grep 'nginx:' | grep -v grep"
|
||||||
|
echo | openssl s_client -connect auth.hyeonworks.com:443 \
|
||||||
|
-servername auth.hyeonworks.com 2>/dev/null | openssl x509 -noout -serial -dates
|
||||||
|
|
||||||
|
# ── 1. ★ 시계 왜곡을 먼저 잰다. 나중에 재면 값을 해석할 수 없다
|
||||||
|
for i in 1 2 3; do
|
||||||
|
A=$(date -u +%s.%N); B=$(ssh test-server 'date -u +%s.%N'); C=$(date -u +%s.%N)
|
||||||
|
python3 -c "print(f'왜곡 {$B-($A+$C)/2:+.1f}초')"
|
||||||
|
done
|
||||||
|
# 어느 쪽이 맞는지는 외부 기준으로 가른다
|
||||||
|
for H in https://www.google.com https://acme-v02.api.letsencrypt.org/directory; do
|
||||||
|
A=$(date -u +%s)
|
||||||
|
D=$(curl -sI --max-time 10 "$H" | grep -i '^date:' | sed 's/^[Dd]ate: *//' | tr -d '\r')
|
||||||
|
C=$(date -u +%s)
|
||||||
|
python3 -c "print(f'$H 차이 {$(date -u -d \"$D\" +%s)-($A+$C)//2:+d}초')"
|
||||||
|
done
|
||||||
|
|
||||||
|
# ── 2. 훅을 sudo 없는 곳에 미리 만들어 둔다 (사람이 칠 명령을 짧게)
|
||||||
|
ssh test-server "printf '#!/bin/sh\nnginx -t && nginx -s reload\n' > /tmp/reload-nginx.sh"
|
||||||
|
|
||||||
|
# ── 3. 주입 — 여기만 sudo 가 필요하다
|
||||||
|
ssh -t test-server 'sudo sh -c "install -m755 /tmp/reload-nginx.sh /etc/letsencrypt/renewal-hooks/deploy/ && certbot renew --force-renewal > /tmp/d4a-renew.txt 2>&1; chmod 644 /tmp/d4a-renew.txt; tail -25 /tmp/d4a-renew.txt"'
|
||||||
|
|
||||||
|
# ── 4. 판정 — 문구가 아니라 워커 PID 로 본다
|
||||||
|
ssh test-server "ps -eo pid,ppid,etimes,lstart,args | grep 'nginx:' | grep -v grep"
|
||||||
|
# 마스터 PID 그대로 + 워커 PID 바뀜 = reload 됨
|
||||||
|
|
||||||
|
# ── 5. 얼마나 빨랐나 — SCT 가 발급 시각의 외부 기준이다
|
||||||
|
echo | openssl s_client -connect auth.hyeonworks.com:443 \
|
||||||
|
-servername auth.hyeonworks.com 2>/dev/null \
|
||||||
|
| openssl x509 -noout -ext ct_precert_scts | grep Timestamp
|
||||||
|
# 훅 로그 시각에서 왜곡을 빼고 SCT 와 비교한다
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 증거 파일
|
||||||
|
|
||||||
|
| 파일 | 종류 | 무엇을 보여주는가 |
|
||||||
|
|---|---|---|
|
||||||
|
| [`01-hook-verified.txt`](evidence/d4a-deploy-hook/01-hook-verified.txt) | 터미널 | 판정 전문 · 시계 보정과 SCT 교차검증 · D-4 대조 |
|
||||||
|
| [`02-certbot-with-hook.txt`](evidence/d4a-deploy-hook/02-certbot-with-hook.txt) | 터미널 | `certbot renew --force-renewal` 원문 |
|
||||||
|
| [`03-after-state.txt`](evidence/d4a-deploy-hook/03-after-state.txt) | 터미널 | 실행 후 nginx 프로세스 · 서빙 인증서 |
|
||||||
|
|
||||||
|
파일별 상세는 [`evidence/d4a-deploy-hook/README.md`](evidence/d4a-deploy-hook/README.md).
|
||||||
@@ -4,8 +4,21 @@
|
|||||||
증거 [`docs/evidence/followup/`](evidence/followup/) ·
|
증거 [`docs/evidence/followup/`](evidence/followup/) ·
|
||||||
2026-09-04 17:35–18:20 KST
|
2026-09-04 17:35–18:20 KST
|
||||||
|
|
||||||
23개 실험을 마치며 **세 항목을 "못 했다" 로 남겼다.** 그중 둘을 채우고,
|
23개 실험을 마치며 **세 항목을 "못 했다" 로 남겼다. 이제 셋 다 채웠다.**
|
||||||
셋째(D-4 강제 갱신)는 권한이 필요해 별도로 진행한다.
|
셋째(D-4 강제 갱신)는 호스트 sudo 가 필요해 사람이 함께 실행했고,
|
||||||
|
결과적으로 **이 세 항목 중 가장 큰 발견**이 거기서 나왔다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 구조
|
||||||
|
|
||||||
|
이 문서는 세 실험의 후속이므로 각자의 구조도를 참조한다.
|
||||||
|
|
||||||
|
| 항목 | 구조도 |
|
||||||
|
|---|---|
|
||||||
|
| D-2 정방향 업그레이드 | [`d2-version-upgrade.svg`](diagrams/d2-version-upgrade.svg) |
|
||||||
|
| B-4 ③ role 반영 | [`b4-header-forgery.svg`](diagrams/b4-header-forgery.svg) |
|
||||||
|
| D-4 강제 갱신 | [`d4-renewal-gap.svg`](diagrams/d4-renewal-gap.svg) · [`d4a-hook-timeline.svg`](diagrams/d4a-hook-timeline.svg) |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -17,6 +30,8 @@
|
|||||||
| **그리고 D-2 의 결론이 정밀해졌다** | **"롤백 불가" 는 조건부다** — 스키마가 바뀌었을 때만 |
|
| **그리고 D-2 의 결론이 정밀해졌다** | **"롤백 불가" 는 조건부다** — 스키마가 바뀌었을 때만 |
|
||||||
| **B-4 ③ role 변경 반영 시점** | **요청 횟수와 무관하다.** 세션이 새로 만들어져야 한다 |
|
| **B-4 ③ role 변경 반영 시점** | **요청 횟수와 무관하다.** 세션이 새로 만들어져야 한다 |
|
||||||
| **B층에 Grafana 증거가 없는 이유** | **관측 대상에 없다.** 안 찍은 것이 아니다 |
|
| **B층에 Grafana 증거가 없는 이유** | **관측 대상에 없다.** 안 찍은 것이 아니다 |
|
||||||
|
| **D-4 강제 갱신** | **★ 갱신은 성공했는데 38분 25초 동안 반영되지 않았다** |
|
||||||
|
| **D-4 reload 무중단** | **무중단이다.** 8856건 전부 200 · 전송 한가운데였던 요청이 전량 수신 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -130,30 +145,66 @@ B-4 를 할 때 **oauth2-proxy 가 아직 배포되지 않아** "proxy session"
|
|||||||
role 을 헤더로 내보내려면 추가 설정이 필요한데, **"IdP 의 클레임 변경이
|
role 을 헤더로 내보내려면 추가 설정이 필요한데, **"IdP 의 클레임 변경이
|
||||||
언제 반영되는가" 라는 질문은 어느 클레임이든 같다.**
|
언제 반영되는가" 라는 질문은 어느 클레임이든 같다.**
|
||||||
|
|
||||||
|
1 · 3 단계는 **브라우저 콘솔**에서 실행한다. oauth2-proxy 쿠키가 HttpOnly 라
|
||||||
|
curl 로는 로그인 상태를 재현할 수 없기 때문이다(그래서 셸이 아니라 JS 다).
|
||||||
|
|
||||||
|
```js
|
||||||
|
// 1. 기준선 — 로그인된 app2 탭의 콘솔에서
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
const r = await (await fetch('/api/echo')).text();
|
||||||
|
console.log(new Date().toISOString(), r.match(/x-forwarded-email: (.*)/)[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 반복 요청 — 0.5초 간격으로 12번
|
||||||
|
for (let i = 0; i < 12; i++) {
|
||||||
|
const r = await (await fetch('/api/echo')).text();
|
||||||
|
console.log(new Date().toISOString(), r.match(/x-forwarded-email: (.*)/)[1]);
|
||||||
|
await new Promise(s => setTimeout(s, 500));
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
2 단계는 셸에서:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 1. 기준선
|
|
||||||
fetch('/api/echo') → x-forwarded-email = labuser@example.com
|
|
||||||
|
|
||||||
# 2. IdP 에서 바꾼다
|
# 2. IdP 에서 바꾼다
|
||||||
kcadm.sh update users/<id> -r keycloak-patterns -s email=CHANGED-labuser@example.com
|
UID=$(kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
|
||||||
|
get users -r keycloak-patterns -q username=labuser \
|
||||||
|
--fields id --format csv --noquotes | tail -1)
|
||||||
|
kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
|
||||||
|
update users/$UID -r keycloak-patterns -s email=CHANGED-labuser@example.com
|
||||||
|
|
||||||
# 3. 반복 요청
|
# 4. Redis 세션을 지워 재인증을 강제한다 (반영 조건 확인)
|
||||||
for (i=1..12) fetch('/api/echo') # 0.5초 간격
|
kubectl -n keycloak-lab exec deploy/redis -- redis-cli --scan --pattern 'oauth2-proxy*' \
|
||||||
|
| xargs -r kubectl -n keycloak-lab exec deploy/redis -- redis-cli del
|
||||||
```
|
```
|
||||||
|
|
||||||
### 결과 — 반영되지 않는다
|
### 결과 — 반영되지 않는다
|
||||||
|
|
||||||
```
|
```
|
||||||
1: labuser@example.com
|
2026-09-04T07:51:56.300Z req#1 HTTP 200 x-forwarded-email=labuser@example.com
|
||||||
2: labuser@example.com
|
2026-09-04T07:51:56.864Z req#2 HTTP 200 x-forwarded-email=labuser@example.com
|
||||||
...
|
...
|
||||||
12: labuser@example.com ← 12회 · 6초 동안 옛 값
|
2026-09-04T07:52:02.719Z req#12 HTTP 200 x-forwarded-email=labuser@example.com
|
||||||
|
|
||||||
|
→ 12회 · 6.4초 동안 전부 옛 값
|
||||||
```
|
```
|
||||||
|
|
||||||
|
> **이 결과는 처음에 증거 파일로 남기지 않았다.** 브라우저 콘솔에서 보고
|
||||||
|
> 문서에만 옮겨 적었고, 서브에이전트 감사가 "증거 없이 서 있는 결론" 으로
|
||||||
|
> 지적했다. **다시 측정해
|
||||||
|
> [`03-b4-role-propagation.txt`](evidence/followup/03-b4-role-propagation.txt)
|
||||||
|
> 에 타임스탬프와 함께 남겼다.**
|
||||||
|
>
|
||||||
|
> 재측정 중 **두 시계가 107초 어긋나 있다**는 것도 드러났다 —
|
||||||
|
> 브라우저(개발 머신)와 test-server 의 시각을 그대로 비교하면 안 되고,
|
||||||
|
> 증거 파일에 보정값을 적어두었다.
|
||||||
|
|
||||||
세션을 지우고 재인증시키자
|
세션을 지우고 재인증시키자
|
||||||
|
|
||||||
```
|
```
|
||||||
재인증 후 email = changed-labuser@example.com
|
2026-09-04T07:53:01.121Z req#1 HTTP 200 x-forwarded-email=changed-labuser@example.com
|
||||||
|
2026-09-04T07:53:01.456Z req#2 HTTP 200 x-forwarded-email=changed-labuser@example.com
|
||||||
|
2026-09-04T07:53:01.785Z req#3 HTTP 200 x-forwarded-email=changed-labuser@example.com
|
||||||
```
|
```
|
||||||
|
|
||||||
### 개념 — 세션은 로그인 시점의 스냅샷이다
|
### 개념 — 세션은 로그인 시점의 스냅샷이다
|
||||||
@@ -222,31 +273,97 @@ for (i=1..12) fetch('/api/echo') # 0.5초 간격
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 5. 남은 것 — D-4 강제 갱신
|
## 5. D-4 강제 갱신 — 셋째 항목, 그리고 가장 큰 발견
|
||||||
|
|
||||||
|
### 왜 못 했었나
|
||||||
|
|
||||||
```
|
```
|
||||||
$ sudo -n -l
|
$ sudo -n -l
|
||||||
sudo: a password is required
|
sudo: a password is required
|
||||||
```
|
```
|
||||||
|
|
||||||
**호스트 sudo 가 비밀번호를 요구해 `certbot renew --force-renewal` 과
|
호스트 sudo 가 비밀번호를 요구해 `certbot renew --force-renewal` 을 실행할 수
|
||||||
`systemctl reload nginx` 를 실행할 수 없다.** 사람이 함께 있어야 한다.
|
없었다. 게스트(kc-lab-1/2)는 무암호라 A층에서 `conntrack`·`tc` 를 자유롭게
|
||||||
|
썼는데 **호스트는 다르다.** 이 사실 자체가 B-7 에서 처음 드러났던 것이다 —
|
||||||
|
nginx 설정을 읽으려던 시도가 계속 빈 결과였고, 그게 sudo 의 조용한 실패였다.
|
||||||
|
|
||||||
측정 계획은 준비되어 있다.
|
### 방법 — 주입보다 대조군을 먼저
|
||||||
|
|
||||||
```bash
|
사람이 실행할 명령은 하나뿐이므로, **그 한 번을 헛되게 쓰지 않는 것**이
|
||||||
# 측정 쪽 (내가 실행)
|
관건이었다. 주입 전에 평시를 재두지 않으면 주입 중에 나온 값을 해석할 수 없다.
|
||||||
while true; do
|
|
||||||
printf '%s ' "$(curl -s -o /dev/null -w '%{http_code}' --max-time 2 https://auth.hyeonworks.com/realms/master)"
|
|
||||||
sleep 0.2
|
|
||||||
done
|
|
||||||
|
|
||||||
# 주입 쪽 (사람이 실행)
|
| 대조군 | 결과 |
|
||||||
sudo certbot renew --force-renewal
|
|---|---|
|
||||||
|
| 새 연결 (0.2초 × 900회 / 180초) | **900 전부 200, 오류 0** · 중앙 98ms · p95 195ms |
|
||||||
|
| 진행 중 요청 (845KB @ 20k/s) | 200 · 845361바이트 · 연결수 1 · **42.3초 완주** |
|
||||||
|
|
||||||
|
두 번째 장치가 따로 필요했던 이유가 중요하다. 첫 번째 폴링은 **TLS
|
||||||
|
핸드셰이크가 900/900** 이다 — 매 요청이 새 연결이라는 뜻이고, 그래서
|
||||||
|
"새 연결을 받아주는가"만 잰다. 계획서가 물은 것은 **"진행 중이던 요청은
|
||||||
|
어떻게 되는가"** 이므로, reload 순간에 실제로 전송 중인 요청이 있어야 한다.
|
||||||
|
845KB 짜리 번들을 일부러 느리게 받아 요청 하나를 42초간 살려 두었다.
|
||||||
|
|
||||||
|
### 결과 ① — 갱신은 성공했는데 인증서가 바뀌지 않았다
|
||||||
|
|
||||||
|
```
|
||||||
|
디스크 cert2.pem 2026-09-04 17:22:13 KST 기록됨
|
||||||
|
네트워크 08:10:51 ~ 08:58:47 serial=0520BB…B853 (옛 것)
|
||||||
|
08:58:52 serial=06C7CB…EA1D (새 것)
|
||||||
```
|
```
|
||||||
|
|
||||||
**0.2초 간격으로 재는 이유** — 2절에서 1초 간격으로는 전환을 거의 못 잡았다.
|
| | 시각 (UTC) |
|
||||||
nginx reload 는 그보다 훨씬 짧을 것이므로 해상도를 올려야 한다.
|
|---|---|
|
||||||
|
| 새 인증서 디스크 기록 | 08:22:13 |
|
||||||
|
| 실제 서빙 시작 (`nginx -s reload`) | 08:58:52 |
|
||||||
|
| **공백** | **2305초 = 38분 25초** (그 사이 428회 관측 · 시계 보정 후) |
|
||||||
|
|
||||||
|
그리고 그 36분은 **우연히 짧았을 뿐이다** — reload 를 시킨 것은 사람이지
|
||||||
|
자동화가 아니다. 원인이 셋 겹쳤고 **전부 비어 있었다.**
|
||||||
|
|
||||||
|
| | 상태 |
|
||||||
|
|---|---|
|
||||||
|
| `certbot-renew.service` 의 `ExecStartPost` | 없음 |
|
||||||
|
| `renewal-hooks/{deploy,post,pre}/` | **셋 다 비었음** |
|
||||||
|
| certbot 의 nginx 플러그인 | 없음 |
|
||||||
|
|
||||||
|
**이 결함은 88일 동안 보이지 않는다.** 타이머는 오늘도 두 번 `SUCCESS` 로
|
||||||
|
끝났다. 만료 30일 전까지는 갱신 자체를 하지 않으므로 발현할 기회가 없고,
|
||||||
|
발현하는 날의 증상은 **인증서 만료**다 — 그날에도 로그는 `SUCCESS` 다.
|
||||||
|
|
||||||
|
### 결과 ② — reload 는 무중단이다
|
||||||
|
|
||||||
|
**0.2초 간격으로 잰 이유** — 2절에서 1초 간격으로는 전환을 거의 못 잡았다.
|
||||||
|
nginx reload 는 그보다 짧을 것이므로 해상도를 올렸다. 결과적으로 해상도는
|
||||||
|
충분했고, **끊김 자체가 없었다.**
|
||||||
|
|
||||||
|
새 연결 **8856건 전부 200**. p95 는 평시 205.7ms 대 직후 204.3ms.
|
||||||
|
|
||||||
|
```
|
||||||
|
08:58:40 845KB 요청 시작 (20k/s)
|
||||||
|
08:58:52 ← nginx -s reload. 전송 12초째, 한가운데
|
||||||
|
08:59:21 코드=200 바이트=845361(전량) 연결수=1
|
||||||
|
```
|
||||||
|
|
||||||
|
연결수가 1이라는 것이 핵심이다 — 끊겨서 다시 붙은 것이 아니라, **옛 워커가
|
||||||
|
그 요청을 끝까지 책임졌다.**
|
||||||
|
|
||||||
|
### 곁다리 — 측정 장치가 거짓말할 뻔했다
|
||||||
|
|
||||||
|
in-flight 감시에서 76건이 실패했다. 그대로 적었으면 "갱신 중 대규모 요청
|
||||||
|
실패"라는 오보가 됐을 것이다. 서버 탓이 아니었다.
|
||||||
|
|
||||||
|
| 근거 | 값 |
|
||||||
|
|---|---|
|
||||||
|
| 같은 순간 폴링 | 49건 **전부 200** |
|
||||||
|
| 연결수 | **0** — TCP 연결 시도조차 못 했다 |
|
||||||
|
| 소요 시간 | **50µs** — DNS 조회보다도 짧다 |
|
||||||
|
| 재현 | **0/100** |
|
||||||
|
|
||||||
|
**대조군이 오보를 막았다.** 상세는
|
||||||
|
[`08-inflight-artifact.txt`](evidence/d4-certificate-renewal/08-inflight-artifact.txt).
|
||||||
|
|
||||||
|
전체 판정은 [D-4 문서](experiment-d4-certificate-renewal.md) 와
|
||||||
|
[`13-verdict.txt`](evidence/d4-certificate-renewal/13-verdict.txt).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -26,8 +26,11 @@
|
|||||||
| **D-1** | 백업·복구 | `...d1-backup-restore` | **빈 데이터베이스가 `200` 을 냈다** |
|
| **D-1** | 백업·복구 | `...d1-backup-restore` | **빈 데이터베이스가 `200` 을 냈다** |
|
||||||
| **D-2** | 버전 업그레이드 | `...d2-version-upgrade` | **이미지를 되돌려도 스키마는 안 돌아온다** |
|
| **D-2** | 버전 업그레이드 | `...d2-version-upgrade` | **이미지를 되돌려도 스키마는 안 돌아온다** |
|
||||||
| **D-3** | 비밀 관리 | `...d3-secret-management` | **RBAC 만 실제로 감춘다** |
|
| **D-3** | 비밀 관리 | `...d3-secret-management` | **RBAC 만 실제로 감춘다** |
|
||||||
| **D-4** | 인증서 갱신 | `...d4-certificate-renewal` | 구성은 정상. **강제 갱신은 사람 손이 필요** |
|
| **D-4** | 인증서 갱신 | `...d4-certificate-renewal` | **갱신은 됐는데 38분 25초 반영 안 됨** (훅 3경로 전부 비었음). reload 자체는 **무중단**(8856건 0실패) |
|
||||||
| **후속** | 미측정 항목 채우기 | `...followup-untested-items` | 정방향 업그레이드 무중단 · **롤백 불가는 조건부였다** · role 변경은 요청으로 반영 안 됨 |
|
| **A-7a** | volatile refresh 500 원인 확정 | `...a7a-volatile-cause` | **가설(`REVOKED_TOKEN`)은 틀렸다** — `CLIENT_SCOPE_CLIENT` 조회다. **A-7 의 표는 캐시 온도에 따라 400/500/200** |
|
||||||
|
| **B-7a** | 고아 세션 정리 | `...b7a-orphan-session` | **지울 수 있다** — 프록시가 못 할 뿐. TTL 역산으로 고아만 **1초 오차**로 골라낸다 |
|
||||||
|
| **D-4a** | deploy 훅 검증 | `...d4a-deploy-hook` | **처방이 듣는다** — 발급→서빙 **1~2초**(D-4 는 38분 25초). test-server 시계가 **106초 빨라** D-4 수치를 정정 |
|
||||||
|
| **후속** | 미측정 3항목 채우기 | `...followup-untested-items` | **셋 다 완료.** 정방향 업그레이드 무중단 · **롤백 불가는 조건부였다** · role 변경은 요청으로 반영 안 됨 · **D-4 갱신 38분 25초 미반영** |
|
||||||
|
|
||||||
## 시각 자료
|
## 시각 자료
|
||||||
|
|
||||||
@@ -38,7 +41,14 @@
|
|||||||
| `diagrams/*.svg` | 실험별 구조도 12개 |
|
| `diagrams/*.svg` | 실험별 구조도 12개 |
|
||||||
| `evidence/*/*.png` | Grafana · 브라우저 스크린샷 |
|
| `evidence/*/*.png` | Grafana · 브라우저 스크린샷 |
|
||||||
|
|
||||||
**스크린샷이 없는 실험은 11개다** — `A-3 A-7 B-3 B-4 B-5 B-6 C-2 D-1 D-3 D-4 후속`.
|
**스크린샷이 없는 실험은 14개다** — `A-3 A-7 A-7a B-3 B-4 B-5 B-6 C-2 D-1 D-3 D-4 D-4a 후속 멀티노드`.
|
||||||
|
없는 이유는 **관측 대상에 그 지표가 없기 때문**이지 안 찍어서가 아니다 —
|
||||||
|
Prometheus 는 keycloak·kubelet·node-exporter·prometheus 만 긁는다
|
||||||
|
([`followup/04-observability-gap.txt`](evidence/followup/04-observability-gap.txt)).
|
||||||
|
터미널·DB·로그로 측정한 실험은 브라우저에 띄울 화면 자체가 없다.
|
||||||
|
|
||||||
|
**구조도(SVG)는 28개이고, 실험 문서 29개 중 28개가 하나 이상을 싣는다.**
|
||||||
|
싣지 않는 하나는 후속 요약 문서이며, 세 실험의 구조도를 표로 참조한다.
|
||||||
그중 B층은 **Prometheus 가 Redis·BFF·PostgreSQL 을 긁지 않아** 만들 수가 없다 —
|
그중 B층은 **Prometheus 가 Redis·BFF·PostgreSQL 을 긁지 않아** 만들 수가 없다 —
|
||||||
[`followup/04-observability-gap.txt`](evidence/followup/04-observability-gap.txt) 에 측정해 두었다.
|
[`followup/04-observability-gap.txt`](evidence/followup/04-observability-gap.txt) 에 측정해 두었다.
|
||||||
|
|
||||||
@@ -85,7 +95,7 @@
|
|||||||
| A-6 | `tc ... dev eth0` | 인터페이스가 `enp1s0` 이다 |
|
| A-6 | `tc ... dev eth0` | 인터페이스가 `enp1s0` 이다 |
|
||||||
| A-6 | `enp1s0` 에 파드 IP 필터 | VXLAN 캡슐화로 **안 보인다** |
|
| A-6 | `enp1s0` 에 파드 IP 필터 | VXLAN 캡슐화로 **안 보인다** |
|
||||||
| B-2 | `spring.sql.init` 스키마 | PostgreSQL 에 없는 `blob` 타입 + `continue-on-error` |
|
| B-2 | `spring.sql.init` 스키마 | PostgreSQL 에 없는 `blob` 타입 + `continue-on-error` |
|
||||||
| B-7·D-4 | `sudo nginx -T` 등 | **호스트 sudo 가 비밀번호를 요구**한다 |
|
| B-7·D-4 | `sudo nginx -T` 등 | **호스트 sudo 가 비밀번호를 요구**한다. D-4 는 사용자가 직접 실행해 해소했다 |
|
||||||
|
|
||||||
**그래서 실험마다 "주입 성공 신호" 를 먼저 정하게 됐다** —
|
**그래서 실험마다 "주입 성공 신호" 를 먼저 정하게 됐다** —
|
||||||
`cluster_size` 하락, `not properly shut down` 로그, iptables 패킷 카운터,
|
`cluster_size` 하락, `not properly shut down` 로그, iptables 패킷 카운터,
|
||||||
|
|||||||
@@ -0,0 +1,209 @@
|
|||||||
|
# 00 — lab host 준비
|
||||||
|
|
||||||
|
## 이 단계가 끝나면
|
||||||
|
|
||||||
|
`virsh list` 가 sudo 없이 돌고, VM 을 만들 수 있는 상태가 된다.
|
||||||
|
|
||||||
|
## 전제
|
||||||
|
|
||||||
|
물리 기계 한 대. 이 실험대는 Arch Linux 를 썼지만 배포판은 상관없다 —
|
||||||
|
패키지 이름만 다르다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. CPU 가상화가 켜져 있는가
|
||||||
|
|
||||||
|
BIOS 에서 꺼져 있으면 아무것도 못 한다. 먼저 본다.
|
||||||
|
|
||||||
|
**확인** — CPU 가 하드웨어 가상화 확장을 내놓고 있는가
|
||||||
|
```bash
|
||||||
|
grep -Eo 'vmx|svm' /proc/cpuinfo | head -1
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — 출력 한 줄이 전부다. `vmx`(Intel) 또는 `svm`(AMD)
|
||||||
|
중 하나가 찍히는가, 아니면 아무것도 안 찍히는가.
|
||||||
|
|
||||||
|
**이 결과가 의미하는 것** — 찍혔으면 이 호스트에서 KVM 을 쓸 수 있다. 다음
|
||||||
|
단계로 간다. 빈 출력은 「CPU 가 못 한다」가 아니라 대개 **BIOS 에서 꺼져
|
||||||
|
있다**는 뜻이다 — 재부팅해 Intel VT-x / AMD-V 를 켜고 다시 잰다. 여기서
|
||||||
|
막히면 뒤의 어떤 단계도 의미가 없으므로 진행하지 않는다.
|
||||||
|
|
||||||
|
**실측** — 이 실험대의 호스트는 16 코어 전부에서 지원한다.
|
||||||
|
|
||||||
|
## 2. KVM 모듈이 올라와 있는가
|
||||||
|
|
||||||
|
**확인** — 커널이 그 확장을 실제로 잡고 있는가
|
||||||
|
```bash
|
||||||
|
lsmod | grep kvm
|
||||||
|
```
|
||||||
|
|
||||||
|
**형태** (이 실험대에서 캡처해 두지 않았다 — 줄 모양만)
|
||||||
|
```
|
||||||
|
kvm_intel ...
|
||||||
|
kvm ...
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — 왼쪽 첫 열의 모듈 이름 두 개. 벤더 모듈
|
||||||
|
(`kvm_intel` 또는 `kvm_amd`)과 공용 `kvm` 이 **둘 다** 있어야 한다. 셋째 열은
|
||||||
|
이 모듈을 쓰고 있는 쪽의 수라서, 아직 VM 이 없으면 0 인 것이 정상이다.
|
||||||
|
|
||||||
|
**이 결과가 의미하는 것** — 두 줄이면 커널이 하드웨어 가상화를 쓸 준비가 됐고
|
||||||
|
`virt-install` 이 KVM 가속으로 뜬다. `kvm` 만 있고 벤더 모듈이 없으면 1번의
|
||||||
|
BIOS 설정이 커널까지 안 넘어온 것이다 — `sudo modprobe kvm_intel` 로 직접
|
||||||
|
올려 보면 거부 사유가 그대로 나온다. 아무것도 없으면 1번으로 돌아간다.
|
||||||
|
|
||||||
|
> **왜 이걸 먼저 보나.** KVM 없이도 QEMU 는 돌지만 **소프트웨어 에뮬레이션**이
|
||||||
|
> 되어 수십 배 느리다. VM 두 대가 「뜨긴 뜨는데 느리다」면 대개 여기다.
|
||||||
|
|
||||||
|
## 3. 패키지 설치
|
||||||
|
|
||||||
|
**하기** (Arch)
|
||||||
|
```bash
|
||||||
|
sudo pacman -S --needed qemu-full libvirt virt-install dnsmasq
|
||||||
|
```
|
||||||
|
|
||||||
|
Debian/Ubuntu 면 이름이 다르다.
|
||||||
|
```bash
|
||||||
|
sudo apt install qemu-system-x86 libvirt-daemon-system virtinst cloud-image-utils
|
||||||
|
```
|
||||||
|
|
||||||
|
| 무엇 | 하는 일 |
|
||||||
|
|---|---|
|
||||||
|
| qemu | 실제로 가상 기계를 돌리는 것 |
|
||||||
|
| libvirt | qemu 를 관리하는 층 (`virsh` 가 여기 붙는다) |
|
||||||
|
| virt-install | VM 을 만드는 명령 |
|
||||||
|
| dnsmasq | 가상 네트워크의 DHCP·DNS |
|
||||||
|
|
||||||
|
**확인** — 두 실행 파일이 PATH 에 들어왔는가
|
||||||
|
```bash
|
||||||
|
virsh --version
|
||||||
|
qemu-system-x86_64 --version
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — 판 번호 두 줄. 명령을 못 찾는다(`command not found`)면
|
||||||
|
패키지가 안 깔린 것이고, 번호가 나오면 깔린 것이다.
|
||||||
|
|
||||||
|
**이 결과가 의미하는 것** — 여기서 나오는 libvirt 판 번호가 뒤의 옵션 이름을
|
||||||
|
가른다. 이 실험대는 **libvirt 12.7.0 · QEMU emulator version 11.1.1** 이었다
|
||||||
|
(문서 끝 실측값 블록). 훨씬 낮은 판이면 `virt-install --cloud-init` 같은
|
||||||
|
옵션의 동작이 다를 수 있으니, 01 에서 막힐 때 이 번호를 같이 본다.
|
||||||
|
|
||||||
|
## 4. libvirt 를 띄우고 권한을 받는다
|
||||||
|
|
||||||
|
**하기**
|
||||||
|
```bash
|
||||||
|
sudo systemctl enable --now libvirtd.socket
|
||||||
|
sudo usermod -aG libvirt "$USER"
|
||||||
|
```
|
||||||
|
|
||||||
|
그리고 **로그아웃했다 다시 들어온다.** 보조 그룹은 로그인할 때 정해지므로
|
||||||
|
`usermod` 만으로는 지금 셸에 반영되지 않는다.
|
||||||
|
|
||||||
|
**확인** — 지금 이 셸이 libvirt 에 sudo 없이 붙는가
|
||||||
|
```bash
|
||||||
|
groups # libvirt 가 보여야 한다
|
||||||
|
virsh list --all # sudo 없이 돌아야 한다
|
||||||
|
```
|
||||||
|
|
||||||
|
**실측**
|
||||||
|
```
|
||||||
|
donghyeon libvirt wheel
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — `groups` 출력에 `libvirt` 가 끼어 있는가, 그리고
|
||||||
|
`virsh list --all` 이 **머리글만 있는 빈 표**라도 오류 없이 끝나는가. 아직
|
||||||
|
VM 을 안 만들었으므로 표가 비어 있는 것이 정상이다 — 봐야 할 것은 표의
|
||||||
|
내용이 아니라 명령이 통과했다는 사실이다.
|
||||||
|
|
||||||
|
**이 결과가 의미하는 것** — 둘 다 통과하면 이 셸에서 VM 을 만들 수 있고,
|
||||||
|
01 로 넘어가도 된다. `groups` 에 `libvirt` 가 없으면 `usermod` 는 됐지만
|
||||||
|
**지금 로그인 세션이 옛 그룹 목록을 들고 있는** 것이다 — 로그아웃/로그인
|
||||||
|
한다. `groups` 에는 있는데 `virsh` 가 `Permission denied` 면 그룹이 아니라
|
||||||
|
소켓 문제이므로 `systemctl status libvirtd.socket` 을 본다.
|
||||||
|
|
||||||
|
> **`libvirtd.service` 가 아니라 `.socket` 을 켠 이유.** 소켓 활성화라서
|
||||||
|
> 데몬이 미리 떠 있지 않아도 `virsh` 가 접속하는 순간 systemd 가 띄운다.
|
||||||
|
> 자원을 아끼고, 데몬을 재시작해도 클라이언트가 끊기지 않는다.
|
||||||
|
|
||||||
|
## 5. 연결 URI 를 고정한다
|
||||||
|
|
||||||
|
`virsh` 는 기본으로 `qemu:///session`(사용자 단위)에 붙는데, VM 은
|
||||||
|
`qemu:///system`(시스템 단위)에 만들어야 한다. **이걸 안 맞추면 만든 VM 이
|
||||||
|
안 보인다.**
|
||||||
|
|
||||||
|
**하기** — 셸 프로필에 넣는다
|
||||||
|
```bash
|
||||||
|
echo 'export LIBVIRT_DEFAULT_URI=qemu:///system' >> ~/.bashrc
|
||||||
|
```
|
||||||
|
|
||||||
|
**확인** — 지금 셸이 어느 하이퍼바이저를 보고 있는가
|
||||||
|
```bash
|
||||||
|
virsh uri
|
||||||
|
```
|
||||||
|
```
|
||||||
|
qemu:///system
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — 끝의 한 낱말. `system` 인가 `session` 인가.
|
||||||
|
|
||||||
|
**이 결과가 의미하는 것** — `qemu:///system` 이면 뒤에서 만들 VM 과 지금
|
||||||
|
`virsh` 가 같은 곳을 본다. `qemu:///session` 이면 사용자 단위 하이퍼바이저를
|
||||||
|
보고 있어 **VM 은 만들어졌는데 `virsh list` 에 안 나오는** 상태가 된다.
|
||||||
|
`.bashrc` 에 넣은 것은 **새로 여는 셸에만** 적용되므로, 지금 셸에서는
|
||||||
|
`export LIBVIRT_DEFAULT_URI=qemu:///system` 을 한 번 더 치거나 새 셸을 연다.
|
||||||
|
|
||||||
|
## 6. 기본 네트워크
|
||||||
|
|
||||||
|
**확인** — VM 이 붙을 가상 네트워크가 살아 있는가
|
||||||
|
```bash
|
||||||
|
virsh net-list --all
|
||||||
|
```
|
||||||
|
|
||||||
|
**실측**
|
||||||
|
```
|
||||||
|
Name State Autostart Persistent
|
||||||
|
--------------------------------------------
|
||||||
|
default active yes yes
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — `default` 행의 **State 와 Autostart 두 칸**.
|
||||||
|
`--all` 을 준 이유가 여기 있다 — 빼면 `inactive` 인 네트워크는 아예 목록에
|
||||||
|
안 나와서 「없음」과 「꺼짐」을 구분할 수 없다.
|
||||||
|
|
||||||
|
**이 결과가 의미하는 것** — `active` + `yes` 면 지금도, 호스트를 재부팅한
|
||||||
|
뒤에도 `virbr0` 와 `192.168.122.0/24` 가 있다. `inactive` 면 VM 을 만들어도
|
||||||
|
DHCP 가 없어 IP 를 못 받는다. Autostart 가 `no` 면 **지금은 되지만 호스트를
|
||||||
|
재부팅한 다음 01 의 SSH 가 전부 실패**하고, 원인을 게스트에서 찾게 된다.
|
||||||
|
둘 중 하나라도 어긋나면 아래 두 줄로 맞춘다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
virsh net-start default
|
||||||
|
virsh net-autostart default
|
||||||
|
```
|
||||||
|
|
||||||
|
이 네트워크가 `virbr0` 브리지와 `192.168.122.0/24` 대역을 만든다. VM 들이
|
||||||
|
여기 붙는다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 막히면
|
||||||
|
|
||||||
|
| 증상 | 원인 | 확인 |
|
||||||
|
|---|---|---|
|
||||||
|
| `virsh list` 에 permission denied | 그룹 반영 안 됨 | 로그아웃/로그인 했는가. `groups` 에 libvirt 가 있는가 |
|
||||||
|
| 만든 VM 이 목록에 없다 | URI 가 `session` | `virsh uri` |
|
||||||
|
| VM 이 극단적으로 느리다 | KVM 미사용 | `lsmod \| grep kvm` · BIOS |
|
||||||
|
| `net-start default` 실패 | dnsmasq 없음 | 패키지 설치 확인 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 이 단계의 실측값
|
||||||
|
|
||||||
|
돌고 있는 실험대에서 그대로 읽은 것이다.
|
||||||
|
|
||||||
|
```
|
||||||
|
libvirt 12.7.0
|
||||||
|
qemu QEMU emulator version 11.1.1
|
||||||
|
그룹 donghyeon libvirt wheel
|
||||||
|
네트워크 default / active / autostart yes
|
||||||
|
```
|
||||||
@@ -0,0 +1,445 @@
|
|||||||
|
# 01 — VM 세 대
|
||||||
|
|
||||||
|
## 이 단계가 끝나면
|
||||||
|
|
||||||
|
`kc-lab-edge`·`kc-lab-1`·`kc-lab-2` 세 게스트가 뜨고, lab host 에서 SSH 가
|
||||||
|
키로 붙는다.
|
||||||
|
|
||||||
|
## 전제
|
||||||
|
|
||||||
|
[00](../00-lab-host/) 이 끝나 `virsh list` 가 sudo 없이 돈다.
|
||||||
|
|
||||||
|
## 왜 VM 세 대인가
|
||||||
|
|
||||||
|
**k3s 노드 두 대** — 이 실험대의 질문 전부가 **「인스턴스가 둘 이상이고
|
||||||
|
요청이 어느 쪽으로 갈지 모른다」** 를 전제한다. 한 대면 세션 공유도 분단도
|
||||||
|
노드 상실도 실험이 되지 않는다.
|
||||||
|
|
||||||
|
**엣지 한 대** — nginx·인증서·certbot 이 사는 곳이다. 이것을 물리 호스트에
|
||||||
|
두면 **되돌릴 수가 없다.** 자주 고치고 자주 갈아엎는 층인데 물리 기계에
|
||||||
|
쌓이기 때문이다. VM 이면 초기화가 `virsh undefine` 한 줄이고, 물리 호스트에는
|
||||||
|
DNAT 규칙 하나와 DHCP 예약만 남는다. 자세한 이유는 [03](../03-nginx/) 의
|
||||||
|
「왜 엣지가 물리 호스트가 아니라 VM 인가」에 있다.
|
||||||
|
|
||||||
|
그리고 호스트에 직접 깔면 **되돌릴 수가 없다** — 이 실험대는 노드를
|
||||||
|
죽이고 DB 를 crash 시키는 것이 목적이라 망가뜨릴 수 있는 층이 필요하다.
|
||||||
|
|
||||||
|
| 게스트 | IP | MAC 끝 | 메모리 | 무엇이 도나 |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| `kc-lab-edge` | 192.168.122.10 | `:10` | 1024MB | nginx · certbot |
|
||||||
|
| `kc-lab-1` | 192.168.122.11 | `:11` | 5120MB | k3s server · Traefik |
|
||||||
|
| `kc-lab-2` | 192.168.122.12 | `:12` | 4096MB | k3s agent · Traefik |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. base 이미지를 받는다
|
||||||
|
|
||||||
|
OS 를 설치하지 않는다. 클라우드 이미지는 **이미 설치가 끝난 디스크**이고,
|
||||||
|
첫 부팅에서 cloud-init 이 사용자·SSH 키·호스트명을 채운다.
|
||||||
|
|
||||||
|
**하기**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /var/lib/libvirt/images
|
||||||
|
sudo curl -fL --output /var/lib/libvirt/images/base.qcow2 \
|
||||||
|
https://cloud.debian.org/images/cloud/bookworm/latest/debian-12-genericcloud-amd64.qcow2
|
||||||
|
```
|
||||||
|
|
||||||
|
**확인** — 받은 파일이 온전한 qcow2 인가
|
||||||
|
|
||||||
|
```bash
|
||||||
|
qemu-img info /var/lib/libvirt/images/base.qcow2
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — 세 줄이다. `file format:` 이 `qcow2` 인가(`raw` 로
|
||||||
|
읽히면 내려받기가 중간에 끊겨 HTML 오류 페이지를 저장한 것이다),
|
||||||
|
`virtual size:` 가 `disk size:` 보다 훨씬 큰가(qcow2 는 희소 파일이라 이게
|
||||||
|
정상이다), `backing file:` 줄이 **없는가**. base 는 아무것도 뒤에 두지 않는다.
|
||||||
|
|
||||||
|
**이 결과가 의미하는 것** — 셋이 맞으면 이 파일을 4번에서 오버레이의 바닥으로
|
||||||
|
쓸 수 있다. 여기서 형식이 틀린 채로 진행하면 증상이 `virt-install` 이 아니라
|
||||||
|
**게스트가 부팅을 못 하는 것**으로 나타나 원인을 찾기 어려워진다.
|
||||||
|
|
||||||
|
## 2. cloud-init 을 쓴다
|
||||||
|
|
||||||
|
게스트마다 하나씩 만든다. 템플릿은
|
||||||
|
[`deploy/lab/cloud-init/kc-lab.yaml.example`](../../../deploy/lab/cloud-init/kc-lab.yaml.example).
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
#cloud-config
|
||||||
|
hostname: kc-lab-1
|
||||||
|
fqdn: kc-lab-1
|
||||||
|
manage_etc_hosts: true
|
||||||
|
|
||||||
|
users:
|
||||||
|
- name: donghyeon
|
||||||
|
groups: [sudo]
|
||||||
|
shell: /bin/bash
|
||||||
|
sudo: ['ALL=(ALL) NOPASSWD:ALL']
|
||||||
|
lock_passwd: false
|
||||||
|
plain_text_passwd: __CONSOLE_PW__
|
||||||
|
ssh_authorized_keys:
|
||||||
|
- __LAB_HOST_KEY__
|
||||||
|
- __WORKSTATION_KEY__
|
||||||
|
|
||||||
|
ssh_pwauth: false
|
||||||
|
package_update: true
|
||||||
|
packages: [curl, nftables]
|
||||||
|
```
|
||||||
|
|
||||||
|
### 세 값을 어디서 가져오나
|
||||||
|
|
||||||
|
자리표시자 셋을 실제 값으로 바꿔야 한다. **찾는 명령이 있다.**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# ① lab host 공개키 — 없으면 만든다
|
||||||
|
[ -f ~/.ssh/id_ed25519.pub ] || ssh-keygen -t ed25519 -N '' -f ~/.ssh/id_ed25519
|
||||||
|
cat ~/.ssh/id_ed25519.pub
|
||||||
|
|
||||||
|
# ② 워크스테이션 공개키 — 워크스테이션에서
|
||||||
|
cat ~/.ssh/id_ed25519.pub
|
||||||
|
|
||||||
|
# ③ 콘솔용 비밀번호 — 만들어서 보관한다
|
||||||
|
openssl rand -base64 18
|
||||||
|
```
|
||||||
|
|
||||||
|
셋을 넣는다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sed -i \
|
||||||
|
-e "s|__LAB_HOST_KEY__|$(cat ~/.ssh/id_ed25519.pub)|" \
|
||||||
|
-e "s|__WORKSTATION_KEY__|<워크스테이션에서 복사해 온 값>|" \
|
||||||
|
-e "s|__CONSOLE_PW__|$(openssl rand -base64 18)|" \
|
||||||
|
kc-lab-1.yaml
|
||||||
|
```
|
||||||
|
|
||||||
|
**확인** — 자리표시자가 남아 있으면 cloud-init 이 그대로 넣는다
|
||||||
|
|
||||||
|
```bash
|
||||||
|
grep -c '__' kc-lab-1.yaml # 0 이어야 한다
|
||||||
|
grep -c 'ssh-ed25519\|ssh-rsa' kc-lab-1.yaml # 2 여야 한다
|
||||||
|
python3 -c 'import yaml,sys; yaml.safe_load(open("kc-lab-1.yaml")); print("YAML OK")'
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — 숫자 두 개와 낱말 하나. 첫 줄이 `0` 이 아니면
|
||||||
|
`__LAB_HOST_KEY__` 같은 문자열이 남아 있는 것이고, 둘째 줄이 `2` 가 아니면
|
||||||
|
`sed` 치환 중 하나가 안 먹은 것이다. 셋째 줄은 `YAML OK` 가 찍히는가만 본다 —
|
||||||
|
찍히지 않으면 대신 파이썬 예외 줄이 나오고, 거기 적힌 `line N` 이 문제의
|
||||||
|
줄 번호다. 이 셋은 값을 뽑는 것이 아니라 **찍힌 숫자를 눈으로 비교하는**
|
||||||
|
용도라 이 형태가 맞다.
|
||||||
|
|
||||||
|
**이 결과가 의미하는 것** — `0` / `2` / `YAML OK` 셋이 다 맞아야 시드를 만든다.
|
||||||
|
어긋난 채로 3번을 진행하면 **cloud-init 은 파싱에 실패해도 아무 오류를 남기지
|
||||||
|
않으므로**, 증상이 「SSH 가 안 붙는다」로만 나타나고 원인이 이 파일에 있다는
|
||||||
|
사실이 드러나지 않는다. 여기서 거르는 것이 뒤에서 30분 걸릴 일을 없앤다.
|
||||||
|
|
||||||
|
> **`yamllint` 는 이 실험대에 없다.** 있으면 좋지만 없다고 설치하러 가지
|
||||||
|
> 않는다 — 위 세 줄로 충분하다.
|
||||||
|
|
||||||
|
**게스트가 한 대라도 떠 있으면 한 단계 더 볼 수 있다.** YAML 로 파싱된다는
|
||||||
|
것과 **cloud-config 로 유효하다**는 것은 다르다. 키 이름 오타(`user` vs
|
||||||
|
`users`)는 위 검사를 그냥 통과한다. cloud-init 자신의 스키마 검사기가
|
||||||
|
게스트에 들어 있다 — `kc-lab-2` 용 파일은 `kc-lab-1` 에서 검사할 수 있다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 이 파일에는 콘솔 비밀번호가 평문으로 들어 있다 — /tmp 가 아니라
|
||||||
|
# 자기 홈에 600 으로 두고, 검사가 끝나면 바로 지운다
|
||||||
|
ssh donghyeon@192.168.122.11 'umask 077; cat > ~/kc-lab-2.yaml' < kc-lab-2.yaml
|
||||||
|
ssh donghyeon@192.168.122.11 'cloud-init schema -c ~/kc-lab-2.yaml; rm -f ~/kc-lab-2.yaml'
|
||||||
|
```
|
||||||
|
|
||||||
|
**실측** — 통과하면 이 한 줄이다.
|
||||||
|
|
||||||
|
```
|
||||||
|
Valid cloud-config: /home/donghyeon/kc-lab-edge.yaml
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — 마지막 한 줄. 통과하면 유효하다는 한 줄만 나오고,
|
||||||
|
아니면 `Invalid cloud-config` 아래에 **어느 키가 왜 틀렸는지**가 나열된다.
|
||||||
|
경고(`deprecated`)와 오류(`error`)는 다르다 — 경고는 지금 동작한다.
|
||||||
|
|
||||||
|
> **★ `sudo` 는 리스트가 아니라 문자열로 쓴다.** 이 실험대의 첫 두 게스트는
|
||||||
|
> 이렇게 되어 있었는데, 게스트의 cloud-init 22.4.2 스키마 검사기가 거부한다.
|
||||||
|
>
|
||||||
|
> ```yaml
|
||||||
|
> sudo: ['ALL=(ALL) NOPASSWD:ALL'] # 거부된다
|
||||||
|
> sudo: "ALL=(ALL) NOPASSWD:ALL" # 통과한다
|
||||||
|
> ```
|
||||||
|
>
|
||||||
|
> **실측** — 리스트 형태로 검사하면 이렇게 나온다.
|
||||||
|
>
|
||||||
|
> ```
|
||||||
|
> Error: Cloud config schema errors: users.0: {'name': 'donghyeon', 'groups':
|
||||||
|
> ['sudo'], 'shell': '/bin/bash', ...} is not valid under any of the given schemas
|
||||||
|
> ```
|
||||||
|
>
|
||||||
|
> 어느 키가 문제인지 **안 알려 준다** — `users.0` 전체를 통째로 찍고
|
||||||
|
> 「어느 스키마에도 안 맞는다」고만 한다. 그래서 키를 하나씩 바꿔 가며
|
||||||
|
> 좁혀야 한다. 리스트 형태도 **부팅은 된다**(`kc-lab-1`·`kc-lab-2` 가 그
|
||||||
|
> 상태로 NOPASSWD sudo 가 멀쩡히 돌고 있다). 검사기만 거부하는 것이라
|
||||||
|
> 「검사는 실패했는데 왜 되지」로 헷갈리기 쉽다.
|
||||||
|
|
||||||
|
**이 결과가 의미하는 것** — 오류가 나면 그 키는 **조용히 무시된다.**
|
||||||
|
`users` 를 `user` 로 잘못 쓰면 계정이 안 생기고, 증상은 역시 「SSH 가 안
|
||||||
|
붙는다」 하나뿐이다. 첫 게스트(`kc-lab-1`)를 만들 때는 검사할 게스트가 아직
|
||||||
|
없으니 위 세 줄로 가고, 둘째부터는 이 검사를 거친다.
|
||||||
|
|
||||||
|
세 가지가 의도적이다.
|
||||||
|
|
||||||
|
| | 왜 |
|
||||||
|
| --------------------- | -------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| `NOPASSWD:ALL` | k3s 설치와 장애 주입이 비대화식으로 돌아야 한다. 비밀번호를 물으면 멈춘다 |
|
||||||
|
| `plain_text_passwd` | **cloud-init 이 실패했을 때의 유일한 탈출구.** 키가 안 들어가면 SSH 가 막히므로 콘솔로 들어가 로그를 봐야 한다 |
|
||||||
|
| 키 두 개 | lab host 에서 자동화가 돌고(에이전트 포워딩 없음), 워크스테이션에서는 ProxyJump 로 직접 붙는다 |
|
||||||
|
|
||||||
|
> **들여쓰기는 공백만.** YAML 은 탭을 금지하고, cloud-init 은 파싱에 실패해도
|
||||||
|
> **아무 오류도 남기지 않는다.** 게스트가 `localhost` 로 뜨고 로그인이 안 되는
|
||||||
|
> 것이 유일한 증상이다.
|
||||||
|
|
||||||
|
## 3. 시드 이미지를 만든다
|
||||||
|
|
||||||
|
cloud-init 은 `cidata` 라벨이 붙고 안에 `user-data`·`meta-data` 라는 **정확한
|
||||||
|
이름**의 파일이 있는 볼륨을 찾는다.
|
||||||
|
|
||||||
|
**하기**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
printf 'instance-id: kc-lab-1-%s\nlocal-hostname: kc-lab-1\n' "$(date +%s)" > meta-kc-lab-1
|
||||||
|
|
||||||
|
xorrisofs -quiet -output seed-kc-lab-1.iso -volid CIDATA -joliet -rock \
|
||||||
|
-graft-points /user-data=kc-lab-1.yaml /meta-data=meta-kc-lab-1
|
||||||
|
|
||||||
|
virsh vol-create-as default seed-kc-lab-1.iso "$(stat -c%s seed-kc-lab-1.iso)" --format raw
|
||||||
|
virsh vol-upload --pool default seed-kc-lab-1.iso seed-kc-lab-1.iso
|
||||||
|
```
|
||||||
|
|
||||||
|
**확인** — 볼륨이 풀에 올라갔고 비어 있지 않은가
|
||||||
|
|
||||||
|
```bash
|
||||||
|
virsh vol-list default
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — `seed-kc-lab-1.iso` 행이 목록에 있는가. 있으면
|
||||||
|
크기까지 본다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
virsh vol-info --pool default seed-kc-lab-1.iso
|
||||||
|
```
|
||||||
|
|
||||||
|
**Capacity** 가 방금 만든 로컬 파일 크기(`stat -c%s seed-kc-lab-1.iso`)와
|
||||||
|
같아야 한다.
|
||||||
|
|
||||||
|
**이 결과가 의미하는 것** — `vol-create-as` 는 **빈 볼륨을 만들 뿐**이고
|
||||||
|
내용은 `vol-upload` 가 채운다. 두 명령 중 뒤엣것을 빠뜨리면 목록에는 이름이
|
||||||
|
보이지만 안이 0 으로 채워져 있고, cloud-init 은 `cidata` 라벨을 못 찾아
|
||||||
|
조용히 끝난다 — 증상은 또 「SSH 가 안 붙는다」다. 크기가 맞으면 4번으로 간다.
|
||||||
|
|
||||||
|
`instance-id` 에 타임스탬프를 넣는 이유가 있다. cloud-init 은 **인스턴스마다
|
||||||
|
한 번만** 초기화 모듈을 돌린다. id 가 같으면 이미 한 것으로 보고 건너뛰므로,
|
||||||
|
user-data 를 고쳐도 반영되지 않는다.
|
||||||
|
|
||||||
|
## 4. VM 을 만든다
|
||||||
|
|
||||||
|
**하기**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
virt-install --name kc-lab-1 --memory 5120 --vcpus 2 \
|
||||||
|
--disk size=20,backing_store=/var/lib/libvirt/images/base.qcow2 \
|
||||||
|
--disk vol=default/seed-kc-lab-1.iso,device=disk,bus=virtio,readonly=on \
|
||||||
|
--network network=default,mac=52:54:00:aa:bb:11 \
|
||||||
|
--import --os-variant debian12 --noautoconsole
|
||||||
|
```
|
||||||
|
|
||||||
|
나머지 둘은 **이름·메모리·MAC·시드**만 바꾼다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# kc-lab-2 — k3s agent
|
||||||
|
virt-install --name kc-lab-2 --memory 4096 --vcpus 2 \
|
||||||
|
--disk size=20,backing_store=/var/lib/libvirt/images/base.qcow2 \
|
||||||
|
--disk vol=default/seed-kc-lab-2.iso,device=disk,bus=virtio,readonly=on \
|
||||||
|
--network network=default,mac=52:54:00:aa:bb:12 \
|
||||||
|
--import --os-variant debian12 --noautoconsole
|
||||||
|
|
||||||
|
# kc-lab-edge — nginx + certbot 만 돌므로 훨씬 작아도 된다
|
||||||
|
virt-install --name kc-lab-edge --memory 1024 --vcpus 1 \
|
||||||
|
--disk size=10,backing_store=/var/lib/libvirt/images/base.qcow2 \
|
||||||
|
--disk vol=default/seed-kc-lab-edge.iso,device=disk,bus=virtio,readonly=on \
|
||||||
|
--network network=default,mac=52:54:00:aa:bb:10 \
|
||||||
|
--import --os-variant debian12 --noautoconsole
|
||||||
|
```
|
||||||
|
|
||||||
|
**실측** — 엣지 생성 출력이다.
|
||||||
|
|
||||||
|
```
|
||||||
|
Starting install...
|
||||||
|
Allocating 'kc-lab-edge.qcow2' | 10 GB 00:00
|
||||||
|
Creating domain... | 00:00
|
||||||
|
Domain creation completed.
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — `Domain creation completed.` 한 줄. 그 위
|
||||||
|
`Allocating` 이 **즉시(00:00) 끝나는 것이 정상**이다 — 오버레이라 10GB 를
|
||||||
|
실제로 쓰지 않는다.
|
||||||
|
|
||||||
|
**★ 시드를 `--cloud-init` 으로 붙이지 않는다.** 그 옵션은 시드를 SATA CD-ROM
|
||||||
|
으로 붙이는데, Debian `genericcloud` 이미지는 크기를 줄이려고 물리 하드웨어
|
||||||
|
드라이버를 뺐다. **AHCI 장치가 보이지 않아** cloud-init 이 데이터소스를
|
||||||
|
못 찾고 조용히 끝난다. `bus=virtio` 로 디스크로 붙여야 한다.
|
||||||
|
|
||||||
|
`--disk size=20,backing_store=...` 는 복사가 아니라 **오버레이**다. base 는
|
||||||
|
읽기 전용으로 두고 변경분만 새 파일에 쌓이므로, 20GB 를 두 개 만들어도
|
||||||
|
실제 디스크는 몇백 MB 만 쓴다.
|
||||||
|
|
||||||
|
## 5. DHCP 로 IP 를 고정한다
|
||||||
|
|
||||||
|
MAC 을 정해 두었으니 그 MAC 에 IP 를 예약한다.
|
||||||
|
|
||||||
|
**하기**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
virsh net-update default add ip-dhcp-host \
|
||||||
|
"<host mac='52:54:00:aa:bb:11' name='kc-lab-1' ip='192.168.122.11'/>" \
|
||||||
|
--live --config
|
||||||
|
```
|
||||||
|
|
||||||
|
`--live --config` 둘 다 준다. `--live` 만 주면 재부팅에 사라지고, `--config`
|
||||||
|
만 주면 지금 반영되지 않는다.
|
||||||
|
|
||||||
|
**확인** — 예약이 실제로 들어갔는가
|
||||||
|
|
||||||
|
```bash
|
||||||
|
virsh net-dumpxml default | grep ip-dhcp-host -A3
|
||||||
|
```
|
||||||
|
|
||||||
|
**실측**
|
||||||
|
|
||||||
|
```
|
||||||
|
<range start='192.168.122.2' end='192.168.122.254'/>
|
||||||
|
<host mac='52:54:00:aa:bb:11' name='kc-lab-1' ip='192.168.122.11'/>
|
||||||
|
<host mac='52:54:00:aa:bb:12' name='kc-lab-2' ip='192.168.122.12'/>
|
||||||
|
<host mac='52:54:00:aa:bb:10' name='kc-lab-edge' ip='192.168.122.10'/>
|
||||||
|
```
|
||||||
|
|
||||||
|
**★ 예약을 먼저 넣고 VM 을 띄운다.** 순서가 반대면 게스트가 동적 대역에서
|
||||||
|
아무 주소나 받아 버리고, 예약이 먹으려면 리스 만료를 기다리거나 재부팅해야
|
||||||
|
한다. 넣을 때 나오는 한 줄은 이것이다.
|
||||||
|
|
||||||
|
```
|
||||||
|
Updated network default persistent config and live state
|
||||||
|
```
|
||||||
|
|
||||||
|
`persistent config` 와 `live state` **두 마디가 다 나와야** `--live --config`
|
||||||
|
가 제대로 먹은 것이다.
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — `<host>` 두 줄의 **MAC 끝 두 자리와 IP 끝 숫자가
|
||||||
|
짝이 맞는가**(`:11` ↔ `.11`, `:12` ↔ `.12`). MAC 은 4번의 `virt-install --network mac=` 에 쓴 값과 한 글자도 다르면 안 된다. `<range>` 줄은 예약이
|
||||||
|
아니라 동적 대역이라, 예약 IP 가 이 안에 들어 있어도 상관없다.
|
||||||
|
|
||||||
|
**이 결과가 의미하는 것** — 두 줄이 다 보이면 게스트는 재부팅해도 같은 IP 를
|
||||||
|
받는다. 한 줄만 보이면 `--live --config` 중 하나를 빠뜨린 것이다.
|
||||||
|
`net-dumpxml` 은 **지금 돌고 있는 정의**를 보여 주므로 여기 보이는 것은
|
||||||
|
`--live` 가 먹었다는 뜻이고, 재부팅 뒤에도 남는지는
|
||||||
|
`virsh net-dumpxml --inactive default` 로 따로 본다.
|
||||||
|
|
||||||
|
## 6. 붙어 본다
|
||||||
|
|
||||||
|
**확인** — 떴는가, 그리고 cloud-init 이 제 일을 했는가
|
||||||
|
|
||||||
|
```bash
|
||||||
|
virsh list --all
|
||||||
|
ssh donghyeon@192.168.122.11 'hostname; cat /etc/os-release | head -1'
|
||||||
|
```
|
||||||
|
|
||||||
|
**실측**
|
||||||
|
|
||||||
|
```
|
||||||
|
Id Name State
|
||||||
|
-----------------------------
|
||||||
|
2 kc-lab-1 running
|
||||||
|
4 kc-lab-2 running
|
||||||
|
5 kc-lab-edge running
|
||||||
|
|
||||||
|
kc-lab-1
|
||||||
|
PRETTY_NAME="Debian GNU/Linux 12 (bookworm)"
|
||||||
|
```
|
||||||
|
|
||||||
|
엣지도 같은 방법으로 본다. `cloud-init status` 까지 한 번에 친다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh kc-lab-edge 'hostname; ip -4 -br addr show enp1s0; cloud-init status'
|
||||||
|
```
|
||||||
|
|
||||||
|
**실측**
|
||||||
|
|
||||||
|
```
|
||||||
|
kc-lab-edge
|
||||||
|
enp1s0 UP 192.168.122.10/24 metric 100
|
||||||
|
status: done
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — 세 줄이다. 호스트명이 `kc-lab-edge` 인가, IP 가
|
||||||
|
**예약한 `.10`** 인가, `cloud-init status` 가 `done` 인가. `running` 이면
|
||||||
|
아직 패키지를 받는 중이니 기다린다 — 이 실험대에서는 **약 50초** 걸렸다.
|
||||||
|
`error` 면 `cloud-init status --long` 으로 어느 모듈이 실패했는지 본다.
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — 세 가지다. ① State 가 두 대 다 `running` 인가
|
||||||
|
(`shut off` 면 아직 안 뜬 것이고, Id 칸이 `-` 로 비어 있다). ② SSH 가
|
||||||
|
**비밀번호를 묻지 않고** 통과했는가. ③ `hostname` 이 `kc-lab-1` 인가
|
||||||
|
`localhost` 인가.
|
||||||
|
|
||||||
|
**이 결과가 의미하는 것** — 이 세 줄이 cloud-init 성공의 판정 기준 전부다.
|
||||||
|
호스트명이 바뀌어 있다는 것은 시드가 읽혔다는 뜻이고, 시드가 읽혔으면
|
||||||
|
같은 파일에 있던 SSH 키도 들어갔다는 뜻이다. **`localhost` 가 나오면 SSH
|
||||||
|
설정을 고치지 말고 시드부터 의심한다** — 아래 「막히면」의 화면 캡처로 간다.
|
||||||
|
Id 번호가 2 보다 큰 것은 아무 뜻도 없다(만들고 지운 이력일 뿐이다).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 막히면
|
||||||
|
|
||||||
|
여기서 실제로 겪은 것들이다.
|
||||||
|
|
||||||
|
| 증상 | 원인 | 확인 |
|
||||||
|
| ----------------------------------------------------------------- | ------------------------------------------------------------------------- | ----------------------- |
|
||||||
|
| SSH`Permission denied (publickey)` · hostname 이 `localhost` | **cloud-init 이 안 돌았다.** 시드를 SATA 로 붙였거나 YAML 파싱 실패 | 아래 화면 캡처 |
|
||||||
|
| user-data 를 고쳤는데 반영 안 됨 | `instance-id` 가 같아 건너뜀 | meta-data 의 id 확인 |
|
||||||
|
| IP 가 매번 바뀐다 | DHCP 예약이`--config` 없이 들어감 | `net-dumpxml` |
|
||||||
|
| VM 이 느리다 | KVM 미사용 | [00](../00-lab-host/) 로 |
|
||||||
|
|
||||||
|
게스트에 못 들어갈 때는 **화면을 직접 뜬다.**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
virsh screenshot kc-lab-1 /tmp/kc1.ppm # 확장자와 무관하게 PNG 로 저장된다
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — 이미지를 열어 **로그인 프롬프트 앞의 호스트명 한
|
||||||
|
낱말**만 본다. `localhost login:` 인가 `kc-lab-1 login:` 인가.
|
||||||
|
|
||||||
|
**이 결과가 의미하는 것** — `localhost` 면 cloud-init 이 아예 안 돌았다.
|
||||||
|
시드를 못 찾은 것(4번의 `bus=virtio`)이거나 YAML 파싱 실패(2번)이므로 SSH
|
||||||
|
쪽은 볼 필요가 없다. `kc-lab-1` 이면 cloud-init 은 돌았고 **그 안의 사용자·키
|
||||||
|
단계에서 틀린** 것이라, 이제 콘솔로 들어가 게스트 안의 로그를 본다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
virsh console kc-lab-1 # 빠져나오려면 Ctrl+]
|
||||||
|
# 게스트 안에서
|
||||||
|
sudo cloud-init status --long
|
||||||
|
sudo journalctl -u cloud-init -n 50
|
||||||
|
```
|
||||||
|
|
||||||
|
콘솔 로그인에 쓸 비밀번호가 2번의 `plain_text_passwd` 다. **이 한 장과 이 두
|
||||||
|
줄이 「SSH 가 안 되는 이유」를 절반으로 줄인다.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 실측값
|
||||||
|
|
||||||
|
```
|
||||||
|
kc-lab-1 vCPU 2 메모리 5120MB 192.168.122.11
|
||||||
|
kc-lab-2 vCPU 2 메모리 4096MB 192.168.122.12
|
||||||
|
게스트 OS Debian GNU/Linux 12 (bookworm)
|
||||||
|
```
|
||||||
|
|
||||||
|
> 메모리가 처음 만들 때(3584MB)와 다르다. 호스트가 12GB 뿐이라 실험을 늘리며
|
||||||
|
> 재배분했다. VM 을 다시 만들지 않고 바꾸는 방법은
|
||||||
|
> [`session-lab-concepts.md`](../../session-lab-concepts.md) 13층에 있다.
|
||||||
@@ -0,0 +1,407 @@
|
|||||||
|
# 02 — k3s 두 노드
|
||||||
|
|
||||||
|
## 이 단계가 끝나면
|
||||||
|
|
||||||
|
lab host 에서 `kubectl get nodes` 를 치면 두 노드가 `Ready` 로 나온다.
|
||||||
|
`sudo` 도 `ssh` 도 붙이지 않는다.
|
||||||
|
|
||||||
|
## 전제
|
||||||
|
|
||||||
|
[01](../01-vms/) 이 끝나 lab host 에서 두 게스트에 SSH 가 붙는다.
|
||||||
|
|
||||||
|
## 어디서 치는가
|
||||||
|
|
||||||
|
**이 단계는 전부 `[lab host]` 에서 친다.** 게스트에 로그인하지 않는다.
|
||||||
|
자세한 이유는 [가이드 공통 규약](../README.md#어느-기계에서-치는가) 에 있고,
|
||||||
|
요점만 옮기면 이렇다.
|
||||||
|
|
||||||
|
- 게스트에는 lab host 의 개인키도 `~/.ssh/config` 도 없다. 게스트 안에서
|
||||||
|
`ssh kc-lab-1` 을 치면 `Host key verification failed` 로 끝난다.
|
||||||
|
- 그 실패를 `TOKEN=$(...)` 로 감싸면 **오류는 화면으로 새고 변수는 빈 채로**
|
||||||
|
남는다. 셸은 불평하지 않는다.
|
||||||
|
- 그래서 3번의 토큰이 비고, 4번의 agent 설치가 `--token is required` 로
|
||||||
|
죽는다. 설치 스크립트는 그 전까지를 다 성공으로 찍고 끝나기 때문에
|
||||||
|
**설치 출력만 보면 성공으로 읽힌다.**
|
||||||
|
|
||||||
|
셸을 하나만 쓰면 이 문제가 통째로 없어진다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. server 를 깐다 (kc-lab-1)
|
||||||
|
|
||||||
|
**하기** — `[lab host]`
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh kc-lab-1 'curl -sfL https://get.k3s.io | sudo sh -s - server --node-ip 192.168.122.11'
|
||||||
|
```
|
||||||
|
|
||||||
|
게스트에 들어가지 않고 원격 실행한다. cloud-init 이 `NOPASSWD:ALL` 을
|
||||||
|
넣어 두었으므로 비대화식 `sudo` 가 멈추지 않는다.
|
||||||
|
|
||||||
|
`--node-ip` 를 준다. 게스트에 인터페이스가 여럿이면 k3s 가 엉뚱한 것을 고를 수
|
||||||
|
있고, 그러면 두 노드가 서로를 다른 주소로 알게 된다.
|
||||||
|
|
||||||
|
**확인** — 서버가 떴고 자기 자신을 노드로 등록했는가
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh kc-lab-1 'sudo systemctl is-active k3s; sudo kubectl get nodes'
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — 유닛이 `active` 인가, 그리고 `get nodes` 에
|
||||||
|
`kc-lab-1` 한 줄이 `Ready` 로 있는가. **설치 직후 30초 남짓은 `NotReady`
|
||||||
|
이거나 아예 목록이 비어 있는 것이 정상이다** — CNI 가 아직 안 올라온
|
||||||
|
시간이다. 한 번 더 친다.
|
||||||
|
|
||||||
|
이 가이드에서 `kubectl` 을 게스트 쪽에서 돌리는 것은 여기 한 번뿐이다.
|
||||||
|
lab host 에는 아직 kubeconfig 가 없기 때문이고, 그것을 두는 것이 바로 2번이다.
|
||||||
|
|
||||||
|
**이 결과가 의미하는 것** — `active` + `Ready` 면 API 서버가 살아 있고
|
||||||
|
kubeconfig 도 자리를 잡았다는 뜻이라 2번으로 간다. 유닛이 `active` 인데
|
||||||
|
`get nodes` 가 접속 오류를 내면 API 서버가 아직 기동 중이다. 유닛이
|
||||||
|
`activating` 에서 안 넘어가거나 `failed` 면 설치 자체가 실패한 것이니
|
||||||
|
로그를 본다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh kc-lab-1 'sudo journalctl -u k3s -n 50 --no-pager'
|
||||||
|
```
|
||||||
|
|
||||||
|
## 2. lab host 에 kubeconfig 를 둔다
|
||||||
|
|
||||||
|
**왜 여기서 하나** — 이 뒤로 `kubectl` 을 계속 쓴다. 지금 한 번 해두면
|
||||||
|
남은 단계에서 `ssh` 도 `sudo` 도 붙이지 않는다. **그리고 agent 노드에는
|
||||||
|
kubeconfig 가 없으므로**(6번) 클러스터를 볼 자리를 먼저 정해 두는 편이 낫다.
|
||||||
|
|
||||||
|
**하기** — `[lab host]`
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p ~/.kube
|
||||||
|
ssh kc-lab-1 'sudo cat /etc/rancher/k3s/k3s.yaml' \
|
||||||
|
| sed 's|127.0.0.1|192.168.122.11|' > ~/.kube/config
|
||||||
|
chmod 600 ~/.kube/config
|
||||||
|
```
|
||||||
|
|
||||||
|
세 줄 다 필요하다.
|
||||||
|
|
||||||
|
| 줄 | 빠뜨리면 |
|
||||||
|
| -------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| `mkdir -p ~/.kube` | `>` 는 파일을 열 뿐 경로를 만들지 않는다. `cat` 이 시작되기도 전에 `No such file or directory` 로 끝난다 |
|
||||||
|
| `sed` | k3s 가 쓴 주소는`https://127.0.0.1:6443` 이다. **게스트 안에서만 맞는 주소**라 lab host 에서는 자기 자신의 6443 을 두드리게 된다 |
|
||||||
|
| `chmod 600` | 이 파일은 클러스터 admin 자격증명이다. 비밀번호 파일과 같은 급으로 다룬다 |
|
||||||
|
|
||||||
|
> `sudo` 는 `cat` 에만 걸리고 `>` 에는 걸리지 않는다. 리다이렉션은 셸이
|
||||||
|
> **명령보다 먼저** 현재 사용자 권한으로 처리하기 때문이다. 그래서 출력
|
||||||
|
> 파일은 홈 아래(`~/.kube/config`)에 둔다.
|
||||||
|
|
||||||
|
**확인** — 주소가 바뀌었고, 밖에서 붙는가
|
||||||
|
|
||||||
|
```bash
|
||||||
|
grep server: ~/.kube/config
|
||||||
|
kubectl get nodes
|
||||||
|
```
|
||||||
|
|
||||||
|
**실측**
|
||||||
|
|
||||||
|
```
|
||||||
|
server: https://192.168.122.11:6443
|
||||||
|
NAME STATUS ROLES AGE VERSION
|
||||||
|
kc-lab-1 Ready control-plane 47m v1.36.4+k3s1
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — `server:` 값에 `127.0.0.1` 이 남아 있으면 `sed` 가
|
||||||
|
안 먹은 것이다. 그다음 `get nodes` 가 **`sudo` 없이** 도는가. 아직 노드는
|
||||||
|
한 줄뿐인 것이 정상이다 — agent 는 4번에서 붙인다.
|
||||||
|
|
||||||
|
**이 결과가 의미하는 것** — 통과하면 이 뒤의 `kubectl` 은 전부 lab host 에서
|
||||||
|
친다. `x509` 오류가 나면 파일 안의 CA 와 서버가 지금 쓰는 CA 가 어긋난
|
||||||
|
것이다 — k3s 를 다시 깔았다면 이 복사도 다시 해야 한다. `connection refused`
|
||||||
|
면 주소는 맞는데 API 서버가 아직 안 뜬 것이다.
|
||||||
|
|
||||||
|
> **인증서 SAN 에 두 주소가 다 들어 있어서** 이 `sed` 가 통한다. 확인하려면
|
||||||
|
> `ssh kc-lab-1 'sudo openssl x509 -in /var/lib/rancher/k3s/server/tls/serving-kube-apiserver.crt -noout -ext subjectAltName'`
|
||||||
|
> 을 친다. `IP Address:127.0.0.1` 과 `IP Address:192.168.122.11` 이 둘 다
|
||||||
|
> 보인다. 8번의 SSH 터널이 `127.0.0.1` 로 붙을 수 있는 것도 같은 이유다.
|
||||||
|
|
||||||
|
## 3. 토큰을 꺼낸다
|
||||||
|
|
||||||
|
**하기** — `[lab host]`. 화면에 찍어 눈으로 옮기지 말고 변수로 받는다
|
||||||
|
|
||||||
|
```bash
|
||||||
|
TOKEN=$(ssh kc-lab-1 'sudo cat /var/lib/rancher/k3s/server/node-token')
|
||||||
|
echo "${#TOKEN} 자" # 값이 아니라 길이만 확인한다
|
||||||
|
```
|
||||||
|
|
||||||
|
**실측** — 이 실험대에서는 108자였다. (`K10<해시>::server:<비밀번호>` 형식이라
|
||||||
|
k3s 판올림에 따라 자릿수가 달라진다. **중요한 것은 값이 아니라 `0` 이 아니라는
|
||||||
|
것이다.**)
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — 찍히는 것은 **자릿수 하나뿐**이다. 값은 보지
|
||||||
|
않는다 — 화면에 띄우는 순간 터미널 스크롤백과 셸 히스토리에 남는다.
|
||||||
|
|
||||||
|
**이 결과가 의미하는 것** — 세 자리 수가 나오면 토큰을 손에 쥔 것이니 4번으로
|
||||||
|
넘어간다. `0` 이면 변수가 비었다는 뜻이고 원인은 셋 중 하나다.
|
||||||
|
|
||||||
|
| `0` 인 이유 | 확인 |
|
||||||
|
| ------------------------------------------ | ---------------------------------------------------------------------- |
|
||||||
|
| **게스트 안에서 쳤다** (가장 흔하다) | 프롬프트가`kc-lab-1` 이면 `exit` 로 lab host 로 나온다 |
|
||||||
|
| server 가 아직 안 떠서 파일이 없다 | `ssh kc-lab-1 'sudo ls -l /var/lib/rancher/k3s/server/node-token'` |
|
||||||
|
| 새 셸을 열어 변수가 사라졌다 | `echo "${#TOKEN} 자"` 를 **4번을 칠 바로 그 셸에서** 다시 친다 |
|
||||||
|
|
||||||
|
> **4번을 3번과 같은 셸에서 친다.** 변수는 셸 밖으로 나가지 않는다. 창을
|
||||||
|
> 새로 열거나 `ssh` 로 어딘가 들어갔다 나오면 `TOKEN` 은 없다.
|
||||||
|
|
||||||
|
## 4. agent 를 붙인다 (kc-lab-2)
|
||||||
|
|
||||||
|
**하기** — `[lab host]`. 3번과 **같은 셸**에서 친다
|
||||||
|
|
||||||
|
```bash
|
||||||
|
[ ${#TOKEN} -ge 50 ] || echo "TOKEN 이 비었다 — 3번으로 돌아간다"
|
||||||
|
|
||||||
|
ssh kc-lab-2 "curl -sfL https://get.k3s.io | sudo sh -s - agent \
|
||||||
|
--server https://192.168.122.11:6443 \
|
||||||
|
--token '$TOKEN' \
|
||||||
|
--node-ip 192.168.122.12"
|
||||||
|
```
|
||||||
|
|
||||||
|
첫 줄의 가드를 빼지 않는다. `$TOKEN` 이 비면 게스트에는
|
||||||
|
`--token '' --node-ip ...` 가 전달되고, agent 는 기동 즉시
|
||||||
|
`level=fatal msg="Error: --token is required"` 로 죽는다. **그런데 설치
|
||||||
|
스크립트는 내려받기·유닛 생성·`enable` 까지 다 성공으로 찍고 끝나고**, 유닛은
|
||||||
|
`Restart=always` 라 5초마다 조용히 재시도한다. 가드 한 줄이 그 몇 분을 막는다.
|
||||||
|
|
||||||
|
> 토큰을 셸 히스토리에 남기고 싶지 않으면 파일로 넘긴다. 이러면 변수를 쓰지
|
||||||
|
> 않으므로 3번의 「같은 셸」 제약도 없어진다.
|
||||||
|
>
|
||||||
|
> ```bash
|
||||||
|
> ssh kc-lab-1 'sudo cat /var/lib/rancher/k3s/server/node-token' \
|
||||||
|
> | ssh kc-lab-2 'sudo tee /tmp/token >/dev/null'
|
||||||
|
> ssh kc-lab-2 "curl -sfL https://get.k3s.io | sudo sh -s - agent \
|
||||||
|
> --server https://192.168.122.11:6443 --token-file /tmp/token \
|
||||||
|
> --node-ip 192.168.122.12; rm -f /tmp/token"
|
||||||
|
> ```
|
||||||
|
|
||||||
|
**확인** — agent 가 클러스터에 들어왔는가, 그리고 **제 주소로** 들어왔는가
|
||||||
|
|
||||||
|
```bash
|
||||||
|
kubectl get nodes -o wide
|
||||||
|
```
|
||||||
|
|
||||||
|
**실측**
|
||||||
|
|
||||||
|
```
|
||||||
|
NAME STATUS ROLES AGE VERSION INTERNAL-IP
|
||||||
|
kc-lab-1 Ready control-plane 47m v1.36.4+k3s1 192.168.122.11
|
||||||
|
kc-lab-2 Ready <none> 21m v1.36.4+k3s1 192.168.122.12
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — `-o wide` 를 준 이유가 마지막 열이다. **INTERNAL-IP
|
||||||
|
두 개가 1번·4번에서 `--node-ip` 로 준 값과 같은가.** 그다음이 STATUS 두 줄
|
||||||
|
`Ready`, 그다음이 ROLES 열이다. `<none>` 은 오류가 아니라 **역할 라벨이
|
||||||
|
없다**는 뜻이다 — agent 는 원래 그렇다.
|
||||||
|
|
||||||
|
**이 결과가 의미하는 것** — 두 줄이 `Ready` 이고 IP 가 맞으면 이 단계는 끝났다.
|
||||||
|
IP 가 다른 대역(예: flannel 이나 다른 인터페이스 주소)으로 잡혀 있으면
|
||||||
|
지금은 아무 증상이 없다가 **03 의 nginx upstream 과 A층의 노드 상실
|
||||||
|
실험에서** 어긋난다 — 그때 고치는 것보다 지금 재설치가 싸다. `kc-lab-2` 가
|
||||||
|
아예 안 보이면 join 이 실패한 것이니 agent 쪽 로그를 본다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh kc-lab-2 'sudo journalctl -u k3s-agent -n 30 --no-pager'
|
||||||
|
```
|
||||||
|
|
||||||
|
## 5. 유닛 이름이 다르다
|
||||||
|
|
||||||
|
| 노드 | 유닛 |
|
||||||
|
| ------ | --------------------- |
|
||||||
|
| server | `k3s.service` |
|
||||||
|
| agent | `k3s-agent.service` |
|
||||||
|
|
||||||
|
**확인** — 어느 노드가 무슨 이름으로, 어떤 인자로 돌고 있는가
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh kc-lab-1 'systemctl cat k3s | grep -A3 ExecStart='
|
||||||
|
ssh kc-lab-2 'systemctl cat k3s-agent | grep -A4 ExecStart='
|
||||||
|
```
|
||||||
|
|
||||||
|
**실측**
|
||||||
|
|
||||||
|
```
|
||||||
|
ExecStart=/usr/local/bin/k3s server '--node-ip' '192.168.122.11'
|
||||||
|
ExecStart=/usr/local/bin/k3s agent '--node-ip' '192.168.122.12'
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — `ExecStart=` 줄의 **부분명령(`server`/`agent`)과
|
||||||
|
그 뒤의 인자**. 설치 스크립트에 준 옵션이 여기 그대로 굳어 있다. 유닛 이름을
|
||||||
|
틀리면(`systemctl cat k3s` 를 agent 노드에서) `No files found` 가 나오는데,
|
||||||
|
그것 자체가 「이 노드는 agent 다」라는 답이다.
|
||||||
|
|
||||||
|
**이 결과가 의미하는 것** — 4번의 `get nodes -o wide` 는 **k3s 가 보고한**
|
||||||
|
IP 이고, 이 줄은 **우리가 준** IP 다. 둘이 다르면 옵션이 안 먹은 것이다.
|
||||||
|
그리고 뒤의 실험에서 노드를 멈출 때 칠 유닛 이름이 노드마다 다르다는 것을
|
||||||
|
여기서 확인해 둔다 — `systemctl stop k3s` 를 agent 노드에서 치면 아무 일도
|
||||||
|
일어나지 않고, 「주입했는데 증상이 없다」로 오독하게 된다.
|
||||||
|
|
||||||
|
> 이 차이가 A-4 에서 결과를 갈랐다. server 노드를 잃으면 `kubectl` 자체가
|
||||||
|
> 불통이 되고, agent 를 잃으면 `kubectl` 은 되지만 그 위의 워크로드가 사라진다.
|
||||||
|
|
||||||
|
## 6. agent 노드에서는 `kubectl` 이 안 된다
|
||||||
|
|
||||||
|
`kc-lab-2` 에 들어가 `sudo kubectl get pods -A` 를 치면 이렇게 끝난다.
|
||||||
|
|
||||||
|
```
|
||||||
|
Get "http://localhost:8080/api?timeout=32s": dial tcp [::1]:8080: connect: connection refused
|
||||||
|
```
|
||||||
|
|
||||||
|
**`kubectl` 명령 자체는 있다.** 설치 스크립트가
|
||||||
|
`/usr/local/bin/kubectl -> k3s` 심볼릭 링크를 만들기 때문이다. 없는 것은
|
||||||
|
**붙을 곳을 알려 주는 파일**, 곧 kubeconfig 다.
|
||||||
|
|
||||||
|
| 어디를 찾나 | kc-lab-1 | kc-lab-2 |
|
||||||
|
| ----------------------------- | -------------- | -------------- |
|
||||||
|
| `$KUBECONFIG` | (비어 있음) | (비어 있음) |
|
||||||
|
| `~/.kube/config` | 없음 | 없음 |
|
||||||
|
| `/etc/rancher/k3s/k3s.yaml` | **있음** | **없음** |
|
||||||
|
|
||||||
|
넷 다 못 찾으면 kubectl 은 오류를 내지 않고 하드코딩된 기본값
|
||||||
|
`http://localhost:8080` 으로 넘어간다. 쿠버네티스 1.20 이전 API 서버가
|
||||||
|
평문으로 열던 레거시 포트인데 지금은 아무도 열지 않는다.
|
||||||
|
|
||||||
|
> **`localhost:8080` 이 보이면 네트워크 문제가 아니라 「설정을 하나도 못
|
||||||
|
> 찾았다」는 뜻이다.** 이 주소는 어디에도 적혀 있지 않다. 방화벽이나 k3s 를
|
||||||
|
> 의심하기 전에 kubeconfig 부터 본다.
|
||||||
|
|
||||||
|
**워커라서 파드가 안 보이는 것이 아니다.** kubectl 은 그냥 HTTP 클라이언트라
|
||||||
|
어디서 실행하든 상관없다. `k3s.yaml` 을 kc-lab-2 로 복사해 넣으면 거기서도
|
||||||
|
전부 보인다. **그래도 복사하지 않는다** — 워커 한 대가 털리면 클러스터 전체가
|
||||||
|
털리는 구성이 된다. agent 가 가진 자격증명은 급이 다르다.
|
||||||
|
|
||||||
|
```
|
||||||
|
subject=O = system:nodes, CN = system:node:kc-lab-2
|
||||||
|
```
|
||||||
|
|
||||||
|
이 신원은 Node authorizer 와 NodeRestriction admission 이 **자기 노드에
|
||||||
|
배정된 객체만** 다루도록 제한한다. 게다가 그 자격증명은 kubelet 전용 경로
|
||||||
|
(`/var/lib/rancher/k3s/agent/`)에 있어 kubectl 이 읽지도 않는다. 그래서 실패가
|
||||||
|
「권한 없음(403)」이 아니라 「설정 없음(`localhost:8080`)」으로 나타난다.
|
||||||
|
|
||||||
|
**그래서 클러스터는 2번에서 만든 lab host 의 kubeconfig 로 본다.** 개념
|
||||||
|
설명은 [`session-lab-concepts.md`](../../session-lab-concepts.md) 의
|
||||||
|
「agent 노드에는 kubeconfig 가 없다」에 있다.
|
||||||
|
|
||||||
|
## 7. k3s 가 기본으로 딸려 오는 것
|
||||||
|
|
||||||
|
따로 설치하지 않아도 이미 있다.
|
||||||
|
|
||||||
|
| | 무엇 |
|
||||||
|
| ---------------------- | -------------------------------------------- |
|
||||||
|
| Traefik | 인그레스 컨트롤러.`:80` 을 듣는다 |
|
||||||
|
| servicelb (klipper-lb) | LoadBalancer 타입을 호스트 포트로 매핑 |
|
||||||
|
| local-path | 기본 StorageClass.**노드 로컬 디스크** |
|
||||||
|
| flannel | 파드 네트워크 (VXLAN) |
|
||||||
|
| kube-router | NetworkPolicy 집행 |
|
||||||
|
|
||||||
|
**확인** — `[lab host]`. 무엇이 이미 돌고 있고, 기본 저장소가 무엇인가
|
||||||
|
|
||||||
|
```bash
|
||||||
|
kubectl get pods -A
|
||||||
|
kubectl get storageclass
|
||||||
|
```
|
||||||
|
|
||||||
|
**실측**
|
||||||
|
|
||||||
|
```
|
||||||
|
NAMESPACE NAME READY STATUS RESTARTS AGE
|
||||||
|
kube-system coredns-54996dc9b4-x5bsz 1/1 Running 0 49m
|
||||||
|
kube-system helm-install-traefik-cnv8z 0/1 Completed 2 (48m ago) 48m
|
||||||
|
kube-system helm-install-traefik-crd-c6vft 0/1 Completed 0 48m
|
||||||
|
kube-system local-path-provisioner-77b9867795-5k8d2 1/1 Running 0 49m
|
||||||
|
kube-system metrics-server-6dc596dfb8-nzwt2 1/1 Running 0 49m
|
||||||
|
kube-system svclb-traefik-a18ee1fc-2s9rl 2/2 Running 0 48m
|
||||||
|
kube-system svclb-traefik-a18ee1fc-xmrrt 2/2 Running 0 22m
|
||||||
|
kube-system traefik-59b7647586-rsrbc 1/1 Running 0 48m
|
||||||
|
|
||||||
|
NAME PROVISIONER RECLAIMPOLICY VOLUMEBINDINGMODE ALLOWVOLUMEEXPANSION AGE
|
||||||
|
local-path (default) rancher.io/local-path Delete WaitForFirstConsumer false 49m
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — `get pods -A` 에서는 **NAMESPACE 열이 `kube-system`
|
||||||
|
인 줄들의 STATUS**. `Running` 과 `Completed` 가 섞여 있는 것이 정상이다 —
|
||||||
|
`helm-install-traefik-*` 는 일회성 잡이라 `Completed` 로 남는다.
|
||||||
|
`svclb-traefik-*` 가 **두 줄**인 것도 봐 둔다. DaemonSet 이라 노드마다 하나씩
|
||||||
|
뜨는 것이고, 이 두 줄이 4번의 join 이 실제로 먹었다는 또 하나의 증거다.
|
||||||
|
`get storageclass` 에서는 이름 뒤의 **`(default)` 표시**가 어디 붙어 있는가.
|
||||||
|
|
||||||
|
**이 결과가 의미하는 것** — 여기 뜬 것들은 우리가 안 깔았는데 있는 것이고,
|
||||||
|
뒤 단계에서 「왜 80 포트가 이미 잡혀 있지」·「왜 PVC 가 이 노드에만 묶이지」의
|
||||||
|
답이 전부 이 목록에 있다. `local-path` 에 `(default)` 가 붙어 있으면
|
||||||
|
05 의 PVC 는 StorageClass 를 안 적어도 이것으로 만들어진다. `Pending` 이나
|
||||||
|
`CrashLoopBackOff` 가 섞여 있으면 그 파드부터 `describe` 로 본다.
|
||||||
|
|
||||||
|
> `local-path` 가 기본이라는 것이 A-4 에서 비용을 청구한다. PVC 가 **만들어진
|
||||||
|
> 노드에 묶여** 다른 노드로 재배치되지 않는다.
|
||||||
|
|
||||||
|
## 8. 워크스테이션에서 쓰려면
|
||||||
|
|
||||||
|
**2번의 kubeconfig 를 그대로 가져와도 안 된다.** 게스트는 libvirt NAT 안에
|
||||||
|
있어서 워크스테이션에서 `192.168.122.11` 로 가는 경로가 없다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
[워크스테이션] $ ping -c1 192.168.122.11
|
||||||
|
1 packets transmitted, 0 received, 100% packet loss
|
||||||
|
```
|
||||||
|
|
||||||
|
lab host 를 거치는 터널을 뚫는다.
|
||||||
|
|
||||||
|
**하기** — `[워크스테이션]`
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1) 터널. 이 창은 열어 둔다
|
||||||
|
ssh -N -L 6443:192.168.122.11:6443 test-server
|
||||||
|
|
||||||
|
# 2) 다른 창에서 — 게스트 원본을 그대로 가져온다. sed 가 필요 없다
|
||||||
|
mkdir -p ~/.kube
|
||||||
|
ssh test-server "ssh kc-lab-1 'sudo cat /etc/rancher/k3s/k3s.yaml'" > ~/.kube/kc-lab.yaml
|
||||||
|
chmod 600 ~/.kube/kc-lab.yaml
|
||||||
|
export KUBECONFIG=~/.kube/kc-lab.yaml
|
||||||
|
```
|
||||||
|
|
||||||
|
**2번과 달리 `sed` 를 치지 않는다.** k3s 원본이 이미
|
||||||
|
`https://127.0.0.1:6443` 이고, 터널 덕에 워크스테이션에서는 그 주소가 맞기
|
||||||
|
때문이다. 2번이 `192.168.122.11` 로 바꿨던 것은 lab host 에서 볼 때
|
||||||
|
`127.0.0.1` 이 lab host 자신을 가리키기 때문이었다. **같은 파일이라도 어느
|
||||||
|
기계에서 읽느냐에 따라 맞는 주소가 다르다.**
|
||||||
|
|
||||||
|
**확인**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
grep server: ~/.kube/kc-lab.yaml
|
||||||
|
kubectl get nodes
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — `server:` 가 `https://127.0.0.1:6443` 인가. 그다음
|
||||||
|
`get nodes` 가 4번과 **같은 두 줄**을 내놓는가.
|
||||||
|
|
||||||
|
**이 결과가 의미하는 것** — 터널 덕에 워크스테이션의 6443 이 `kc-lab-1` 의
|
||||||
|
6443 이다. API 서버 인증서 SAN 에 `127.0.0.1` 이 들어 있어서(2번의 각주)
|
||||||
|
인증서 검증도 통과한다. 타임아웃이면 1)의 터널 창이 닫힌 것이고,
|
||||||
|
`connection refused` 면 터널은 살아 있는데 반대편 API 서버가 죽은 것이다.
|
||||||
|
|
||||||
|
> **터널이 닫히면 `kubectl` 이 통째로 멎는다.** 이 실험대의 A층 실험은 노드를
|
||||||
|
> 죽이고 살리는 것이 목적이라, 터널 상태와 클러스터 상태가 섞여 오독하기
|
||||||
|
> 쉽다. **A층 실험은 lab host 에서 치는 것을 권한다.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 막히면
|
||||||
|
|
||||||
|
| 증상 | 원인 | 확인 |
|
||||||
|
| -------------------------------------------- | ------------------------------------------------ | ------------------------------------------------------------------------------------------- |
|
||||||
|
| `echo "${#TOKEN} 자"` 가 `0` | 게스트 안에서 쳤거나, 셸이 바뀌었다 | 프롬프트가`kc-lab-1` 이 아닌지. 3번 표 |
|
||||||
|
| agent 설치는 성공했는데 노드가 안 보임 | `--token` 이 빈 문자열 | `ssh kc-lab-2 'sudo journalctl -u k3s-agent -n 30 --no-pager'` 에 `--token is required` |
|
||||||
|
| agent 가`NotReady` | 토큰·주소 오타 | `ssh kc-lab-2 'sudo journalctl -u k3s-agent -n 30 --no-pager'` |
|
||||||
|
| 노드 IP 가 예상과 다름 | `--node-ip` 없이 설치 | `kubectl get nodes -o wide` |
|
||||||
|
| lab host 에서`kubectl` 이 `No such file` | `mkdir -p ~/.kube` 를 빠뜨렸다 | 위 2번 |
|
||||||
|
| lab host 에서`connection refused` | kubeconfig 의`127.0.0.1` 을 안 바꿨다 | `grep server: ~/.kube/config` |
|
||||||
|
| `kc-lab-2` 에서 `localhost:8080` | agent 에는 kubeconfig 가 없다.**정상이다** | 위 6번 |
|
||||||
|
| 워크스테이션에서 타임아웃 | 터널이 없다 | 위 8번 |
|
||||||
|
| `x509` 오류 | k3s 재설치로 CA 가 바뀌었다 | 2번의 복사를 다시 한다 |
|
||||||
|
| 파드가 한 노드에만 몰림 | 스케줄러 판단 | `topologySpreadConstraints` 로 강제 |
|
||||||
@@ -0,0 +1,391 @@
|
|||||||
|
# 03 — 엣지 nginx 라우팅 (kc-lab-edge)
|
||||||
|
|
||||||
|
## 이 단계가 끝나면
|
||||||
|
|
||||||
|
밖에서 보낸 요청이 **호스트 DNAT → 엣지 nginx → Traefik → 파드**로 닿는다.
|
||||||
|
아직 TLS 는 없다.
|
||||||
|
|
||||||
|
## 전제
|
||||||
|
|
||||||
|
[02](../02-k3s/) 가 끝나 두 노드가 `Ready` 이고, [01](../01-vms/) 에서
|
||||||
|
`kc-lab-edge`(192.168.122.10) 까지 세 대가 떠 있다.
|
||||||
|
|
||||||
|
## 왜 프록시가 두 겹인가
|
||||||
|
|
||||||
|
nginx 와 Traefik 이 하는 일이 다르다.
|
||||||
|
|
||||||
|
| | 맡는 것 |
|
||||||
|
|---|---|
|
||||||
|
| 엣지 nginx (`kc-lab-edge`) | 바깥세상과의 접점 — TLS 종단 · 인증서 · `X-Forwarded-*` |
|
||||||
|
| Traefik | 클러스터 안의 동적 라우팅 — Ingress 를 보고 서비스를 고른다 |
|
||||||
|
|
||||||
|
**이 2홉이 운영 구조와 같다는 것이 이 배치의 핵심**이고, 동시에 B-4 의 헤더
|
||||||
|
실험이 성립하는 이유다. 1홉을 가정하고 쓴 계약이 2홉에서도 유효한지를
|
||||||
|
재려면 두 겹이 있어야 한다.
|
||||||
|
|
||||||
|
## 왜 엣지가 물리 호스트가 아니라 VM 인가
|
||||||
|
|
||||||
|
**L7 홉 수는 그대로 2홉이다.** 늘어난 것은 커널이 하는 L4 전달 한 번뿐이다.
|
||||||
|
|
||||||
|
바뀐 것은 **더러워지는 층이 어디냐**다. nginx 설정 · 인증서 · certbot · deploy
|
||||||
|
훅은 자주 고치고 자주 갈아엎는 것들인데, 그것이 물리 호스트에 있으면
|
||||||
|
「깨끗하게 초기화하고 다시」가 불가능하다. 엣지가 VM 이면 초기화가
|
||||||
|
`virsh undefine kc-lab-edge --remove-all-storage` 한 줄이 된다.
|
||||||
|
|
||||||
|
물리 호스트에 남는 실험대 설정은 **DNAT 규칙 하나와 DHCP 예약 세 줄**뿐이고,
|
||||||
|
둘 다 한 번 쓰고 다시 안 건드린다.
|
||||||
|
|
||||||
|
덤으로 **엣지 장애를 실험할 수 있게 된다.** 엣지가 물리 호스트일 때는
|
||||||
|
`systemctl stop nginx` 가 진입 경로(SSH)까지 위험하게 만들어서 A층 실험 9건
|
||||||
|
어디에도 엣지 장애가 없었다. VM 이면 A-4 와 똑같이 `virsh destroy` 로 뽑는다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 설정을 쓴다
|
||||||
|
|
||||||
|
원본은 [`deploy/lab/edge/nginx-keycloak-lab.conf`](../../../deploy/lab/edge/nginx-keycloak-lab.conf).
|
||||||
|
|
||||||
|
**하기** — `[kc-lab-edge]`. lab host 에서 원격 실행해도 된다.
|
||||||
|
```bash
|
||||||
|
sudo tee /etc/nginx/sites-available/keycloak-lab > /dev/null <<'EOF'
|
||||||
|
upstream k3s_traefik {
|
||||||
|
server 192.168.122.11:80;
|
||||||
|
server 192.168.122.12:80;
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 80 default_server;
|
||||||
|
server_name _;
|
||||||
|
return 301 https://$host$request_uri;
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 443 ssl default_server;
|
||||||
|
http2 on;
|
||||||
|
server_name _;
|
||||||
|
|
||||||
|
ssl_certificate /etc/letsencrypt/live/auth.hyeonworks.com/fullchain.pem;
|
||||||
|
ssl_certificate_key /etc/letsencrypt/live/auth.hyeonworks.com/privkey.pem;
|
||||||
|
ssl_protocols TLSv1.2 TLSv1.3;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://k3s_traefik;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Forwarded-Host $host;
|
||||||
|
proxy_set_header X-Forwarded-Proto https;
|
||||||
|
proxy_set_header X-Forwarded-Port 443;
|
||||||
|
proxy_set_header X-Forwarded-For $remote_addr;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_read_timeout 3600s;
|
||||||
|
proxy_send_timeout 3600s;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
```
|
||||||
|
|
||||||
|
> **인증서 경로는 아직 없다.** [04](../04-tls/) 에서 만든다. 그전까지는 443
|
||||||
|
> 블록을 주석 처리하고 80 만 `proxy_pass` 로 두면 이 단계를 먼저 확인할 수 있다.
|
||||||
|
|
||||||
|
**엣지가 Debian 이라 `sites-available` 관례가 기본으로 있다.** 물리 호스트
|
||||||
|
(Arch) 였을 때는 디렉터리를 직접 만들고 `nginx.conf` 에 include 를 넣어야
|
||||||
|
했는데, 그 손질이 없어졌다. 운영도 Debian 계열이라 관례가 맞아떨어진다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh kc-lab-edge '
|
||||||
|
sudo ln -sf /etc/nginx/sites-available/keycloak-lab /etc/nginx/sites-enabled/
|
||||||
|
sudo rm -f /etc/nginx/sites-enabled/default'
|
||||||
|
```
|
||||||
|
|
||||||
|
`default` 를 지우는 것을 빠뜨리지 않는다. Debian 기본 사이트가 `:80` 에
|
||||||
|
`default_server` 로 붙어 있어서, 우리 설정의 `listen 80 default_server` 와
|
||||||
|
**충돌해 `nginx -t` 가 실패한다.**
|
||||||
|
|
||||||
|
> **★ `http2 on;` 을 쓰지 않는다.** 그 지시어는 nginx 1.25.1 이상이다.
|
||||||
|
>
|
||||||
|
> ```
|
||||||
|
> 엣지 (Debian 12): nginx version: nginx/1.22.1
|
||||||
|
> 물리 호스트 (Arch): nginx version: nginx/1.30.4
|
||||||
|
> ```
|
||||||
|
>
|
||||||
|
> 물리 호스트에서 쓰던 설정을 그대로 옮기면 **실측으로 이렇게 막힌다.**
|
||||||
|
>
|
||||||
|
> ```
|
||||||
|
> [emerg] 1339#1339: unknown directive "http2" in /etc/nginx/sites-enabled/keycloak-lab:29
|
||||||
|
> nginx: configuration file /etc/nginx/nginx.conf test failed
|
||||||
|
> ```
|
||||||
|
>
|
||||||
|
> `listen 443 ssl http2;` 형태를 쓴다 — 1.22 와 1.30 양쪽에서 다 돈다.
|
||||||
|
|
||||||
|
## 2. 문법을 보고 적용한다
|
||||||
|
|
||||||
|
**하기** — `[kc-lab-edge]`
|
||||||
|
```bash
|
||||||
|
sudo nginx -t && sudo systemctl reload nginx
|
||||||
|
```
|
||||||
|
|
||||||
|
**실측** — 04 를 아직 안 했다면 여기서 이렇게 막히는 것이 **정상**이다.
|
||||||
|
설정은 맞는데 참조하는 파일이 아직 없는 것뿐이다.
|
||||||
|
|
||||||
|
```
|
||||||
|
[emerg] 1431#1431: cannot load certificate
|
||||||
|
"/etc/letsencrypt/live/auth.hyeonworks.com/fullchain.pem": BIO_new_file() failed
|
||||||
|
(SSL: ... No such file or directory ...)
|
||||||
|
nginx: configuration file /etc/nginx/nginx.conf test failed
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — `nginx -t` 가 내놓는 **마지막 줄**이다. `syntax is
|
||||||
|
ok` 와 `test is successful` 두 마디가 다 나와야 통과다. 앞에 나오는
|
||||||
|
`[warn]` 줄(예: `types_hash_max_size`)은 통과를 막지 않는다 — 04 에서 이
|
||||||
|
경고를 실패로 오독하는 일이 실제로 벌어지므로, 여기서 **경고와 오류를
|
||||||
|
구분하는 눈**을 들여 둔다. 실패면 `[emerg]` 줄에 파일과 줄 번호가 찍힌다.
|
||||||
|
|
||||||
|
**이 결과가 의미하는 것** — 통과했으면 `&&` 뒤의 reload 가 이어서 돌고,
|
||||||
|
`systemctl reload` 는 아무 말 없이 끝난다(무소식이 좋은 소식이다). 실패했으면
|
||||||
|
`&&` 가 reload 를 **막아 준 것**이고, 지금 돌고 있는 nginx 는 옛 설정 그대로
|
||||||
|
멀쩡하다. 설정이 깨진 상태에서 reload 하면 새 워커를 못 띄운다 — `-t` 를
|
||||||
|
먼저 통과시키고 그때만 reload 하는 이유다.
|
||||||
|
|
||||||
|
reload 가 정말 반영됐는지는 워커가 갈렸는지로 본다. 같은 판정 방법을
|
||||||
|
04 에서 인증서 갱신에 그대로 쓴다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
systemctl status nginx --no-pager | head -20
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — `Active:` 줄이 `active (running)` 인가, 그리고 그
|
||||||
|
아래 프로세스 트리에 `nginx: master process` 하나와 `nginx: worker process`
|
||||||
|
여럿이 붙어 있는가. 워커 줄의 **PID** 를 눈에 담아 둔다.
|
||||||
|
|
||||||
|
**이 결과가 의미하는 것** — reload 는 마스터를 그대로 두고 **워커만** 갈아
|
||||||
|
끼운다. 그래서 reload 전후로 워커 PID 가 바뀌면 새 설정이 실제로 적용된
|
||||||
|
것이고, 안 바뀌었으면 `-t` 는 통과했는데 reload 가 안 간 것이다.
|
||||||
|
04 에서 인증서 갱신이 서빙까지 닿았는지를 정확히 이 방법으로 판정한다.
|
||||||
|
|
||||||
|
## 3. 호스트에서 엣지로 넘긴다 (DNAT)
|
||||||
|
|
||||||
|
여기까지 하면 엣지 nginx 는 살아 있는데 **아무도 거기로 안 보낸다.** tailnet
|
||||||
|
주소 `100.83.212.4:443` 을 받는 것은 여전히 물리 호스트다. 그 트래픽을
|
||||||
|
엣지로 넘기는 것이 이 단계고, **물리 호스트가 실험대를 위해 하는 일의
|
||||||
|
전부**다.
|
||||||
|
|
||||||
|
**하기** — `[lab host]`. 원본은
|
||||||
|
[`deploy/lab/edge/lab-edge-dnat.nft`](../../../deploy/lab/edge/lab-edge-dnat.nft)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo mkdir -p /etc/nftables.d
|
||||||
|
sudo cp deploy/lab/edge/lab-edge-dnat.nft /etc/nftables.d/
|
||||||
|
sudo cp deploy/lab/edge/lab-edge-dnat.service /etc/systemd/system/
|
||||||
|
sudo systemctl daemon-reload
|
||||||
|
sudo systemctl enable --now lab-edge-dnat.service
|
||||||
|
```
|
||||||
|
|
||||||
|
규칙의 알맹이는 두 줄이다.
|
||||||
|
|
||||||
|
```
|
||||||
|
iifname "tailscale0" tcp dport { 80, 443 } dnat to 192.168.122.10
|
||||||
|
ip daddr 192.168.122.10 tcp dport { 80, 443 } ct state new accept
|
||||||
|
```
|
||||||
|
|
||||||
|
**★ SNAT 을 걸지 않는다.** 게스트의 기본 게이트웨이가 호스트라 응답은
|
||||||
|
어차피 여기로 돌아오고, conntrack 이 알아서 되돌린다. masquerade 를 붙이면
|
||||||
|
출발지가 덮여서 **엣지가 모든 클라이언트를 `192.168.122.1` 로 본다** — 이
|
||||||
|
실험대가 재고 있는 `X-Forwarded-For` 계약이 통째로 무의미해진다.
|
||||||
|
|
||||||
|
**두 번째 줄이 왜 필요한가** — libvirt 의 기본 네트워크 규칙은 게스트 대역으로
|
||||||
|
들어가는 `RELATED,ESTABLISHED` 만 허용한다. **밖에서 새로 들어오는 연결은
|
||||||
|
막는다.** 그래서 명시적으로 열어 준다.
|
||||||
|
|
||||||
|
**확인**
|
||||||
|
```bash
|
||||||
|
sudo nft list table ip lab_edge
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — `dnat to 192.168.122.10` 한 줄과 `accept` 한 줄이
|
||||||
|
다 있는가. 그리고 **`masquerade` 나 `snat` 이 없는가.**
|
||||||
|
|
||||||
|
**이 결과가 의미하는 것** — PREROUTING nat 은 라우팅 결정보다 먼저 돌기
|
||||||
|
때문에 이 규칙이 **호스트 자신의 `:443` 소켓보다 우선한다.** 그래서 물리
|
||||||
|
호스트에 nginx 가 아직 떠 있어도 트래픽은 엣지로 간다 — 전환이 원자적이고,
|
||||||
|
되돌리기도 한 줄이다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo nft delete table ip lab_edge # rollback
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4. 층별로 확인한다 — 아래에서 위로
|
||||||
|
|
||||||
|
한 번에 밖에서 치지 말고, **가까운 층부터** 본다. 어디서 끊겼는지가 바로 나온다.
|
||||||
|
|
||||||
|
> **`curl` 을 두 형태로 쓴다.** 처음 볼 때는 `-I`(헤더까지 읽는 형태)로
|
||||||
|
> **응답을 눈으로 읽고**, 같은 것을 여러 번 재거나 두 노드를 나란히 비교할
|
||||||
|
> 때만 `-w '%{http_code}'`(값만 뽑는 형태)로 바꾼다. 값만 뽑는 형태는
|
||||||
|
> 골라 놓은 한 칸 말고는 전부 버리므로, **무엇이 잘못됐는지 모르는 상태**
|
||||||
|
> 에서는 쓸 것이 못 된다.
|
||||||
|
|
||||||
|
**확인 ①** Traefik 이 듣고 있나 (nginx 를 건너뛴다)
|
||||||
|
```bash
|
||||||
|
curl -I http://192.168.122.11
|
||||||
|
```
|
||||||
|
|
||||||
|
**형태** (이 실험대에서 캡처해 두지 않았다 — 봐야 할 줄만)
|
||||||
|
```
|
||||||
|
HTTP/1.1 404 Not Found
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — 첫 줄의 상태 줄 하나. 그리고 그 앞에 **아무 오류도
|
||||||
|
없이** 헤더가 나왔다는 사실. `curl: (7) Failed to connect` 이면 응답 자체가
|
||||||
|
없는 것이라 상태 코드를 볼 일도 없다.
|
||||||
|
|
||||||
|
**이 결과가 의미하는 것** — **`404` 가 성공 신호다.** 게스트의 80 을 누가
|
||||||
|
듣고 있고(Traefik), 그가 요청을 받아 「매칭되는 Ingress 규칙이 없다」고
|
||||||
|
답한 것이다. 이 층은 통과. `502` 면 Traefik 은 떴는데 그 뒤 백엔드가 없는
|
||||||
|
것이고, 연결 거부·타임아웃이면 Traefik 이 안 떴거나 게스트가 죽은 것이라
|
||||||
|
**02 로 돌아간다.**
|
||||||
|
|
||||||
|
두 노드가 같은지 볼 때는 값만 뽑는 형태가 낫다. 두 줄을 나란히 놓고 눈으로
|
||||||
|
비교하는 것이 목적이기 때문이다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s -o /dev/null -w '%{http_code}\n' http://192.168.122.11
|
||||||
|
curl -s -o /dev/null -w '%{http_code}\n' http://192.168.122.12
|
||||||
|
```
|
||||||
|
|
||||||
|
**실측** — `.11` 에서 잰 값이다.
|
||||||
|
```
|
||||||
|
404
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — 두 줄이 **같은 코드**인가.
|
||||||
|
|
||||||
|
**이 결과가 의미하는 것** — 둘이 같으면 nginx 가 어느 쪽으로 보내도 같은
|
||||||
|
결과가 나온다. 한쪽만 다르면 upstream 두 개 중 하나가 죽은 것이고, 그
|
||||||
|
상태에서는 **요청의 절반만 실패**해서 「가끔 안 된다」로 보인다.
|
||||||
|
|
||||||
|
**확인 ②** 엣지 nginx 가 직접 응답하나 (DNAT 을 건너뛴다)
|
||||||
|
```bash
|
||||||
|
curl -s -o /dev/null -w '%{http_code} %{redirect_url}\n' http://192.168.122.10
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — `301` 과 `Location` 이 나오는가. 여기서 막히면
|
||||||
|
문제는 **엣지 안**이다(설정·기동). 통과하는데 아래 ③ 이 안 되면 문제는
|
||||||
|
**DNAT** 이다. 이 한 층을 끼워 두면 그 둘을 헷갈리지 않는다.
|
||||||
|
|
||||||
|
**확인 ③** 밖에서, 즉 DNAT 을 거쳐 닿나
|
||||||
|
```bash
|
||||||
|
curl -I http://auth.hyeonworks.com
|
||||||
|
```
|
||||||
|
|
||||||
|
**형태** (봐야 할 두 줄만)
|
||||||
|
```
|
||||||
|
HTTP/1.1 301 Moved Permanently
|
||||||
|
Location: https://auth.hyeonworks.com/
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — 상태 줄과 **`Location:` 헤더 한 줄**. `Location` 이
|
||||||
|
`https://` 로 시작하고 원래 호스트명을 그대로 들고 있는가. `$host` 대신
|
||||||
|
설정에 이름을 박아 두면 여기서 엉뚱한 호스트가 나온다.
|
||||||
|
|
||||||
|
**이 결과가 의미하는 것** — 301 이 나왔다는 것은 **바깥 요청이 DNAT 을 거쳐
|
||||||
|
엣지 nginx 까지 닿았다**는 뜻이다(①은 Traefik 에 직접, ②는 엣지에 직접 친
|
||||||
|
것이라 DNAT 을 안 거쳤다). DNS·DNAT·80 리스너가 전부 살아 있다. 응답이 아예 없으면 nginx 가 안 떴거나
|
||||||
|
80 이 막힌 것이다. 리다이렉트를 따라가 끝까지 보려면 `-L` 을 붙인다.
|
||||||
|
|
||||||
|
값을 반복해서 잴 때의 형태는 이것이고, 아래가 이 실험대의 실측이다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s -o /dev/null -w '%{http_code} %{redirect_url}\n' http://auth.hyeonworks.com
|
||||||
|
```
|
||||||
|
```
|
||||||
|
301 https://auth.hyeonworks.com/
|
||||||
|
```
|
||||||
|
|
||||||
|
**확인 ④** 끝까지 닿나 (TLS 이후)
|
||||||
|
```bash
|
||||||
|
curl -s -o /dev/null -w '%{http_code}\n' https://auth.hyeonworks.com/realms/master
|
||||||
|
```
|
||||||
|
```
|
||||||
|
200
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — 코드 한 칸. 여기서만 값만 뽑는 형태를 바로 쓰는
|
||||||
|
이유는, 이 200 이 **04·05 에서 매번 같은 명령으로 다시 잴 기준값**이기
|
||||||
|
때문이다. 처음 한 번은 `curl -I https://auth.hyeonworks.com/realms/master`
|
||||||
|
로 헤더까지 보고, 그다음부터 이 형태로 줄인다.
|
||||||
|
|
||||||
|
**이 결과가 의미하는 것** — `200` 이면 nginx → Traefik → 파드까지 2홉이 다
|
||||||
|
이어졌다. `502` 는 nginx 는 살아 있는데 upstream 을 못 잡은 것(4번의 로그를
|
||||||
|
본다), `curl: (60)` 같은 인증서 오류는 아직 04 를 안 한 것이다. TLS 단계에서
|
||||||
|
막히면 코드만 보지 말고 `curl -v` 로 협상 과정을 읽는다 — 04 에서 그렇게 한다.
|
||||||
|
|
||||||
|
## 5. upstream 이 둘인 이유
|
||||||
|
|
||||||
|
```
|
||||||
|
upstream k3s_traefik {
|
||||||
|
server 192.168.122.11:80;
|
||||||
|
server 192.168.122.12:80;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
두 노드 모두 Traefik 이 뜨므로 어느 쪽으로 보내도 된다. nginx 는 기본
|
||||||
|
라운드로빈으로 번갈아 보내고, **한쪽이 죽으면 자동으로 뺀다.**
|
||||||
|
|
||||||
|
그 「빼는」 동작이 로그에 이렇게 남는다.
|
||||||
|
|
||||||
|
```
|
||||||
|
connect() failed (113: No route to host) ← 호스트에 못 닿는다
|
||||||
|
connect() failed (111: Connection refused) ← 포트에 아무도 없다
|
||||||
|
no live upstreams ← 둘 다 죽었다고 판단
|
||||||
|
```
|
||||||
|
|
||||||
|
**113 과 111 은 대응이 다르다.** 113 은 네트워크, 111 은 프로세스다.
|
||||||
|
A-4 에서 노드를 잃었을 때 이 세 줄이 1분 안에 순서대로 나왔다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 막히면
|
||||||
|
|
||||||
|
| 증상 | 어디서 끊겼나 | 확인 |
|
||||||
|
|---|---|---|
|
||||||
|
| ① 이 연결 거부 | Traefik 이 안 떴거나 게스트가 죽음 | `kubectl get pods -n kube-system` |
|
||||||
|
| ① 이 `502` | Traefik 은 떴는데 백엔드가 없음 | Ingress 확인 |
|
||||||
|
| ② 가 응답 없음 | nginx 가 안 떴거나 방화벽 | `systemctl status nginx` |
|
||||||
|
| ③ 이 `502` | 인증서 문제 또는 upstream 다운 | [04](../04-tls/) · 아래 로그 |
|
||||||
|
|
||||||
|
**로그를 볼 때** — 실무자가 치는 형태다.
|
||||||
|
```bash
|
||||||
|
journalctl -u nginx -p err -n 5 # 최근 에러만
|
||||||
|
journalctl -u nginx -f # 지금 벌어지는 것
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — 각 줄의 **괄호 안 errno**(`113`·`111`)와 그 뒤의
|
||||||
|
`upstream: "http://192.168.122.1x:80/..."` 부분. 어느 upstream 이 문제인지가
|
||||||
|
거기 적혀 있다. 그리고 **타임스탬프** — 방금 친 요청 시각과 안 맞으면 지금
|
||||||
|
보고 있는 것은 옛 사고다.
|
||||||
|
|
||||||
|
**이 결과가 의미하는 것** — `-p err` 로 걸러도 아무것도 안 나오면 nginx 는
|
||||||
|
정상이고 문제는 더 위(Traefik·파드)에 있다. 두 번째 명령은 **띄워 놓은
|
||||||
|
채로 다른 창에서 요청을 치는** 용도다 — 요청과 로그 줄을 눈으로 짝지으면
|
||||||
|
「이 요청이 어느 upstream 으로 갔나」가 바로 보인다. 끝내려면 Ctrl+C.
|
||||||
|
|
||||||
|
**★ nginx 에러 로그는 2048바이트에서 잘린다.** 긴 URL 이 끝에서 단어 중간에
|
||||||
|
끊겨 보이면 그것이다. 되찾으려면 **access 로그를 본다** — 거기엔 제한이 없다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
grep oauth2/callback /var/log/nginx/access.log | tail -1
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — 그 한 줄의 **끝**이다. 요청 URL 이 `"` 로 제대로
|
||||||
|
닫혀 있으면 온전한 줄이고, 단어 중간에서 멈춰 있으면 잘린 것이다. 길이가
|
||||||
|
궁금하면 세어 본다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
grep oauth2/callback /var/log/nginx/access.log | tail -1 | wc -c
|
||||||
|
```
|
||||||
|
|
||||||
|
**이 결과가 의미하는 것** — error 로그와 access 로그의 같은 요청 길이가 다르면
|
||||||
|
**error 쪽이 잘린 것**이지 요청이 잘린 것이 아니다. 이 실험대에서 B-7 의
|
||||||
|
502 원인이 error 로그에 있었는데 잘려 있었고, access 로그에는 3492자로 온전히
|
||||||
|
남아 있었다. 잘린 문자열을 놓고 원인을 추측하면 없는 문제를 좇게 된다.
|
||||||
@@ -0,0 +1,449 @@
|
|||||||
|
# 04 — TLS
|
||||||
|
|
||||||
|
## 이 단계가 끝나면
|
||||||
|
|
||||||
|
`https://` 가 열리고 체인이 완전하며, 갱신이 자동으로 **서빙까지** 닿는다.
|
||||||
|
|
||||||
|
## 전제
|
||||||
|
|
||||||
|
[03](../03-nginx/) 이 끝나 엣지 nginx 가 Traefik 으로 프록시한다.
|
||||||
|
|
||||||
|
## 어디서 치는가
|
||||||
|
|
||||||
|
**이 단계는 전부 `[kc-lab-edge]` 에서 친다.** 인증서·certbot·갱신 타이머·
|
||||||
|
deploy 훅이 전부 엣지 게스트에 산다. 물리 호스트에는 아무것도 두지 않는다 —
|
||||||
|
그래야 `virsh undefine kc-lab-edge` 한 줄로 이 계층을 통째로 되돌릴 수 있다.
|
||||||
|
|
||||||
|
## ★ 검증 방식을 먼저 정한다 — HTTP-01 이냐 DNS-01 이냐
|
||||||
|
|
||||||
|
같은 Let's Encrypt 인증서인데 **「이 도메인이 네 것이냐」를 증명하는 방법**만
|
||||||
|
다르다. 그리고 이 실험대에서는 **선택의 여지가 없다.**
|
||||||
|
|
||||||
|
| | HTTP-01 | DNS-01 |
|
||||||
|
|---|---|---|
|
||||||
|
| 검증 방향 | Let's Encrypt **→ 우리 서버** (인바운드) | certbot **→ DNS 공급자 API** (아웃바운드) |
|
||||||
|
| 공개 인터넷에서 보여야 하나 | **그렇다** | 아니다 |
|
||||||
|
| 와일드카드 | 불가 | 가능 |
|
||||||
|
| 필요한 것 | 80 포트 · 공개 A 레코드 | DNS 공급자 API 토큰 |
|
||||||
|
|
||||||
|
**이 실험대는 공개 인터넷을 쓰지 않는다.** 도메인 세 개는 tailnet 주소를
|
||||||
|
가리킨다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dig +short auth.hyeonworks.com
|
||||||
|
```
|
||||||
|
```
|
||||||
|
100.83.212.4
|
||||||
|
```
|
||||||
|
|
||||||
|
`100.64.0.0/10` 은 CGNAT 용으로 예약된 대역이라 **공개 인터넷에서 라우팅
|
||||||
|
자체가 되지 않는다.** 방화벽을 여는 문제가 아니라 그 주소가 인터넷에 존재하지
|
||||||
|
않는다. Let's Encrypt 를 tailnet 에 초대할 방법도 없다. **그래서 HTTP-01 은
|
||||||
|
쓸 수 없고 DNS-01 을 쓴다.**
|
||||||
|
|
||||||
|
> **공개 서버라면 HTTP-01 이 맞다.** 토큰도 DNS 연동도 필요 없어서 관리할
|
||||||
|
> 것이 적다. DNS-01 이 더 좋은 방식이어서 고르는 것이 아니라, HTTP-01 이
|
||||||
|
> 못 쓰이는 환경이라 고르는 것이다. 개념은
|
||||||
|
> [`session-lab-concepts.md`](../../session-lab-concepts.md) 의
|
||||||
|
> 「DNS-01 은 언제 쓰는가」.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. certbot 을 깐다
|
||||||
|
|
||||||
|
cloud-init 이 이미 깔았다면 건너뛴다 —
|
||||||
|
[`kc-lab.yaml.example`](../../../deploy/lab/cloud-init/kc-lab.yaml.example) 의
|
||||||
|
`packages` 에 들어 있다.
|
||||||
|
|
||||||
|
**하기** — `[kc-lab-edge]`
|
||||||
|
```bash
|
||||||
|
sudo apt install -y certbot python3-certbot-dns-cloudflare
|
||||||
|
```
|
||||||
|
|
||||||
|
**확인** — 쓸 수 있는 검증 방식이 무엇인가
|
||||||
|
```bash
|
||||||
|
certbot plugins 2>/dev/null | grep -E '^\*'
|
||||||
|
```
|
||||||
|
|
||||||
|
**실측**
|
||||||
|
```
|
||||||
|
* dns-cloudflare
|
||||||
|
* standalone
|
||||||
|
* webroot
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — `dns-cloudflare` 한 줄이 있는가. 없으면 플러그인
|
||||||
|
패키지가 안 깔린 것이고, `--dns-cloudflare` 를 줘도 `unrecognized arguments`
|
||||||
|
로 끝난다.
|
||||||
|
|
||||||
|
## 2. 인증서를 받는다
|
||||||
|
|
||||||
|
DNS-01 이면 **와일드카드를 받을 수 있다.** 이 실험대는 처음에 이름 셋을
|
||||||
|
따로 받았고, 그 비용이 B-7 에서 청구됐다 — oauth2-proxy 를 올릴 네 번째
|
||||||
|
이름이 없어 Grafana 의 `app2` 를 빌려야 했다.
|
||||||
|
|
||||||
|
**하기** — `[kc-lab-edge]`. 토큰은 **존 하나 + DNS:Edit** 으로 좁힌다.
|
||||||
|
계정 전역 API Key 를 쓰지 않는다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo install -m 600 /dev/null /etc/letsencrypt/cloudflare.ini
|
||||||
|
sudo tee /etc/letsencrypt/cloudflare.ini >/dev/null <<'EOF'
|
||||||
|
dns_cloudflare_api_token = <Cloudflare API 토큰>
|
||||||
|
EOF
|
||||||
|
|
||||||
|
sudo certbot certonly --dns-cloudflare \
|
||||||
|
--dns-cloudflare-credentials /etc/letsencrypt/cloudflare.ini \
|
||||||
|
-d hyeonworks.com -d '*.hyeonworks.com' --dry-run
|
||||||
|
```
|
||||||
|
|
||||||
|
**`--dry-run` 을 먼저 붙인다.** Let's Encrypt 는 같은 이름 조합에 대해
|
||||||
|
**주당 중복 인증서 5장** 제한이 있고, `--dry-run` 은 그 한도를 쓰지 않는다.
|
||||||
|
통과하면 `--dry-run` 만 떼고 다시 친다.
|
||||||
|
|
||||||
|
> **DNS-01 은 느리다.** TXT 레코드가 퍼질 때까지 기다려야 해서 발급이 수십
|
||||||
|
> 초 걸린다. certbot 이 기본 대기 시간을 두고 있으니 중간에 끊지 않는다.
|
||||||
|
|
||||||
|
**확인** — 인증서가 실제로 생겼고 이름이 다 들어갔는가
|
||||||
|
```bash
|
||||||
|
sudo certbot certificates
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — 네 줄이다. `Domains:` 에 **이름 세 개가 한 줄에**
|
||||||
|
나열되어 있는가(줄이 두 개로 갈라져 있으면 인증서가 두 장 만들어진 것이다),
|
||||||
|
`Expiry Date:` 가 오늘로부터 90일 뒤이고 `VALID` 인가, `Certificate Path:` 가
|
||||||
|
`/etc/letsencrypt/live/auth.hyeonworks.com/fullchain.pem` 인가, 그리고
|
||||||
|
`Private Key Path:` 가 그 옆의 `privkey.pem` 인가. **경로 두 개가 03 의
|
||||||
|
nginx 설정에 적은 것과 한 글자도 다르면 안 된다.**
|
||||||
|
|
||||||
|
**이 결과가 의미하는 것** — 여기 나온 경로가 곧 nginx 가 읽을 파일이다.
|
||||||
|
발급은 성공했는데 `No certificates found` 가 나오면 `sudo` 없이 쳐서 다른
|
||||||
|
사용자의 설정 디렉터리를 본 것이다. 발급 자체가 실패했다면 검증 단계에서
|
||||||
|
막힌 것이므로 DNS 와 80 포트부터 본다(아래 「막히면」).
|
||||||
|
|
||||||
|
> **이 실험대는 와일드카드를 쓰지 않았고, 그 비용이 나중에 청구됐다.**
|
||||||
|
> B-7 에서 oauth2-proxy 를 올릴 네 번째 이름이 없어 Grafana 의 `app2` 를
|
||||||
|
> 빌려야 했다. 와일드카드는 DNS-01 검증이 필요하고 그건 DNS 공급자 API 를
|
||||||
|
> 붙여야 한다 — 그 절충을 안 한 결과다.
|
||||||
|
|
||||||
|
## 3. nginx 가 `fullchain` 을 보게 한다
|
||||||
|
|
||||||
|
[03](../03-nginx/) 의 설정에 이미 있다.
|
||||||
|
|
||||||
|
```
|
||||||
|
ssl_certificate /etc/letsencrypt/live/auth.hyeonworks.com/fullchain.pem;
|
||||||
|
ssl_certificate_key /etc/letsencrypt/live/auth.hyeonworks.com/privkey.pem;
|
||||||
|
```
|
||||||
|
|
||||||
|
**`cert.pem` 이 아니라 `fullchain.pem`.** 서버 인증서만 보내면 중간 인증서가
|
||||||
|
빠져 체인이 끊긴다. 브라우저는 대개 캐시나 AIA 로 보완해서 **정상으로 보이고**,
|
||||||
|
캐시가 없는 클라이언트에서만 깨진다. 그래서 발견이 늦다.
|
||||||
|
|
||||||
|
**하기**
|
||||||
|
```bash
|
||||||
|
sudo nginx -t && sudo systemctl reload nginx
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4. 확인 — 열리는가, 체인이 완전한가
|
||||||
|
|
||||||
|
**확인 ①** 열리나 — **처음 한 번은 협상 과정을 읽는다**
|
||||||
|
```bash
|
||||||
|
curl -v https://auth.hyeonworks.com/realms/master -o /dev/null
|
||||||
|
```
|
||||||
|
|
||||||
|
**형태** (이 실험대에서 캡처해 두지 않았다 — 읽어야 할 줄만)
|
||||||
|
```
|
||||||
|
* SSL connection using TLSv1.3 / ...
|
||||||
|
* subject: CN=auth.hyeonworks.com
|
||||||
|
* issuer: C=US; O=Let's Encrypt; CN=...
|
||||||
|
* SSL certificate verify ok.
|
||||||
|
> GET /realms/master HTTP/1.1
|
||||||
|
< HTTP/1.1 200 OK
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — `*` 로 시작하는 줄 넷이다. 어떤 TLS 판으로
|
||||||
|
협상했는가, `subject` 의 CN 이 지금 친 이름과 같은가, `issuer` 가 Let's
|
||||||
|
Encrypt 인가, 그리고 **`SSL certificate verify ok.`** 가 있는가. 그 아래
|
||||||
|
`<` 로 시작하는 첫 줄이 응답 상태다. `-o /dev/null` 은 본문만 버리는 것이라
|
||||||
|
이 줄들은 그대로 남는다.
|
||||||
|
|
||||||
|
**이 결과가 의미하는 것** — 이 네 줄이 다 나오면 인증서가 붙었고 체인이
|
||||||
|
클라이언트 기준으로 검증됐다. TLS 에서 막힐 때 봐야 할 것이 전부 여기
|
||||||
|
있으므로, **인증서를 처음 붙인 직후에는 코드 한 칸이 아니라 이 화면을
|
||||||
|
본다.** `verify` 줄 대신 `unable to get local issuer certificate` 가 나오면
|
||||||
|
중간 인증서가 빠진 것이고, 그 원인은 3번의 `cert.pem`/`fullchain.pem`
|
||||||
|
이다 — 확인 ②로 간다.
|
||||||
|
|
||||||
|
같은 것을 반복해서 재거나 03·05 의 값과 나란히 비교할 때만 값만 뽑는
|
||||||
|
형태로 줄인다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s -o /dev/null -w '%{http_code}\n' https://auth.hyeonworks.com/realms/master
|
||||||
|
```
|
||||||
|
```
|
||||||
|
200
|
||||||
|
```
|
||||||
|
|
||||||
|
**확인 ②** 체인 단계와 검증
|
||||||
|
```bash
|
||||||
|
echo | openssl s_client -connect auth.hyeonworks.com:443 \
|
||||||
|
-servername auth.hyeonworks.com 2>/dev/null \
|
||||||
|
| grep -E '^ *[0-9]+ s:|^ *i:|Verify return code'
|
||||||
|
```
|
||||||
|
|
||||||
|
**실측**
|
||||||
|
```
|
||||||
|
0 s:CN = auth.hyeonworks.com
|
||||||
|
i:C = US, O = Let's Encrypt, CN = YE2
|
||||||
|
1 s:C = US, O = Let's Encrypt, CN = YE2
|
||||||
|
i:C = US, O = ISRG, CN = Root YE
|
||||||
|
2 s:C = US, O = ISRG, CN = Root YE
|
||||||
|
i:C = US, O = Internet Security Research Group, CN = ISRG Root X2
|
||||||
|
3 s:C = US, O = Internet Security Research Group, CN = ISRG Root X2
|
||||||
|
i:C = US, O = Internet Security Research Group, CN = ISRG Root X1
|
||||||
|
Verify return code: 0 (ok)
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — 왼쪽의 **번호(0·1·2·3)가 몇까지 가는가**, 그리고
|
||||||
|
각 단계의 `i:`(발급자)가 **바로 다음 단계의 `s:`(주체)와 같은가**. 0번이
|
||||||
|
우리 서버 인증서이고, 위 실측에서 0의 `i:` 가 `CN = YE2` 인데 1의 `s:` 가
|
||||||
|
같은 `CN = YE2` 다 — 사슬이 이어져 있다는 뜻이다. 마지막이
|
||||||
|
`Verify return code: 0 (ok)`.
|
||||||
|
|
||||||
|
**이 결과가 의미하는 것** — **단계가 1개면 `cert.pem` 을 쓴 것이다.**
|
||||||
|
서버가 자기 인증서만 보내고 중간 인증서를 안 보낸 상태다. 이때 브라우저는
|
||||||
|
대개 캐시나 AIA 로 보완해서 **정상으로 보이므로**, 이 명령이 유일하게
|
||||||
|
믿을 수 있는 판정이다. 고치는 곳은 03 의 `ssl_certificate` 한 줄이고,
|
||||||
|
고친 뒤 `nginx -t && systemctl reload nginx` 하고 여기서 다시 잰다.
|
||||||
|
`Verify return code` 가 0 이 아니면 숫자마다 뜻이 다르다 —
|
||||||
|
`10` 은 만료, `20` 은 발급자를 못 찾음, `21` 은 첫 인증서를 검증 못 함.
|
||||||
|
|
||||||
|
**확인 ③** 이름 세 개가 한 인증서인가
|
||||||
|
```bash
|
||||||
|
for H in auth app1 app2; do
|
||||||
|
echo | openssl s_client -connect $H.hyeonworks.com:443 -servername $H.hyeonworks.com 2>/dev/null \
|
||||||
|
| openssl x509 -noout -serial
|
||||||
|
done
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — 찍히는 세 줄의 **일련번호가 서로 같은가**. 값 자체는
|
||||||
|
아무 의미가 없고 **셋이 일치하는지만** 본다.
|
||||||
|
|
||||||
|
**이 결과가 의미하는 것** — 셋이 같으면 SAN 하나에 이름 셋이 들어 있는
|
||||||
|
인증서 한 장이고, 갱신도 한 번에 끝난다. 다르면 인증서가 여러 장이라
|
||||||
|
**갱신 훅도 장마다 따로 돌고**, 한 장만 갱신됐을 때 나머지 이름이 만료되는
|
||||||
|
상황이 생긴다. 이름별로 무엇이 실려 있는지 보려면 SAN 을 직접 편다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
echo | openssl s_client -connect auth.hyeonworks.com:443 -servername auth.hyeonworks.com 2>/dev/null \
|
||||||
|
| openssl x509 -noout -ext subjectAltName
|
||||||
|
```
|
||||||
|
|
||||||
|
## 5. ★ 갱신이 서빙까지 닿게 한다 — 여기가 이 단계의 핵심이다
|
||||||
|
|
||||||
|
**타이머가 도는 것만으로는 부족하다.**
|
||||||
|
|
||||||
|
**확인** — 타이머
|
||||||
|
```bash
|
||||||
|
systemctl list-timers certbot-renew.timer
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — 네 칸이다. `NEXT`(다음 실행 시각)와 `LEFT`(남은
|
||||||
|
시간)가 채워져 있는가, `LAST`/`PASSED` 가 하루 안쪽인가, `UNIT` 옆의
|
||||||
|
`ACTIVATES` 가 `certbot-renew.service` 를 가리키는가. **표가 통째로 비어
|
||||||
|
나오면 타이머가 없는 것이다** — 이름이 배포판마다 다르니
|
||||||
|
`systemctl list-timers --all | grep -i certbot` 로 찾는다.
|
||||||
|
|
||||||
|
**이 결과가 의미하는 것** — 여기까지가 「갱신이 돌기는 하는가」의 답이고,
|
||||||
|
대부분의 문서가 여기서 끝난다. **그런데 이것이 `active` 여도 갱신된 인증서가
|
||||||
|
서빙되지는 않는다.** nginx 는 인증서를
|
||||||
|
기동 시점에 읽어 메모리에 들고 있고, certbot 은 `live/` 심볼릭 링크만 갈아
|
||||||
|
끼운다. **경로는 그대로이고 내용만 바뀌므로 nginx 는 모른다.**
|
||||||
|
|
||||||
|
배포판 기본 유닛에는 reload 를 부르는 것이 없다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
systemctl cat certbot-renew.service
|
||||||
|
```
|
||||||
|
```
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
ExecStart=/usr/bin/certbot -q renew
|
||||||
|
PrivateTmp=true
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — `ExecStart=` 한 줄과, 그 아래에 `ExecStartPost=` 가
|
||||||
|
**있는지 없는지**. 그리고 `ExecStart` 의 인자에 `--deploy-hook` 이 붙어
|
||||||
|
있는지. 여기 없는 것을 보는 것이 이 명령의 목적이다.
|
||||||
|
|
||||||
|
**이 결과가 의미하는 것** — `ExecStartPost` 도 `--deploy-hook` 도 없다.
|
||||||
|
즉 이 유닛은 **인증서를 새로 받는 데까지만** 책임지고, 받은 것을 누가
|
||||||
|
읽게 만드는 일은 아무도 하지 않는다. 배포판이 이렇게 준다는 것이 요점이다 —
|
||||||
|
「기본값이니 괜찮겠지」가 바로 이 결함의 서식지다. 여기 뭔가 적혀 있는
|
||||||
|
배포판이라면 아래 훅은 필요 없고, 대신 그 명령이 nginx 를 reload 하는지만
|
||||||
|
확인하면 된다.
|
||||||
|
|
||||||
|
**하기** — 훅 하나를 넣는다
|
||||||
|
```bash
|
||||||
|
sudo tee /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh > /dev/null <<'EOF'
|
||||||
|
#!/bin/sh
|
||||||
|
nginx -t && nginx -s reload
|
||||||
|
EOF
|
||||||
|
sudo chmod +x /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
**저장소에 같은 파일이 있다** —
|
||||||
|
[`deploy/lab/edge/reload-nginx.sh`](../../../deploy/lab/edge/reload-nginx.sh).
|
||||||
|
여기 손으로 치지 말고 그걸 밀어 넣는 편이 낫다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cat deploy/lab/edge/reload-nginx.sh \
|
||||||
|
| ssh kc-lab-edge 'sudo tee /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh >/dev/null'
|
||||||
|
ssh kc-lab-edge 'sudo chmod +x /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh'
|
||||||
|
```
|
||||||
|
|
||||||
|
> **이 훅은 한동안 저장소에 없었다.** 호스트에만 있어서, 호스트를 초기화하면
|
||||||
|
> **아무 오류 없이 사라지고** D-4 가 측정한 상태(갱신 성공 · 서빙 38분 25초
|
||||||
|
> 지연 · 타이머는 `SUCCESS`)로 되돌아갔다. 저장소에 두는 이유가 이것이다.
|
||||||
|
|
||||||
|
`deploy/` 에 넣는다. `post/` 는 갱신이 없어도 매번 돌아 하루 두 번 워커를
|
||||||
|
갈아치운다. `deploy/` 는 **실제로 갱신됐을 때만** 실행된다.
|
||||||
|
|
||||||
|
**확인** — 실제로 도는지
|
||||||
|
|
||||||
|
이 확인은 **상태를 바꾼다.** `--force-renewal` 은 인증서를 실제로 새로
|
||||||
|
받으므로 발급 한도(주당 중복 5장)를 깎는다. 먼저 `--dry-run` 으로 훅이
|
||||||
|
호출되는 것까지만 보고, 진짜 판정이 필요할 때만 강제 갱신을 한 번 쓴다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo certbot renew --dry-run
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — 출력 끝의 `Running deploy-hook command` 줄과
|
||||||
|
`simulated renewals` 요약. 훅 줄이 아예 안 나오면 파일이 `deploy/` 가 아닌
|
||||||
|
곳에 있거나 실행 권한이 없는 것이다(`ls -l` 로 `x` 를 본다).
|
||||||
|
|
||||||
|
**이 결과가 의미하는 것** — dry-run 은 훅이 **호출되는지**까지만 말해 준다.
|
||||||
|
호출된 훅이 nginx 를 정말 갈아 끼웠는지는 dry-run 으로 알 수 없다 —
|
||||||
|
그래서 아래를 한 번 한다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 강제 갱신 전에 워커 PID 를 적어 둔다
|
||||||
|
ps -eo pid,lstart,args | grep 'nginx: worker' | grep -v grep
|
||||||
|
|
||||||
|
sudo certbot renew --force-renewal
|
||||||
|
|
||||||
|
# 워커 PID 가 바뀌었으면 reload 된 것이다
|
||||||
|
ps -eo pid,lstart,args | grep 'nginx: worker' | grep -v grep
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — 두 출력의 **첫 열(PID)과 둘째 열 묶음(lstart, 프로세스
|
||||||
|
시작 시각)**. 앞뒤로 놓고 PID 집합이 통째로 바뀌었는지만 본다. `lstart` 를
|
||||||
|
같이 뽑는 이유는 PID 가 우연히 재사용됐을 때를 가르기 위해서다. 마스터
|
||||||
|
프로세스는 그대로이고 **워커만** 갈리는 것이 정상이다.
|
||||||
|
|
||||||
|
**이 결과가 의미하는 것** — PID 가 바뀌었으면 훅이 돌아 nginx 가 새 인증서를
|
||||||
|
읽었다. 안 바뀌었으면 인증서는 갱신됐는데 **서빙되는 것은 옛것**이고,
|
||||||
|
이 상태가 아래 표의 왼쪽 칸이다.
|
||||||
|
|
||||||
|
**판정은 로그 문구가 아니라 워커 PID 로 한다.** certbot 이
|
||||||
|
`Hook 'deploy-hook' ran with error output` 이라고 찍는데 **실패가 아니다** —
|
||||||
|
nginx 의 `types_hash` 경고가 stderr 로 나갔을 뿐이고 내용은
|
||||||
|
`test is successful` · `signal process started` 다.
|
||||||
|
|
||||||
|
> **로그에서 `error` 를 grep 하는 감시를 걸면 성공한 훅을 실패로 오독한다.**
|
||||||
|
|
||||||
|
**실측** — 이 실험대에서 잰 차이
|
||||||
|
|
||||||
|
| | 훅 없음 | 훅 있음 |
|
||||||
|
|---|---|---|
|
||||||
|
| 갱신 → 서빙 | **2305초 (38분 25초)** | **1~2초** |
|
||||||
|
| 무엇이 reload 했나 | 사람이 직접 | certbot deploy 훅 |
|
||||||
|
| 아무도 안 했다면 | 다음 nginx 재시작까지 = 사실상 무기한 | — |
|
||||||
|
|
||||||
|
**88일 동안 이 결함이 보이지 않는다.** 타이머는 정상이고 매번 `SUCCESS` 로
|
||||||
|
끝나며, 만료 30일 전까지는 갱신 자체를 하지 않아 발현할 기회가 없다.
|
||||||
|
발현하는 날의 증상은 **인증서 만료**이고, 그날에도 로그에는 `SUCCESS` 라고
|
||||||
|
적혀 있다.
|
||||||
|
|
||||||
|
원문: [D-4](../../experiment-d4-certificate-renewal.md) ·
|
||||||
|
[D-4a](../../experiment-d4a-deploy-hook.md) ·
|
||||||
|
증거 [`evidence/d4-certificate-renewal/`](../../evidence/d4-certificate-renewal/)
|
||||||
|
|
||||||
|
## 6. reload 는 무중단인가 — 쟀다
|
||||||
|
|
||||||
|
궁금할 것이므로 결과만 적는다. **무중단이다.**
|
||||||
|
|
||||||
|
새 연결 8856건 전부 200, p95 는 205.7ms 대 204.3ms 로 변화 없음. 그리고
|
||||||
|
845KB 를 20k/s 로 받는 중이던 요청이 **전송 12초째에 reload 를 맞고도**
|
||||||
|
845361바이트를 온전히 받았다(연결수 1). 옛 워커가 그 요청을 끝까지 책임진다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 막히면
|
||||||
|
|
||||||
|
| 증상 | 원인 | 확인 |
|
||||||
|
|---|---|---|
|
||||||
|
| certbot 검증 실패 | DNS 가 이 호스트를 안 가리킴 · 80 이 막힘 | `dig +short auth.hyeonworks.com` · 밖에서 `curl -I http://auth.hyeonworks.com` |
|
||||||
|
| 체인 단계가 1개 | `cert.pem` 을 씀 | 위 확인 ② |
|
||||||
|
| 갱신은 됐는데 옛 인증서가 나감 | **deploy 훅 없음** | 워커 PID · `sudo ls /etc/letsencrypt/renewal-hooks/deploy/` |
|
||||||
|
| 훅이 실패한 것처럼 보임 | stderr 경고를 error 로 표시 | 문구 말고 **워커 PID** |
|
||||||
|
| 발급 한도 | 주당 중복 인증서 5장 | `--dry-run` 으로 먼저 시험 |
|
||||||
|
| `unrecognized arguments: --dns-cloudflare` | 플러그인 미설치 | `certbot plugins \| grep '^\*'` |
|
||||||
|
| DNS-01 이 오래 걸림 | TXT 전파 대기 | **정상이다.** 끊지 않는다 |
|
||||||
|
| 재구축 뒤 인증서가 없음 | 발급하지 말고 **백업을 되돌린다** | 한도를 아끼는 길이다 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 근거를 재려면 (선택)
|
||||||
|
|
||||||
|
평소에는 필요 없다. **문서에 남길 근거가 필요할 때만** 이렇게까지 한다.
|
||||||
|
|
||||||
|
갱신 중 가용성을 재려면 **주입 전에 대조군부터** 잡는다. 평시 오류율을 모르면
|
||||||
|
갱신 중에 나온 실패 한 건을 해석할 수 없다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 대조군 — 0.2초 × 900회 = 180초
|
||||||
|
i=0; while [ $i -lt 900 ]; do
|
||||||
|
curl -s -o /dev/null -w '%{http_code} %{time_total}\n' --max-time 5 \
|
||||||
|
https://auth.hyeonworks.com/realms/master
|
||||||
|
i=$((i+1)); sleep 0.2
|
||||||
|
done > /tmp/control.txt
|
||||||
|
awk '{print $1}' /tmp/control.txt | sort | uniq -c
|
||||||
|
```
|
||||||
|
|
||||||
|
**여기서는 값만 뽑는 형태가 맞다.** 900번을 재서 코드별로 세는 것이 목적이고,
|
||||||
|
헤더는 볼 일이 없다. 앞의 확인 ①과 형태가 다른 이유가 이것이다.
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — `uniq -c` 가 내놓는 **줄이 몇 개인가**. 한 줄이면
|
||||||
|
900번이 전부 같은 코드였다는 뜻이고, 그 줄의 왼쪽 수가 900 인지 본다.
|
||||||
|
두 줄 이상이면 그 자체가 평시 오류가 있다는 뜻이다. 응답 시간이 궁금하면
|
||||||
|
둘째 열을 따로 본다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
awk '{print $2}' /tmp/control.txt | sort -n | tail -1 # 최악값
|
||||||
|
```
|
||||||
|
|
||||||
|
**이 결과가 의미하는 것** — 이 실험대의 대조군은 **900건 전부 200, 오류 0**
|
||||||
|
이었다. 그래서 갱신 중 비200 이 한 번이라도 나오면 갱신 탓으로 귀속할 수
|
||||||
|
있었다. 대조군에 이미 오류가 섞여 있다면 주입 중의 오류 한 건은 아무것도
|
||||||
|
증명하지 못하므로, **대조군이 깨끗해질 때까지는 주입을 하지 않는다.**
|
||||||
|
|
||||||
|
**그리고 두 기계의 시각을 나란히 놓기 전에 시계부터 잰다.**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
A=$(date -u +%s.%N); B=$(ssh test-server 'date -u +%s.%N'); C=$(date -u +%s.%N)
|
||||||
|
# 왜곡 ≈ B − (A+C)/2 , 어느 쪽이 맞는지는 외부 기준으로 가른다
|
||||||
|
curl -sI https://www.google.com | grep -i '^date:'
|
||||||
|
timedatectl show -p NTP -p NTPSynchronized
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — 세 수 `A`·`B`·`C` 를 눈으로 빼서 **초 단위 차이가
|
||||||
|
몇인가**. `A` 와 `C` 는 같은 기계에서 SSH 왕복 직전·직후에 찍은 것이라, 그
|
||||||
|
가운데가 「저쪽 시각을 잰 순간의 이쪽 시각」이다. 그다음 `Date:` 헤더의
|
||||||
|
시각이 둘 중 어느 쪽에 가까운가. 마지막으로 `NTPSynchronized=yes` 인가.
|
||||||
|
|
||||||
|
**이 결과가 의미하는 것** — 차이가 수초 이내면 두 기계의 로그를 그대로
|
||||||
|
나란히 놓아도 된다. 크면 **먼저 어느 쪽이 틀렸는지 가른 다음** 보정한다.
|
||||||
|
이 실험대는 test-server 가 NTP 미동기로 106초 빨랐고, 보정하지 않은 첫
|
||||||
|
계산은 훅이 인증서 발급보다 104초 먼저 실행된 것이 되어 물리적으로
|
||||||
|
불가능했다 — **음수 지연이 나오면 계산이 아니라 시계를 의심한다.**
|
||||||
@@ -0,0 +1,514 @@
|
|||||||
|
# 05 — Keycloak 2노드 + PostgreSQL
|
||||||
|
|
||||||
|
## 이 단계가 끝나면
|
||||||
|
|
||||||
|
`https://auth.hyeonworks.com` 에서 관리 콘솔에 로그인되고, 두 Keycloak 이
|
||||||
|
하나의 클러스터로 보인다.
|
||||||
|
|
||||||
|
## 전제
|
||||||
|
|
||||||
|
[04](../04-tls/) 까지 끝나 `https://` 가 열린다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 매니페스트를 적용한다
|
||||||
|
|
||||||
|
**하기**
|
||||||
|
```bash
|
||||||
|
kubectl create namespace keycloak-lab
|
||||||
|
kubectl apply -f deploy/lab/k8s/keycloak-cluster.yaml
|
||||||
|
```
|
||||||
|
|
||||||
|
**확인** — 적용이 끝날 때까지 기다린다
|
||||||
|
```bash
|
||||||
|
kubectl -n keycloak-lab rollout status statefulset/keycloak --timeout=300s
|
||||||
|
```
|
||||||
|
|
||||||
|
```
|
||||||
|
partitioned roll out complete: 2 new pods have been updated...
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — 이 명령은 **끝날 때까지 아무것도 안 찍고 멈춰 있다.**
|
||||||
|
그 침묵이 정상이다. 마지막에 나오는 한 줄에서 `complete` 라는 낱말과 파드
|
||||||
|
개수 `2` 를 본다. 300초를 다 쓰고 타임아웃으로 끝나면 그것도 답이다 —
|
||||||
|
「안 떴다」가 확정된 것이니 3번으로 간다.
|
||||||
|
|
||||||
|
**이 결과가 의미하는 것** — `complete` 면 두 파드가 다 Ready 가 됐다는 뜻이라
|
||||||
|
2번의 층별 확인으로 넘어간다. `rollout status` 를 쓰는 이유는 `get pods` 를
|
||||||
|
반복해서 치는 것보다 나아서만이 아니라, **언제 끝났는지를 사람이 판정하지
|
||||||
|
않아도 되기** 때문이다. StatefulSet 은 파드를 하나씩 순서대로 띄우므로
|
||||||
|
중간에 `0/2` 로 한참 멈춰 있는 것은 정상이다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 리소스가 제대로 만들어졌는지 — 층별로 본다
|
||||||
|
|
||||||
|
`kubectl get pods` 만 보면 놓치는 것이 많다. **위에서 아래로** 확인한다.
|
||||||
|
|
||||||
|
### 2-1. 무엇이 만들어졌나
|
||||||
|
|
||||||
|
**확인** — 이 네임스페이스에 무엇이 서 있는가
|
||||||
|
```bash
|
||||||
|
kubectl -n keycloak-lab get all
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — 종류별로 묶여 나오는 왼쪽 이름 열을 훑고, 파드
|
||||||
|
줄에서는 **READY 칸의 `1/1`** 과 **RESTARTS 칸**을 본다. RESTARTS 가 0 이
|
||||||
|
아니면 지금은 `Running` 이어도 한 번 죽었다 살아난 것이라, 3번의
|
||||||
|
`logs --previous` 를 볼 이유가 된다.
|
||||||
|
|
||||||
|
**이 결과가 의미하는 것** — 여기 보이는 것이 워크로드의 전부다. 그런데
|
||||||
|
`all` 은 이름과 달리 전부는 아니다 — **Secret·ConfigMap·PVC·Ingress 는 안
|
||||||
|
나온다.** 이 넷이 빠졌다는 사실을 모르고 「다 만들어졌다」고 판정하는 것이
|
||||||
|
흔한 오독이라, 한 번 더 친다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
kubectl -n keycloak-lab get secret,configmap,pvc,ingress
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — 네 종류가 **하나씩이라도 있는가**. PVC 줄의
|
||||||
|
STATUS 는 `Bound` 인가, Ingress 줄의 HOSTS 칸이 `auth.hyeonworks.com` 인가.
|
||||||
|
|
||||||
|
**이 결과가 의미하는 것** — 매니페스트에 있는데 여기 없는 종류가 있다면
|
||||||
|
`apply` 가 부분적으로만 먹은 것이다. Ingress 의 호스트 이름이 04 에서 발급한
|
||||||
|
인증서의 이름과 다르면, 밖에서는 TLS 는 되는데 404 가 나온다.
|
||||||
|
|
||||||
|
### 2-2. Deployment → ReplicaSet → Pod 사슬
|
||||||
|
|
||||||
|
Deployment 는 파드를 직접 만들지 않는다. **ReplicaSet 을 만들고 그것이 파드를
|
||||||
|
만든다.** 이 사슬 어디서 끊겼는지가 진단의 출발점이다.
|
||||||
|
|
||||||
|
**확인** — 사슬 어디까지 갔는가
|
||||||
|
```bash
|
||||||
|
kubectl -n keycloak-lab get deploy,rs,pod -l app=bff
|
||||||
|
```
|
||||||
|
|
||||||
|
**실측**
|
||||||
|
```
|
||||||
|
replicaset.apps/bff-555df79c97 2 2 ← 지금 쓰이는 것
|
||||||
|
replicaset.apps/bff-574c6d658b 0 0 ← 지난 배포
|
||||||
|
replicaset.apps/bff-576d869c6d 0 0
|
||||||
|
... (7개)
|
||||||
|
pod/bff-555df79c97-6j86w 1/1 Running
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — ReplicaSet 줄들 중 **DESIRED 가 0 이 아닌 것 하나**
|
||||||
|
를 찾고, 그 이름의 해시를 파드 이름 가운데 해시와 맞춰 본다. 위 실측에서는
|
||||||
|
`555df79c97` 이 그 짝이다. 나머지 `0 0` 줄들은 읽지 않아도 된다.
|
||||||
|
|
||||||
|
**이 결과가 의미하는 것** — **ReplicaSet 이 여러 개인 것은 정상이다.**
|
||||||
|
배포할 때마다 새로 만들고 옛것은 `0` 으로 남긴다 — 그래서
|
||||||
|
`kubectl rollout undo` 가 가능하다. 파드가 옛 해시를 달고 있으면 새 배포가
|
||||||
|
아직 안 넘어온 것이고, 그 상태로 실험하면 **고친 적 없는 코드를 재게 된다.**
|
||||||
|
사슬이 어디서 끊겼는지는 이렇게 읽는다.
|
||||||
|
|
||||||
|
| 보이는 것 | 뜻 |
|
||||||
|
|---|---|
|
||||||
|
| Deployment 는 있는데 RS 가 없다 | 컨트롤러가 못 돌았다 — RBAC·admission 확인 |
|
||||||
|
| RS 는 있는데 DESIRED 만 있고 CURRENT 가 0 | 파드를 못 만든다 — 이벤트를 본다 |
|
||||||
|
| Pod 은 있는데 `0/1` | 컨테이너가 안 뜬다 — 로그와 describe |
|
||||||
|
|
||||||
|
> StatefulSet 은 ReplicaSet 을 쓰지 않고 파드를 직접 만든다. 그래서
|
||||||
|
> `keycloak-0`·`keycloak-1` 처럼 **이름이 고정**이고, A-4 에서 `Terminating`
|
||||||
|
> 파드가 안 지워지면 대체 파드가 안 생기는 이유가 이것이다.
|
||||||
|
|
||||||
|
### 2-3. Secret 이 실제로 들어갔나 — 세 층으로 본다
|
||||||
|
|
||||||
|
값이 있는 것과 파드가 그 값을 받은 것은 다르다.
|
||||||
|
|
||||||
|
**확인 ①** Secret 에 무슨 키가, 얼마만큼 들어 있나 — **값은 찍지 않는다**
|
||||||
|
```bash
|
||||||
|
kubectl -n keycloak-lab describe secret keycloak-lab-secrets
|
||||||
|
```
|
||||||
|
|
||||||
|
**실측** — 아래쪽 `Data` 절만 옮긴 것이다
|
||||||
|
```
|
||||||
|
Data
|
||||||
|
====
|
||||||
|
KC_BOOTSTRAP_ADMIN_PASSWORD: 19 bytes
|
||||||
|
POSTGRES_PASSWORD: 22 bytes
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — `Data` 절의 **키 이름과 그 옆의 바이트 수** 두 칸.
|
||||||
|
`describe` 는 값을 절대 찍지 않고 길이만 보여 준다 — 그래서 키 목록 확인과
|
||||||
|
「비어 있지 않은가」 확인이 **한 명령으로 끝난다.** `0 bytes` 인 키가 있으면
|
||||||
|
그 자리가 비어 있는 것이다.
|
||||||
|
|
||||||
|
**이 결과가 의미하는 것** — 매니페스트가 기대하는 키 이름이 여기 그대로
|
||||||
|
있어야 한다. 이름이 하나라도 다르면 파드는 `CreateContainerConfigError` 로
|
||||||
|
멈추고, 이유는 `describe pod` 의 Events 에 키 이름까지 적혀 나온다.
|
||||||
|
바이트 수가 뜻밖에 크면(예: 20 이어야 할 것이 21) **`echo` 로 만들면서 개행이
|
||||||
|
같이 들어간** 경우다 — 흔한 사고이고, 증상은 「비밀번호가 틀렸다」로 나온다.
|
||||||
|
|
||||||
|
> **`-o yaml` 로 보지 않는다.** base64 는 암호화가 아니라 인코딩이라
|
||||||
|
> 화면·스크롤백·화면 공유·터미널 로그에 값이 그대로 남는다.
|
||||||
|
> D-3 이 잰 것이 이것이다 — [`experiment-d3-secret-management.md`](../../experiment-d3-secret-management.md)
|
||||||
|
|
||||||
|
**확인 ②** 특정 키 하나를 따져 볼 때 — **길이만**
|
||||||
|
|
||||||
|
`describe` 가 보여 주는 바이트 수는 base64 를 푼 뒤의 길이다. 어떤 키
|
||||||
|
하나가 의심스러워 다시 잴 때만 이 형태를 쓴다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
kubectl -n keycloak-lab get secret keycloak-lab-secrets \
|
||||||
|
-o jsonpath='{.data.POSTGRES_PASSWORD}' | base64 -d | wc -c
|
||||||
|
```
|
||||||
|
```
|
||||||
|
22
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — 숫자 하나. 그리고 그것이 확인 ①의 `22 bytes` 와
|
||||||
|
같은가.
|
||||||
|
|
||||||
|
**이 결과가 의미하는 것** — 두 값이 같으면 Secret 쪽은 더 볼 것이 없다.
|
||||||
|
`base64: invalid input` 이 나오면 키 이름을 잘못 쓴 것이다(없는 키는 빈
|
||||||
|
문자열로 나온다). 여기까지는 **Secret 안에 무엇이 있나**이고, 파드가 그것을
|
||||||
|
받았는지는 아직 모른다.
|
||||||
|
|
||||||
|
**확인 ③** 파드 안에 주입됐나 — 여기가 진짜다
|
||||||
|
```bash
|
||||||
|
kubectl -n keycloak-lab exec keycloak-0 -- \
|
||||||
|
sh -c 'echo "길이=${#KC_BOOTSTRAP_ADMIN_PASSWORD}"'
|
||||||
|
```
|
||||||
|
```
|
||||||
|
길이=19
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — 숫자 하나. `${#VAR}` 는 값이 아니라 **글자 수**만
|
||||||
|
내놓는다. 이것이 확인 ①의 `19 bytes` 와 같은가.
|
||||||
|
|
||||||
|
**이 결과가 의미하는 것** — 같으면 Secret → 파드 환경변수까지 이어졌다.
|
||||||
|
`길이=0` 이면 Secret 에는 있는데 **이 파드가 그것을 안 받은** 것이다 —
|
||||||
|
`envFrom`/`valueFrom` 을 빠뜨렸거나, 파드가 Secret 을 고치기 **전에** 떠서
|
||||||
|
옛 값을 들고 있는 경우다(환경변수로 주입한 Secret 은 값을 바꿔도 파드를
|
||||||
|
다시 만들기 전까지 갱신되지 않는다).
|
||||||
|
|
||||||
|
**어느 환경변수가 어느 Secret 에서 왔는지**도 볼 수 있다.
|
||||||
|
```bash
|
||||||
|
kubectl -n keycloak-lab get pod keycloak-0 \
|
||||||
|
-o jsonpath='{range .spec.containers[0].env[*]}{.name}{"\t"}{.valueFrom.secretKeyRef.name}{"\n"}{end}'
|
||||||
|
```
|
||||||
|
```
|
||||||
|
KC_DB
|
||||||
|
KC_DB_URL
|
||||||
|
KC_DB_USERNAME
|
||||||
|
KC_DB_PASSWORD keycloak-lab-secrets ← Secret 에서 온 것만 오른쪽에 이름이 있다
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — **오른쪽 칸이 채워진 줄만**. 왼쪽만 있는 줄은
|
||||||
|
매니페스트에 값이 그대로 적힌 것이고, 오른쪽에 이름이 있는 줄이 Secret 을
|
||||||
|
참조하는 것이다.
|
||||||
|
|
||||||
|
**이 결과가 의미하는 것** — 비밀이어야 할 변수의 오른쪽이 비어 있으면
|
||||||
|
**그 값은 매니페스트에 평문으로 적혀 있다는 뜻**이고, 그 파일은 대개 git 에
|
||||||
|
들어간다. 여기서는 `KC_DB_PASSWORD` 만 Secret 에서 온다.
|
||||||
|
|
||||||
|
### 2-4. Service 가 파드를 잡고 있나 — Endpoints
|
||||||
|
|
||||||
|
Service 가 있어도 **셀렉터가 안 맞으면 뒤가 비어 있다.** 이때 증상은
|
||||||
|
「연결은 되는데 응답이 없다」라 원인을 찾기 어렵다.
|
||||||
|
|
||||||
|
**확인** — 실무자가 가장 자주 쓰는 형태
|
||||||
|
```bash
|
||||||
|
kubectl -n keycloak-lab describe svc keycloak | grep -i endpoints
|
||||||
|
```
|
||||||
|
```
|
||||||
|
Endpoints: 10.42.0.67:8080,10.42.1.155:8080
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — 쉼표로 갈린 **주소가 몇 개인가**, 그리고 그 IP 들이
|
||||||
|
`kubectl -n keycloak-lab get pods -o wide` 의 파드 IP 와 같은가. 포트 번호가
|
||||||
|
컨테이너가 실제로 듣는 포트인가도 함께 본다.
|
||||||
|
|
||||||
|
**이 결과가 의미하는 것** — 두 개면 Service 가 두 파드를 다 잡고 있다.
|
||||||
|
**비어 있으면** Service 는 있는데 뒤가 없는 것이고, 이때 증상은
|
||||||
|
「연결은 되는데 응답이 없다」라 원인이 Service 에 있다는 것이 잘 안 보인다.
|
||||||
|
하나뿐이면 나머지 한 파드가 readiness 를 통과하지 못한 것이라, 그 상태로
|
||||||
|
A층 실험을 하면 **이미 한쪽으로만 가고 있던 트래픽**을 이중화 실패로
|
||||||
|
오독하게 된다.
|
||||||
|
|
||||||
|
목록으로 보려면 **EndpointSlice** 를 쓴다.
|
||||||
|
```bash
|
||||||
|
kubectl -n keycloak-lab get endpointslice -l kubernetes.io/service-name=keycloak
|
||||||
|
```
|
||||||
|
```
|
||||||
|
NAME ADDRESSTYPE PORTS ENDPOINTS AGE
|
||||||
|
keycloak-xdph6 IPv4 8080 10.42.0.67,10.42.1.155 3d23h
|
||||||
|
```
|
||||||
|
|
||||||
|
> **`kubectl get endpoints` 는 쓰지 않는다.** v1.33 부터 deprecated 이고
|
||||||
|
> 실행하면 경고가 나온다.
|
||||||
|
> ```
|
||||||
|
> Warning: v1 Endpoints is deprecated in v1.33+; use discovery.k8s.io/v1 EndpointSlice
|
||||||
|
> ```
|
||||||
|
> 옛 문서와 블로그에 이 형태가 많으니 주의한다.
|
||||||
|
|
||||||
|
준비 상태까지 함께 보려면 이렇게 뽑는다.
|
||||||
|
```bash
|
||||||
|
kubectl -n keycloak-lab get endpointslice -l kubernetes.io/service-name=keycloak \
|
||||||
|
-o jsonpath='{range .items[*].endpoints[*]}{.addresses[0]}{"\t"}{.conditions.ready}{"\n"}{end}'
|
||||||
|
```
|
||||||
|
```
|
||||||
|
10.42.0.67 true
|
||||||
|
10.42.1.155 true
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — 오른쪽 칸이 두 줄 다 `true` 인가. 여기서만 값을
|
||||||
|
뽑는 형태를 쓰는 이유는, 이 두 칸이 **A층 실험 전후로 반복해서 비교할
|
||||||
|
값**이기 때문이다. 처음 볼 때는 위의 `describe svc` 로 충분하다.
|
||||||
|
|
||||||
|
**이 결과가 의미하는 것** — `ready` 가 `false` 면 파드는 있는데 **readiness
|
||||||
|
프로브를 통과하지 못한** 것이라, Service 가 그 파드로 트래픽을 보내지 않는다.
|
||||||
|
파드 목록에서는 `Running` 으로 보이므로 `get pods` 만 봐서는 알 수 없다 —
|
||||||
|
`0/1` 인지 `1/1` 인지가 같은 사실을 말해 준다.
|
||||||
|
|
||||||
|
**비어 있으면** 셀렉터와 파드 라벨이 안 맞는 것이다.
|
||||||
|
```bash
|
||||||
|
kubectl -n keycloak-lab get svc keycloak -o jsonpath='{.spec.selector}'; echo
|
||||||
|
kubectl -n keycloak-lab get pods --show-labels
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2-5. PVC 가 실제로 붙었나
|
||||||
|
|
||||||
|
**확인** — 볼륨이 실제로 잡혔는가
|
||||||
|
```bash
|
||||||
|
kubectl -n keycloak-lab get pvc
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — STATUS 칸(`Bound`/`Pending`)과 VOLUME 칸(비어 있는지),
|
||||||
|
그리고 STORAGECLASS 칸이 `local-path` 인가. StatefulSet 이면 PVC 이름 끝에
|
||||||
|
파드 번호가 붙어 있어(`...-keycloak-0`) 어느 파드 것인지 바로 보인다.
|
||||||
|
|
||||||
|
**이 결과가 의미하는 것** — `Bound` 면 볼륨이 붙었다. `Pending` 이면
|
||||||
|
StorageClass 가 없거나 노드에 자리가 없다. **`local-path` 는 파드가 스케줄될
|
||||||
|
때까지 기다린다**(WaitForFirstConsumer)므로, 파드가 안 뜨면 PVC 도 `Pending`
|
||||||
|
인 것이 정상이다. 둘이 서로를 기다리는 것처럼 보이지만 파드 쪽 원인을 먼저
|
||||||
|
본다. 사유는 PVC 의 이벤트에 적혀 있다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
kubectl -n keycloak-lab describe pvc # 이름을 안 주면 전부 나온다
|
||||||
|
```
|
||||||
|
|
||||||
|
각 PVC 절의 맨 아래 `Events` 에 `waiting for first consumer` 인지
|
||||||
|
`no persistent volumes available` 인지가 적혀 있고, 둘은 대응이 다르다 —
|
||||||
|
앞엣것은 파드를 고치는 문제, 뒤엣것은 저장소를 고치는 문제다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 안 뜰 때 — 순서가 있다
|
||||||
|
|
||||||
|
**① 이벤트부터.** 로그보다 먼저다. 스케줄링·이미지·볼륨 실패가 여기 나온다.
|
||||||
|
```bash
|
||||||
|
kubectl -n keycloak-lab get events --sort-by=.lastTimestamp | tail -20
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — 맨 아랫줄부터 거꾸로 읽는다. `--sort-by` 를 준 이유가
|
||||||
|
그것이다. TYPE 이 `Warning` 인 줄, REASON 칸(`FailedScheduling`·`Failed`·
|
||||||
|
`BackOff`), 그리고 OBJECT 칸이 어느 파드인가. **이벤트는 기본 한 시간만
|
||||||
|
남는다** — 아무것도 없으면 「문제가 없다」가 아니라 「이미 지워졌다」일 수 있다.
|
||||||
|
|
||||||
|
**이 결과가 의미하는 것** — REASON 하나가 다음 행동을 정한다.
|
||||||
|
`FailedScheduling` 은 노드에 자리가 없는 것이라 파드가 아니라 클러스터를
|
||||||
|
봐야 하고, `ErrImagePull` 은 이미지 이름 문제라 로그를 볼 것도 없다.
|
||||||
|
`BackOff` 는 컨테이너가 떴다가 죽는 중이라는 뜻이라 ③으로 간다.
|
||||||
|
|
||||||
|
**② describe.** 그 파드에 한정된 이벤트와 상태를 함께 본다.
|
||||||
|
```bash
|
||||||
|
kubectl -n keycloak-lab describe pod keycloak-0
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — 위에서부터 세 곳이다. `Conditions` 절에서 `Ready` 가
|
||||||
|
`False` 인가, 컨테이너 절의 `State`/`Last State` 와 그 안의 **`Exit Code`**,
|
||||||
|
그리고 맨 아래 `Events`. Exit Code 는 그것만으로 말이 된다 — `137` 은
|
||||||
|
OOM 이나 강제 종료, `1` 은 애플리케이션이 스스로 끝낸 것, `127` 은 명령을
|
||||||
|
못 찾은 것이다.
|
||||||
|
|
||||||
|
**이 결과가 의미하는 것** — Exit Code 가 `137` 이면 로그에는 아무 단서도
|
||||||
|
없을 수 있다(맞아 죽은 쪽은 유언을 못 남긴다) — 메모리 한도를 본다.
|
||||||
|
`Ready` 만 `False` 이고 컨테이너는 살아 있으면 readiness 프로브 문제이므로
|
||||||
|
2-4 로 돌아간다.
|
||||||
|
|
||||||
|
**③ 로그.** 컨테이너가 떴는데 죽는 경우다.
|
||||||
|
```bash
|
||||||
|
kubectl -n keycloak-lab logs keycloak-0
|
||||||
|
kubectl -n keycloak-lab logs keycloak-0 --previous # 재시작 직전 로그
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — 첫 명령에서는 **마지막 줄들**, 둘째 명령에서는
|
||||||
|
스택 트레이스의 **맨 윗줄**(가장 안쪽 예외가 아니라 최초 원인 줄)이다.
|
||||||
|
Keycloak 은 기동에 성공하면 `Keycloak ... started in` 한 줄을 남기므로,
|
||||||
|
그 줄이 있는지 없는지가 「기동 중」과 「기동 실패」를 가른다.
|
||||||
|
|
||||||
|
**이 결과가 의미하는 것** — `--previous` 가 중요하다. CrashLoopBackOff 면
|
||||||
|
지금 컨테이너는 방금 뜬 것이라 **죽은 이유는 이전 컨테이너 로그에 있다.**
|
||||||
|
`--previous` 가 `not found` 를 내면 아직 한 번도 재시작하지 않은 것이고,
|
||||||
|
그러면 지금 로그가 곧 전부다.
|
||||||
|
|
||||||
|
**④ 그래도 모르면 안에서 본다.**
|
||||||
|
```bash
|
||||||
|
kubectl -n keycloak-lab exec -it keycloak-0 -- sh
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 클러스터가 형성됐는지 확인한다
|
||||||
|
|
||||||
|
파드가 둘 다 `Running` 인 것과 **하나의 클러스터로 묶인 것**은 다르다.
|
||||||
|
|
||||||
|
**확인 ①** 로그
|
||||||
|
```bash
|
||||||
|
kubectl -n keycloak-lab logs keycloak-0 | grep ISPN000094 | tail -1
|
||||||
|
```
|
||||||
|
```
|
||||||
|
ISPN000094: Received new cluster view for channel ISPN:
|
||||||
|
[keycloak-0-10001|1] (2) [keycloak-0-10001, keycloak-1-52537]
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — 세 군데다. 괄호 안의 **`(2)` 가 멤버 수**,
|
||||||
|
대괄호 안의 **이름 목록**, 그리고 `|1` 이 **뷰 번호**(멤버가 들고 날 때마다
|
||||||
|
올라간다). `tail -1` 을 붙였으므로 지금 보고 있는 것은 **가장 최근 뷰** 하나다.
|
||||||
|
|
||||||
|
**이 결과가 의미하는 것** — `(2)` 면 이 노드는 상대를 봤다. `(1)` 이면 혼자
|
||||||
|
있다고 알고 있는 것이다. 뷰 번호가 계속 오르고 있으면 멤버가 붙었다
|
||||||
|
떨어지기를 반복하는 중이라, 「지금 2 다」라는 스냅숏보다 그 사실이 더 중요하다.
|
||||||
|
**이 줄은 「그때 그렇게 보였다」는 과거형이다** — 지금 상태는 확인 ③에서 본다.
|
||||||
|
`grep` 이 아무것도 못 찾으면 클러스터링을 아직 시작도 못 한 것이니 로그를
|
||||||
|
통째로 본다.
|
||||||
|
|
||||||
|
**확인 ②** 디스커버리 테이블
|
||||||
|
```bash
|
||||||
|
kubectl -n keycloak-lab exec deploy/postgres -- \
|
||||||
|
psql -U keycloak -d keycloak -c 'select name, ip from jgroups_ping'
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — 나온 **행이 몇 개인가**, 그리고 `ip` 칸이 2-4 에서
|
||||||
|
본 파드 IP 와 같은가. 끝의 `(N rows)` 한 줄이 개수를 말해 준다.
|
||||||
|
|
||||||
|
**이 결과가 의미하는 것** — 이 표는 「**등록**되어 있다」이지 「서로 말이
|
||||||
|
통한다」가 아니다. 두 행이 다 있는데 확인 ①이 `(1)` 이면, 서로를 찾기는
|
||||||
|
했는데 7800 포트로 메시지가 안 가는 것이다 — A-1 에서 정확히 그 일이
|
||||||
|
벌어졌다. 옛 파드의 행이 남아 있을 수도 있으므로 IP 를 지금 파드와 대조한다.
|
||||||
|
|
||||||
|
**확인 ③** 지표
|
||||||
|
|
||||||
|
**★ Keycloak 컨테이너에는 `curl` 이 없다.** 공식 이미지가 최소 구성이라
|
||||||
|
`wget` 도 `nc` 도 없다. 안에서 치면 이렇게 된다.
|
||||||
|
|
||||||
|
```
|
||||||
|
sh: line 1: curl: command not found
|
||||||
|
command terminated with exit code 127
|
||||||
|
```
|
||||||
|
|
||||||
|
그래서 밖에서 물어본다. **Prometheus 에 묻는 것이 가장 짧다.**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
kubectl -n observability exec deploy/prometheus -- \
|
||||||
|
wget -qO- 'localhost:9090/api/v1/query?query=vendor_cluster_size'
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — 응답은 **줄바꿈 없는 JSON 한 줄**로 온다.
|
||||||
|
`data.result` 배열에서 원소가 **몇 개인가**(노드 수), 각 원소에서 두 군데만
|
||||||
|
읽는다 — `"metric"` 안의 `node` 라벨(어느 Keycloak 인가)과 `"value"` 배열의
|
||||||
|
**둘째 원소**(따옴표에 싸인 값). **이 실험대에는 `jq` 가 없다.** 파서를
|
||||||
|
따로 짜지 말고 화면에 나온 JSON 을 그대로 읽는다.
|
||||||
|
|
||||||
|
**실측** — 그렇게 읽어낸 값이다
|
||||||
|
```
|
||||||
|
keycloak-1 → 2
|
||||||
|
keycloak-0 → 2
|
||||||
|
```
|
||||||
|
|
||||||
|
**이 결과가 의미하는 것** — 원소가 둘이고 값이 둘 다 2 면 두 노드가 서로를
|
||||||
|
보고 있다. 원소가 하나뿐이면 나머지 노드의 스크레이프가 실패한 것이라
|
||||||
|
**클러스터 문제가 아니라 관측 문제**일 수 있다 — 06 의 targets 를 본다.
|
||||||
|
|
||||||
|
Prometheus 가 아직 없다면 임시 파드를 띄운다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
K0=$(kubectl -n keycloak-lab get pod keycloak-0 -o jsonpath='{.status.podIP}')
|
||||||
|
kubectl -n keycloak-lab run m --rm -i --restart=Never \
|
||||||
|
--image=curlimages/curl:8.11.1 --quiet --command -- \
|
||||||
|
sh -c "curl -s http://$K0:9000/metrics | grep '^vendor_cluster_size'"
|
||||||
|
```
|
||||||
|
|
||||||
|
```
|
||||||
|
vendor_cluster_size{cache_manager="keycloak",node="keycloak-0-46674"} 2.0
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — 줄 끝의 **숫자**와, 중괄호 안 `node=` 라벨이 **어느
|
||||||
|
파드인가**. 이 형태는 그 파드 하나에게 직접 물은 것이라 라벨의 노드 이름과
|
||||||
|
`$K0` 로 고른 파드가 반드시 일치한다.
|
||||||
|
|
||||||
|
**이 결과가 의미하는 것** — `--rm` 을 붙였으므로 파드는 끝나면 사라진다.
|
||||||
|
`curlimages/curl` 을 쓰는 이유는 **Keycloak 이미지에 도구가 없기** 때문이고,
|
||||||
|
같은 이유로 이 방법은 Keycloak 뿐 아니라 최소 이미지 전부에 쓴다. 값이
|
||||||
|
안 나오고 연결 거부가 나면 9000(관리 포트)이 안 열린 것이다.
|
||||||
|
|
||||||
|
> **두 값이 다를 수 있다.** 각 노드가 자기가 아는 멤버 수를 보고하므로,
|
||||||
|
> 분단되면 한쪽은 2 다른 쪽은 1 이 된다. **한 노드만 보면 분단을 놓친다.**
|
||||||
|
|
||||||
|
> **셋이 다른 것을 본다.** 로그는 「그때 그렇게 보였다」이고, 테이블은
|
||||||
|
> 「지금 등록되어 있다」이며, 지표는 「지금 그 노드가 그렇게 안다」이다.
|
||||||
|
> A-1 에서 이 셋이 갈렸다 — 테이블에는 둘 다 있는데 메시지는 안 갔다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 밖에서 닿는지
|
||||||
|
|
||||||
|
**확인** — 2홉을 다 지나 파드까지 닿는가
|
||||||
|
```bash
|
||||||
|
curl -s -o /dev/null -w '%{http_code}\n' https://auth.hyeonworks.com/realms/master
|
||||||
|
```
|
||||||
|
```
|
||||||
|
200
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — 코드 한 칸. 여기서 값만 뽑는 형태를 쓰는 것은
|
||||||
|
**03·04 에서 잰 것과 같은 명령으로 같은 값이 나오는지** 비교하는 것이
|
||||||
|
목적이기 때문이다. 이 자리에서 처음 보는 것이 아니다.
|
||||||
|
|
||||||
|
**이 결과가 의미하는 것** — `200` 이면 nginx → Traefik → Ingress → Service →
|
||||||
|
파드가 전부 이어졌다. `502`·`503` 이면 뒤에서부터 되짚는다 — Ingress 가
|
||||||
|
있는지(2-1), Service 뒤에 파드가 있는지(2-4), 파드가 Ready 인지(2-2) 순서다.
|
||||||
|
처음 보는 오류라 헤더가 필요하면 값만 뽑는 형태를 버리고 읽는 형태로 바꾼다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -I https://auth.hyeonworks.com/realms/master
|
||||||
|
```
|
||||||
|
|
||||||
|
브라우저로 `https://auth.hyeonworks.com/admin` 에 들어가 관리자로 로그인한다.
|
||||||
|
비밀번호는 위 2-3 의 Secret 에 있다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 막히면
|
||||||
|
|
||||||
|
| 증상 | 어디를 보나 |
|
||||||
|
|---|---|
|
||||||
|
| 파드가 `Pending` | `describe pod` 의 Events — 스케줄 불가 사유 |
|
||||||
|
| `ImagePullBackOff` | 이미지 이름·태그. 자체 빌드면 두 노드 모두에 반입했는가 |
|
||||||
|
| `CrashLoopBackOff` | `logs --previous` |
|
||||||
|
| `Running` 인데 `0/1` | readiness 프로브 실패. `describe` 의 Conditions |
|
||||||
|
| 밖에서 502 | Ingress → Service → Endpoints 순으로 뒤를 본다 |
|
||||||
|
| 클러스터가 1로 보임 | 7800 이 막혔거나 디스커버리 실패. 위 4번 셋 다 확인 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 근거를 재려면 (선택)
|
||||||
|
|
||||||
|
세션이 실제로 어디 저장되는지는 DB 를 직접 본다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
|
||||||
|
-c "select offline_flag, count(*) from offline_user_session group by 1"
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — `offline_flag` 가 `0` 인 행의 `count`. **로그인
|
||||||
|
전과 후에 두 번 재서 그 수의 차이**를 본다. 한 번만 재면 아무것도 알 수
|
||||||
|
없다. 행이 아예 없으면(`0 rows`) 표는 있는데 비어 있는 것이다.
|
||||||
|
|
||||||
|
**이 결과가 의미하는 것** — 로그인 뒤 수가 늘면 세션이 DB 에 남는 것
|
||||||
|
(`persistent-user-sessions` 켜짐)이고, 안 늘면 메모리에만 있는 것이다.
|
||||||
|
그 차이가 A층 결론 전체를 뒤집는다 — 메모리에만 있으면 파드를 재시작하는
|
||||||
|
순간 세션이 사라지고, DB 에 있으면 살아남는다.
|
||||||
|
[A-7](../../experiment-a7-volatile-comparison.md)
|
||||||
@@ -0,0 +1,198 @@
|
|||||||
|
# 06 — 관측
|
||||||
|
|
||||||
|
## 이 단계가 끝나면
|
||||||
|
|
||||||
|
Prometheus 가 Keycloak 을 긁고, `vendor_cluster_size` 로 클러스터 상태를
|
||||||
|
밖에서 볼 수 있다.
|
||||||
|
|
||||||
|
## 전제
|
||||||
|
|
||||||
|
[05](../05-keycloak/) 가 끝나 Keycloak 두 노드가 떴다.
|
||||||
|
|
||||||
|
## 왜 필요한가
|
||||||
|
|
||||||
|
실험의 판정을 **밖에서만** 하면 놓친다. A-1 에서 7800 을 끊었는데 외부 응답이
|
||||||
|
전부 200 이었다 — 분단된 노드가 스스로 로드밸런서에서 빠졌기 때문이다.
|
||||||
|
클러스터 안을 보는 눈이 따로 있어야 한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 적용
|
||||||
|
|
||||||
|
**하기**
|
||||||
|
```bash
|
||||||
|
kubectl apply -f deploy/lab/k8s/observability.yaml
|
||||||
|
kubectl -n observability rollout status deploy/prometheus --timeout=180s
|
||||||
|
```
|
||||||
|
|
||||||
|
**확인** — 무엇이 몇 개 떴는가
|
||||||
|
```bash
|
||||||
|
kubectl -n observability get pods
|
||||||
|
```
|
||||||
|
|
||||||
|
**실측**
|
||||||
|
```
|
||||||
|
grafana-845b5678cf-b6gvc 1/1 Running
|
||||||
|
node-exporter-9qk9w 1/1 Running
|
||||||
|
node-exporter-c2mz4 1/1 Running
|
||||||
|
prometheus-6774f94f7c-pzr2t 1/1 Running
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — **줄이 네 개인가**, 그리고 READY 칸이 전부 `1/1`
|
||||||
|
인가. 특히 `node-exporter` 로 시작하는 줄이 **둘**인지 센다.
|
||||||
|
|
||||||
|
**이 결과가 의미하는 것** — node-exporter 가 둘인 것은 DaemonSet 이라 노드마다
|
||||||
|
하나씩 뜨기 때문이다. **하나뿐이면 노드 하나가 빠진 것**이고, 그러면 그
|
||||||
|
노드의 CPU·메모리·디스크 지표가 통째로 없는 채로 실험을 하게 된다 —
|
||||||
|
이때는 관측이 아니라 02 의 노드 상태부터 본다. 어느 노드에 붙었는지는
|
||||||
|
`-o wide` 로 확인한다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
kubectl -n observability get pods -o wide
|
||||||
|
```
|
||||||
|
|
||||||
|
## 2. 무엇을 긁고 있나 — 여기가 중요하다
|
||||||
|
|
||||||
|
**확인** — Prometheus 가 스스로 밝히는 대상 목록
|
||||||
|
```bash
|
||||||
|
kubectl -n observability exec deploy/prometheus -- \
|
||||||
|
wget -qO- localhost:9090/api/v1/targets | grep -o '"job":"[^"]*"' | sort -u
|
||||||
|
```
|
||||||
|
|
||||||
|
**실측**
|
||||||
|
```
|
||||||
|
"job":"keycloak"
|
||||||
|
"job":"kubelet"
|
||||||
|
"job":"node-exporter"
|
||||||
|
"job":"prometheus"
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — **거기 있는 이름이 아니라 없는 이름**이다. 응답은
|
||||||
|
JSON 한 덩어리이고 그대로는 못 읽는다. **이 실험대에는 `jq` 가 없으므로**
|
||||||
|
`grep -o` 로 필요한 필드만 뽑고 `sort -u` 로 중복을 없앤 것이다 — 여기까지가
|
||||||
|
사람이 손으로 치는 선이고, 그 이상 가공해야 한다면 파서를 짜지 말고 화면에
|
||||||
|
나온 JSON 을 그대로 읽는다.
|
||||||
|
|
||||||
|
**이 결과가 의미하는 것** — **★ Redis · BFF · PostgreSQL 이 없다.** 이 실험대는
|
||||||
|
그것들을 긁지 않는다. 그래서 B층 실험 대부분에 Grafana 화면이 없는데,
|
||||||
|
**안 찍은 것이 아니라 지표가 없는 것**이다. 어떤 실험에서 지표를 못 찾으면
|
||||||
|
「측정이 실패했다」로 적기 전에 **이 목록에 그 job 이 있었는지부터** 본다.
|
||||||
|
|
||||||
|
> 이것을 「스크린샷 누락」이 아니라 **측정된 공백**으로 기록했다.
|
||||||
|
> [`evidence/followup/04-observability-gap.txt`](../../evidence/followup/04-observability-gap.txt)
|
||||||
|
|
||||||
|
목록에 있는데도 값이 안 나온다면 그다음은 **상태**다. 같은 응답에서
|
||||||
|
`health` 만 훑는다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
kubectl -n observability exec deploy/prometheus -- \
|
||||||
|
wget -qO- localhost:9090/api/v1/targets | tr ',' '\n' | grep -E '"(job|health|lastError)"'
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — `"health":"up"` 이 아닌 줄과, 그 **바로 뒤에 붙는
|
||||||
|
`lastError`**. `tr ',' '\n'` 으로 쉼표마다 줄을 나눴으므로 필드가 원래
|
||||||
|
순서대로 세로로 늘어선다 — job 줄 아래에 그 대상의 health 가 온다.
|
||||||
|
|
||||||
|
**이 결과가 의미하는 것** — `down` 인 대상이 있으면 `lastError` 가 이유를
|
||||||
|
그대로 말해 준다(연결 거부·타임아웃·404). 3번에서 값이 한 노드만 나오는
|
||||||
|
증상의 원인이 대개 여기 있고, 그때 **클러스터가 아니라 스크레이프가 문제**다.
|
||||||
|
|
||||||
|
## 3. 클러스터 상태를 본다
|
||||||
|
|
||||||
|
**확인** — 두 노드가 각각 몇 명을 보고 있는가
|
||||||
|
```bash
|
||||||
|
kubectl -n observability exec deploy/prometheus -- \
|
||||||
|
wget -qO- 'localhost:9090/api/v1/query?query=vendor_cluster_size'
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — 응답은 **줄바꿈 없는 JSON 한 줄**이다. `data.result`
|
||||||
|
배열의 원소가 **몇 개인가**(보고하는 노드 수), 각 원소에서 두 군데만 읽는다 —
|
||||||
|
`"metric"` 안의 `node` 라벨과 `"value"` 배열의 **둘째 원소**(따옴표에 싸인
|
||||||
|
값). `jq` 가 없으므로 눈으로 읽는다.
|
||||||
|
|
||||||
|
**실측** — 그렇게 읽어낸 값이다
|
||||||
|
```
|
||||||
|
keycloak-1 → 2
|
||||||
|
keycloak-0 → 2
|
||||||
|
```
|
||||||
|
|
||||||
|
**이 결과가 의미하는 것** — **두 노드가 각각 자기가 아는 멤버 수를 보고한다.**
|
||||||
|
둘 다 2 면 클러스터가 온전하다. 분단되면 한쪽은 2, 다른 쪽은 1 이 된다 —
|
||||||
|
**한 노드만 보면 분단을 놓친다.** 원소가 하나뿐이면 분단이 아니라 스크레이프
|
||||||
|
실패일 수 있으므로 2번의 `health` 를 먼저 본다. 값이 아예 안 나오면
|
||||||
|
`"result":[]` 로 빈 배열이 오는데, 이는 「0 이다」가 아니라 **「그런 지표가
|
||||||
|
없다」**는 뜻이다.
|
||||||
|
|
||||||
|
자주 보는 지표들이다.
|
||||||
|
|
||||||
|
| 지표 | 무엇 |
|
||||||
|
|---|---|
|
||||||
|
| `vendor_cluster_size` | 이 노드가 아는 멤버 수 |
|
||||||
|
| `vendor_jgroups_*` | JGroups 프로토콜별 카운터 |
|
||||||
|
| `vendor_statistics_approximate_entries_unique{cache="sessions"}` | 이 노드의 세션 캐시 엔트리 수 |
|
||||||
|
| `agroal_*` | JDBC 커넥션 풀 |
|
||||||
|
| `up` | 스크레이프 성공 여부 |
|
||||||
|
|
||||||
|
## 4. `up` 을 믿지 않는다
|
||||||
|
|
||||||
|
**A-2 에서 503 이 나는 동안에도 `up` 은 1 이었다.** 프로세스가 살아 있고
|
||||||
|
`/metrics` 가 응답하기만 하면 1 이므로 **「살아 있지만 쓸모없는」 상태를
|
||||||
|
보지 못한다.**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
kubectl -n observability exec deploy/prometheus -- \
|
||||||
|
wget -qO- 'localhost:9090/api/v1/query?query=up'
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — 원소마다 `job` 라벨과 값(`"1"`/`"0"`). 값이 1 이라는
|
||||||
|
것은 **마지막 스크레이프가 성공했다**는 사실 하나만 말한다.
|
||||||
|
|
||||||
|
**이 결과가 의미하는 것** — `up=1` 은 「프로세스가 살아 있고 `/metrics` 가
|
||||||
|
응답했다」이지 「그 서비스가 쓸모 있다」가 아니다. 그래서 경보를 `up == 0`
|
||||||
|
하나로 걸면 **A-2 같은 「살아 있지만 503」 상태를 통째로 놓친다.**
|
||||||
|
기능 지표를 함께 본다 — 밖에서 실제 응답을 받아 보는 것이 가장 짧다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s -o /dev/null -w '%{http_code}\n' https://auth.hyeonworks.com/realms/master
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — 코드 한 칸. 여기서 값만 뽑는 형태를 쓰는 것은
|
||||||
|
`up` 의 1/0 과 **나란히 놓고 비교하기 위해서**다. 처음 보는 오류를 파고들
|
||||||
|
때는 `curl -I` 나 `curl -v` 로 바꾼다(04 참조).
|
||||||
|
|
||||||
|
**이 결과가 의미하는 것** — `up=1` 인데 이쪽이 200 이 아니면 그 조합이 곧
|
||||||
|
「살아 있지만 쓸모없는」 상태의 증거다. 그 두 값을 같이 기록해 두는 것이
|
||||||
|
A-2 의 판정 근거였다.
|
||||||
|
|
||||||
|
## 5. Grafana 를 볼 때
|
||||||
|
|
||||||
|
**하기** — 밖에 열지 않고 포트포워드로 본다
|
||||||
|
```bash
|
||||||
|
kubectl -n observability port-forward svc/grafana 3000:3000
|
||||||
|
```
|
||||||
|
|
||||||
|
**어디를 봐야 하는가** — `Forwarding from 127.0.0.1:3000 -> 3000` 한 줄이
|
||||||
|
찍히고 **명령이 그대로 멈춰 있는가**. 이 명령은 끝나지 않는 것이 정상이라,
|
||||||
|
터미널 하나를 여기에 내준다. 브라우저를 열면 그 아래에 `Handling connection`
|
||||||
|
줄이 하나씩 붙는다 — 그것이 붙지 않으면 브라우저가 다른 곳을 보고 있는 것이다.
|
||||||
|
|
||||||
|
**이 결과가 의미하는 것** — 이 터널은 **명령을 실행한 기계에서만** 열린다.
|
||||||
|
워크스테이션에서 쳤으면 워크스테이션 브라우저로 `http://localhost:3000`,
|
||||||
|
lab host 에서 쳤으면 lab host 에서 봐야 한다. `bind: address already in use`
|
||||||
|
면 3000 을 이미 누가 쓰는 것이니 `3001:3000` 처럼 왼쪽만 바꾼다. Ctrl+C 로
|
||||||
|
끊으면 터널도 사라진다 — 밖에 포트를 여는 것이 아니라 **보는 동안만 뚫는
|
||||||
|
것**이라 실험대의 노출면이 늘지 않는다.
|
||||||
|
|
||||||
|
> 실험 중에는 Grafana 보다 **Prometheus 쿼리 API** 가 편하다. 값을 그대로
|
||||||
|
> 뽑아 비교할 수 있고 스크린샷보다 근거로 남기기 좋다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 막히면
|
||||||
|
|
||||||
|
| 증상 | 원인 | 확인 |
|
||||||
|
|---|---|---|
|
||||||
|
| Keycloak 지표가 안 보임 | 9000 이 안 열렸거나 스크레이프 설정 누락 | 위 2번 targets |
|
||||||
|
| 값이 한 노드만 나옴 | 다른 노드 스크레이프 실패 | targets 의 `health` 필드 |
|
||||||
|
| 컨테이너 안에서 curl 실패 | **Keycloak 이미지에 curl 이 없다** | 밖에서 Prometheus 로 묻는다 |
|
||||||
|
| Grafana 에 데이터 없음 | 데이터소스 주소 오류 | Prometheus 서비스 이름 확인 |
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
# 실습 가이드 — 직접 쳐보면서 만드는 실험대
|
||||||
|
|
||||||
|
이 문서 묶음은 **읽는 문서가 아니라 따라 치는 문서**다. 기존
|
||||||
|
[`experiment-*.md`](../) 가 「무엇을 발견했나」를 적었다면, 여기는
|
||||||
|
「그 발견을 재현하려면 무엇을 어떤 순서로 치는가」를 적는다.
|
||||||
|
|
||||||
|
## 두 종류의 명령을 구별해 적는다
|
||||||
|
|
||||||
|
실무자가 터미널에서 치는 명령과, 근거를 남기려고 재는 명령은 길이도 목적도
|
||||||
|
다르다. 이 가이드는 둘을 섞지 않는다.
|
||||||
|
|
||||||
|
| 표시 | 무엇인가 |
|
||||||
|
|---|---|
|
||||||
|
| **하기** · **확인** | 실무자가 실제로 치는 형태. 짧고, 한 번에 하나씩 |
|
||||||
|
| **근거를 재려면** | 이 실험대가 문서에 남기려고 쓴 긴 형태. 평소에는 필요 없다 |
|
||||||
|
|
||||||
|
예를 들어 nginx 에러 로그가 잘렸을 때, 실무자는 잘린 걸 보고 access 로그로
|
||||||
|
넘어간다. 길이를 재서 2048인지 확인하는 것은 **몰라서 재는** 것이고, 알면
|
||||||
|
재지 않는다.
|
||||||
|
|
||||||
|
같은 이유로 `curl` 도 두 형태가 있다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -I https://auth.hyeonworks.com/realms/master # 한 번 볼 때
|
||||||
|
curl -s -o /dev/null -w '%{http_code}\n' <url> # 여러 번 재서 비교할 때
|
||||||
|
```
|
||||||
|
|
||||||
|
이 가이드의 **확인**은 값을 대조해야 해서 두 번째 형태를 자주 쓴다. 실제로
|
||||||
|
터미널에서 눈으로 볼 때는 첫 번째로 충분하다.
|
||||||
|
|
||||||
|
## 자리표시자를 두지 않는다
|
||||||
|
|
||||||
|
`<토큰>` 처럼 적어 두면 그 값을 어디서 가져오는지가 문서 밖으로 나간다.
|
||||||
|
이 가이드는 **값을 찾는 명령을 함께 적는다.**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
TOKEN=$(ssh kc-lab-1 'sudo cat /var/lib/rancher/k3s/server/node-token')
|
||||||
|
echo "${#TOKEN} 자" # 값이 아니라 길이만 확인한다
|
||||||
|
```
|
||||||
|
|
||||||
|
비밀은 길이나 존재 여부만 확인하고 값을 찍지 않는다. 터미널 스크롤백과
|
||||||
|
화면 공유에 남기 때문이다.
|
||||||
|
|
||||||
|
## 어느 기계에서 치는가
|
||||||
|
|
||||||
|
이 실험대에는 셸이 네 개 있고, **같은 명령이 어디서 도느냐에 따라 결과가
|
||||||
|
달라진다.** 그래서 모든 코드 블록 앞에 어디서 치는지를 붙인다.
|
||||||
|
|
||||||
|
| 표시 | 어느 기계 | 어떻게 들어가나 |
|
||||||
|
|---|---|---|
|
||||||
|
| `[워크스테이션]` | 평소 쓰는 개발 머신 | — |
|
||||||
|
| `[lab host]` | `test-server`. `virsh` 가 도는 곳 | `ssh test-server` |
|
||||||
|
| `[kc-lab-edge]` | 엣지 게스트 — nginx · certbot | `ssh kc-lab-edge` (**lab host 에서만**) |
|
||||||
|
| `[kc-lab-1]` | k3s server 게스트 | `ssh kc-lab-1` (**lab host 에서만**) |
|
||||||
|
| `[kc-lab-2]` | k3s agent 게스트 | `ssh kc-lab-2` (**lab host 에서만**) |
|
||||||
|
|
||||||
|
**기본은 `[lab host]` 다.** 게스트는 libvirt NAT(`192.168.122.0/24`) 안에
|
||||||
|
있어서 워크스테이션에서 직접 닿지 않는다. `ssh kc-lab-1` 이라는 별칭도
|
||||||
|
lab host 의 `~/.ssh/config` 에만 있다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
[워크스테이션] $ ping -c1 192.168.122.11
|
||||||
|
1 packets transmitted, 0 received, 100% packet loss # 경로가 없다
|
||||||
|
```
|
||||||
|
|
||||||
|
**게스트 안에 들어가서 다음 단계를 치지 않는다.** 게스트에는 lab host 의
|
||||||
|
개인키도 `~/.ssh/config` 도 없으므로, 게스트 안에서 `ssh kc-lab-1` 을 치면
|
||||||
|
이렇게 끝난다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
[kc-lab-1] $ ssh kc-lab-1 'sudo cat /var/lib/rancher/k3s/server/node-token'
|
||||||
|
Host key verification failed.
|
||||||
|
```
|
||||||
|
|
||||||
|
**이 실패가 조용한 이유** — 위 명령을 `TOKEN=$(...)` 로 감싸면 오류는
|
||||||
|
stderr 로 흘러가고 `TOKEN` 에는 **빈 문자열**이 담긴다. 셸은 아무 불평도
|
||||||
|
하지 않는다. 그래서 게스트에 로그인한 채 다음 단계를 치면 몇 단계 뒤에
|
||||||
|
가서야 증상이 나타난다.
|
||||||
|
|
||||||
|
그래서 이 가이드는 게스트에 **로그인하지 않고** lab host 에서
|
||||||
|
`ssh kc-lab-1 '...'` 형태로 원격 실행한다. 셸이 하나뿐이면 「지금 어디
|
||||||
|
있더라」가 생기지 않는다.
|
||||||
|
|
||||||
|
## 순서
|
||||||
|
|
||||||
|
앞 단계가 끝나야 다음이 된다. 각 단계 첫머리에 「이 단계가 끝나면」이 있고,
|
||||||
|
그 상태를 확인하는 명령이 있다. **그것이 통과해야 다음으로 넘어간다.**
|
||||||
|
|
||||||
|
| 단계 | 무엇을 세우나 | 끝나면 확인되는 것 |
|
||||||
|
|---|---|---|
|
||||||
|
| [00](00-lab-host/) | lab host 가상화 준비 | `virsh list` 가 돈다 |
|
||||||
|
| [01](01-vms/) | VM 세 대 (엣지 + k3s 2노드) | 세 게스트에 SSH 가 붙는다 |
|
||||||
|
| [02](02-k3s/) | k3s server + agent | `kubectl get nodes` 에 둘 다 Ready |
|
||||||
|
| [03](03-nginx/) | 엣지 nginx 라우팅 + 호스트 DNAT | 밖에서 요청이 파드까지 닿는다 |
|
||||||
|
| [04](04-tls/) | Let's Encrypt | `https://` 가 열리고 체인이 4단계 |
|
||||||
|
| [05](05-keycloak/) | Keycloak 2노드 + PostgreSQL | 관리 콘솔 로그인이 된다 |
|
||||||
|
| [06](06-observability/) | Prometheus · Grafana | `vendor_cluster_size` 가 2 |
|
||||||
|
| [experiments](experiments/) | 실험 26건 | 각 실험의 판정 기준 |
|
||||||
|
|
||||||
|
## 이 가이드가 검증된 방식
|
||||||
|
|
||||||
|
**읽기 전용 확인은 돌아가는 실험대에서 실제로 실행해 출력을 그대로 실었다.**
|
||||||
|
버전·IP·메모리 같은 값은 지어내지 않았다.
|
||||||
|
|
||||||
|
**만드는 명령은 다르다.** VM 을 다시 만들거나 k3s 를 다시 깔면 지금 돌고 있는
|
||||||
|
실험대가 없어지므로, 그 명령들은 **실제로 구축할 때 쓴 것을 그대로 옮겼고**
|
||||||
|
결과 상태를 확인하는 것으로 대신했다. 어느 쪽인지 각 단계에 표시한다.
|
||||||
|
|
||||||
|
## 막혔을 때
|
||||||
|
|
||||||
|
각 단계 끝에 **「막히면」** 표가 있다. 거기 적힌 증상은 전부 이 실험대가
|
||||||
|
실제로 겪은 것이고, 원문은 [`../evidence/`](../evidence/) 에 있다.
|
||||||
|
지어낸 실패 사례는 없다.
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
# 실험 재현 가이드 26편
|
||||||
|
|
||||||
|
각 편은 **직접 쳐서 재현**하는 문서다. 무엇을 발견했는지는
|
||||||
|
[`docs/experiment-*.md`](../../) 가 적고, 여기는 **그것을 다시 만들려면 무엇을
|
||||||
|
어떤 순서로 치는가**를 적는다.
|
||||||
|
|
||||||
|
## 전제
|
||||||
|
|
||||||
|
[기반 7단계](../)가 끝나 있어야 한다. 특히 [05](../05-keycloak/) 까지는
|
||||||
|
모든 실험의 공통 전제이고, 지표를 보는 실험은 [06](../06-observability/) 도
|
||||||
|
필요하다.
|
||||||
|
|
||||||
|
## 각 편의 구조
|
||||||
|
|
||||||
|
```
|
||||||
|
이 가이드가 끝나면 · 전제 · 주의 · 표시 규약
|
||||||
|
0 왜 이 실험인가
|
||||||
|
1 기준선 ← 주입 전에 평시를 잡는다
|
||||||
|
2 주입
|
||||||
|
3 주입 검증 ← 여기가 대부분의 편에서 가장 중요하다
|
||||||
|
4 관찰
|
||||||
|
5 복구
|
||||||
|
막히면 · 다음
|
||||||
|
```
|
||||||
|
|
||||||
|
**3번이 핵심인 편이 많다.** 이 실험대에서 주입은 아홉 번 조용히 실패했고,
|
||||||
|
실패한 주입은 「아무 일도 없었다」로 보여 「영향이 없다」와 구별되지 않는다.
|
||||||
|
그래서 결과를 읽기 전에 대상이 실제로 그 상태인지를 따로 확인한다.
|
||||||
|
|
||||||
|
## 표시 규약
|
||||||
|
|
||||||
|
| 표시 | 뜻 |
|
||||||
|
|---|---|
|
||||||
|
| **실측** | 증거 파일에 있는 출력 원문. 그대로 나온다 |
|
||||||
|
| **형태** | 모양만 같고 값은 환경마다 다르다 |
|
||||||
|
| **미검증** | 손으로 치기 좋게 고쳐 쓴 형태. 원래 실행에서 그대로 쓰이지는 않았다 |
|
||||||
|
|
||||||
|
## A층 — Keycloak 자체가 깨질 때
|
||||||
|
|
||||||
|
| | 가이드 | 무엇을 직접 보게 되나 |
|
||||||
|
|---|---|---|
|
||||||
|
| A-0 | [세션 공유 경로](a0-session-replication.md) | 세션을 나르는 것이 Infinispan 이 아니라 PostgreSQL 이라는 것 |
|
||||||
|
| A-1 | [7800 차단](a1-jgroups-transport-block.md) | 세션 공유는 안 깨지고 로그아웃 전파만 깨진다 |
|
||||||
|
| A-2 | [DB 정지](a2-database-loss.md) | 503 이 나는 동안에도 `up` 이 1 이다 |
|
||||||
|
| A-3 | [DB 크래시](a3-database-crash.md) | 200 을 받은 로그인 153건 중 4건이 DB 에 없다 |
|
||||||
|
| A-4 | [노드 상실](a4-node-loss.md) | 죽은 노드가 40초 동안 `Ready` 로 읽힌다 |
|
||||||
|
| A-5 | [비대칭 분단](a5-asymmetric-partition.md) | 한 방향만 막으면 열린 쪽으로 재연결한다 |
|
||||||
|
| A-6 | [지연 주입](a6-latency-injection.md) | 200밀리초가 두 단계를 지나 22.2초가 된다 |
|
||||||
|
| A-7 | [volatile 비교](a7-volatile-comparison.md) | 설정 하나로 A층 결론 셋이 뒤집힌다 |
|
||||||
|
| A-7a | [volatile 원인 확정](a7a-volatile-cause.md) | 같은 설정이 캐시 온도만으로 400·500·200 세 답을 낸다 |
|
||||||
|
| A-8 | [롤링 재시작](a8-rolling-restart.md) | 세션은 남고 캐시만 사라진다 |
|
||||||
|
|
||||||
|
## B층 — 애플리케이션 쪽 저장소
|
||||||
|
|
||||||
|
| | 가이드 | 무엇을 직접 보게 되나 |
|
||||||
|
|---|---|---|
|
||||||
|
| B-0 | [자동구성 확인](b0-bff-redis-deploy.md) | 아무것도 안 주면 Spring 이 무엇을 고르는가 |
|
||||||
|
| B-1 | [Redis 세션 저장소](b1-redis-session-store.md) | 세션은 옮겨지는데 토큰은 안 따라온다 |
|
||||||
|
| B-2 | [다중 인스턴스](b2-multi-instance-session.md) | 저장소를 옮겨도 안 고쳐지는 것 — 원인은 기본키다 |
|
||||||
|
| B-3 | [refresh 경쟁](b3-refresh-token-contention.md) | 이긴 요청의 토큰조차 못 쓴다 |
|
||||||
|
| B-4 | [Edge 인가 범위](b4-edge-authorization-scope.md) | 위조 헤더가 그대로 도착한다 |
|
||||||
|
| B-5 | [Redis 상실·영속화](b5-redis-loss-persistence.md) | 볼륨 없는 영속화 설정은 장식이다 |
|
||||||
|
| B-6 | [키 회전](b6-key-rotation.md) | JWKS 캐시에 유예 구간이 없다 |
|
||||||
|
| B-7 | [cookie secret 회전](b7-cookie-secret-rotation.md) | 겹침 구간을 만들 수 없고 서버 세션이 고아로 남는다 |
|
||||||
|
| B-7a | [고아 세션 정리](b7a-orphan-session.md) | TTL 로 생성 시각을 역산해 골라낸다 |
|
||||||
|
|
||||||
|
## C층 — SSO 와 로그아웃
|
||||||
|
|
||||||
|
| | 가이드 | 무엇을 직접 보게 되나 |
|
||||||
|
|---|---|---|
|
||||||
|
| C-1 | [다중 앱 SSO](c1-multi-app-sso.md) | SSO 는 되는데 로그아웃이 안 퍼진다 |
|
||||||
|
| C-2 | [백채널 로그아웃](c2-backchannel-logout.md) | 양쪽 다 없었다 — 한쪽만 고치면 여전히 안 된다 |
|
||||||
|
|
||||||
|
## D층 — 운영
|
||||||
|
|
||||||
|
| | 가이드 | 무엇을 직접 보게 되나 |
|
||||||
|
|---|---|---|
|
||||||
|
| D-1 | [백업·복구](d1-backup-restore.md) | 백업이 진짜 백업인지 스키마를 지워서 확인한다 |
|
||||||
|
| D-2 | [버전 업그레이드](d2-version-upgrade.md) | 롤백이 되는 조건은 스키마가 안 움직였을 때다 |
|
||||||
|
| D-3 | [비밀 관리](d3-secret-management.md) | base64 는 인코딩이지 암호화가 아니다 |
|
||||||
|
| D-4 | [인증서 갱신](d4-certificate-renewal.md) | 갱신은 성공했는데 38분 25초 동안 옛 인증서가 나갔다 |
|
||||||
|
| D-4a | [deploy 훅](d4a-deploy-hook.md) | 훅 하나로 그 공백이 1~2초가 된다 |
|
||||||
|
|
||||||
|
## 순서
|
||||||
|
|
||||||
|
A-0 을 먼저 한다. 나머지 A층 결론이 전부 거기서 확인한 「세션이 어디 있는가」
|
||||||
|
위에 서 있다.
|
||||||
|
|
||||||
|
```
|
||||||
|
A-0 ─┬─ A-1 ─┬─ A-5
|
||||||
|
│ └─ A-6
|
||||||
|
├─ A-2 ── A-3 ── D-1 ── D-2
|
||||||
|
├─ A-4
|
||||||
|
├─ A-8
|
||||||
|
└─ A-7 ── A-7a ← A층을 다 한 뒤 설정 하나만 바꿔 재실행한다
|
||||||
|
|
||||||
|
B-0 ── B-1 ─┬─ B-2 · B-3 · B-4 · B-5 · B-6
|
||||||
|
└─ B-7 ── B-7a
|
||||||
|
|
||||||
|
C-1 ── C-2 D-3 · D-4 ── D-4a (언제든 독립적으로)
|
||||||
|
```
|
||||||
|
|
||||||
|
**A-7 을 A층 마지막에 두는 이유** — 앞의 실험을 다 마친 뒤 설정 하나만 바꿔
|
||||||
|
재실행하면 **같은 주입에 대한 정반대 결과**를 한 벌로 얻는다.
|
||||||
|
|
||||||
|
## 안전
|
||||||
|
|
||||||
|
각 편의 2번(주입)부터 상태가 바뀐다. 모든 편이 **되돌리는 명령을 주입보다
|
||||||
|
먼저** 보여 주고, 5번에서 원상복구를 확인한다.
|
||||||
|
|
||||||
|
호스트(`test-server`)에서 하는 일은 sudo 비밀번호가 필요해 **사람이 직접
|
||||||
|
쳐야** 한다. D-1 과 D-4 가 여기 해당하며, 각 편이 어느 단계가 그런지 적는다.
|
||||||