Compare commits

..
Author SHA1 Message Date
DongHyeonka 9465582b5d chore: 실행 환경 구성 문서 추가 및 수정 2026-09-10 15:55:36 +09:00
DongHyeonkaandClaude Opus 5 6f6ab86345 docs(guides): reproduction guides for all 26 experiments
Written by subagents running under the writing-practitioner-guides skill,
one guide per experiment, 22,566 lines. Each walks a reader from baseline
capture through injection, injection verification, observation and recovery.

Section 3 carries the weight in most of them. Injection failed silently nine
times in this lab, and a failed injection looks exactly like no effect — so
the guides verify the target is actually in the intended state before
reading any result. A-4 makes virsh list the only proof because the node
reads Ready for 40 seconds after the machine is off; A-5 makes the packet
counter the sole go/no-go because a rule on the wrong node produces an empty
result that reads like a finding; A-6 quotes the run where 적용완료 was
printed between four Cannot find device "eth0" lines.

The traps the guides are built around are ones that invert a conclusion
rather than merely annoy:

  A-0   emptying the session table without a restart leaves cache entries
        that get counted as replication arriving
  A-2   dropping -o /dev/null fuses body and status into one string
  A-3   presence of "ready to accept connections" instead of its timestamp
  B-2   row count alone reads an UPDATE as nothing having happened
  B-4   tr ',' '\n' splits ["admin","editor"] so only admin is seen
  B-7   no login screen means the cookie died and SSO re-authenticated
  C-1   counting sessions without joining realm counts your own kcadm one
  D-1   kubectl exec without -i restores nothing and still exits 0
  D-4a  "ran with error output" is what success looks like

Every quoted block is copied from docs/evidence/ and marked 실측; reshaped
commands are marked 미검증 rather than passed off as measured. Where a source
document carries a ★ correction the guides follow the corrected claim — A-7's
REVOKED_TOKEN hypothesis, C-1's session count, B-2's schema attribution.

Two hazards are stated rather than smoothed over: B-6 deletes a key that
cannot be recreated, and D-1/D-4 need host sudo, which asks for a password,
so those steps say a person must type them.

Audit over all 26: 672 interpretation pairs, 486 evidence citations, 117
undo sections, and zero occurrences of the patterns the skill forbids —
no python data processing, no deprecated kubectl get endpoints, no
placeholders, no bare kcadm.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 18:29:00 +09:00
DongHyeonkaandClaude Opus 5 8062cc9a19 docs(guides): bring the seven setup guides in line with the practitioner skill
The guides were written before the skill existed and a scored audit found
three gaps. Fixed by a subagent running under the skill, with measured
values, versions, IPs and quoted output declared off limits.

The big one: every step showed a command and its output, and almost none
said which line to look at or what it meant. 64 interpretation pairs added
across the seven files, weighted where the reading is hardest — 20 in the
Keycloak stage, where a Secret existing and a pod having received it are
different facts.

Only the extracting form of curl appeared. Where the reader meets a response
for the first time the guides now open with curl -I or curl -v and name the
lines worth reading; -w '%{http_code}' survives only where the code is a
value being compared — two upstream nodes against each other, or the 900-run
control loop.

Listing Secret keys went from a three-stage pipe to kubectl describe secret,
which prints the key names and their byte counts in one native command
without exposing a value.

And a tool assumption: jq and yamllint are installed on neither the lab host
nor the guests. The guides now say so where JSON is read by eye, rather than
sending the reader to install something mid-diagnosis. cloud-init schema is
on the guests and is now the guest-side check.

Also removes a stray Playwright screenshot committed at the repository root
in 919547a; the evidence copy under docs/evidence/b7a-orphan-session/ is the
one the document references.

Four things the audit left standing are recorded in the agent's report rather
than papered over — notably that 04's reload measurements are stated without
a reproduction procedure, and that 05 and 06 reference each other as
prerequisites.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 17:33:31 +09:00
DongHyeonkaandClaude Opus 5 88b7bd4bf0 docs(guides): hands-on guides for the seven setup stages
The experiment documents record what was found. These record what to type to
reproduce it, in folders per stage.

Two kinds of command are kept apart. 하기/확인 is what somebody actually types
at a terminal — short, one at a time. 근거를 재려면 is the long measuring form
this lab used to put evidence in a document, marked as not needed day to day.
The same split applies to curl: -I to look once, -w '%{http_code}' only when
comparing across repetitions.

No placeholders. Where a value is needed the command that produces it is
given, and secrets are checked by length rather than printed:

  TOKEN=$(ssh kc-lab-1 'sudo cat /var/lib/rancher/k3s/server/node-token')
  echo "${#TOKEN} 자"

Stage 05 verifies resources in layers, because a Secret existing and a pod
having received it are different facts: keys, then length, then the value
inside the container, then which env var came from which Secret. Same for
workloads — Deployment to ReplicaSet to Pod, with the seven ReplicaSets this
cluster actually carries as the worked example.

Two commands were wrong and re-running them caught it. kubectl get endpoints
prints a deprecation warning on v1.33+, so the guide uses describe svc and
EndpointSlice. And the Keycloak image has no curl, so reading metrics from
inside the container fails with exit 127 — the guide asks Prometheus instead,
or runs a throwaway curl pod.

Read-only checks were executed against the running lab and their output is
quoted verbatim. Creating commands could not be re-run without destroying the
lab, so they are the ones used at build time; the README says which is which.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 16:48:10 +09:00
DongHyeonkaandClaude Opus 5 cdac9b8178 docs: give the twelve experiments that had no architecture diagram one
An audit against the standard the series set — concepts, procedure,
commands, architecture diagram, evidence table, terminal output — found the
three new experiments met it while twelve of the original ones had no
diagram at all: A-0, A-1, A-3, A-4, A-5, A-6, A-8, B-0, B-2, B-7, C-2, D-2.

Each now has one drawn from what that experiment actually found, not filler:
A-0 shows sharing going through PostgreSQL rather than between the caches;
A-3 the gap between the 200 and the WAL flush, with both failed injections;
A-5 the three silent injection failures; A-6 the two places latency is
multiplied; B-0 the repository keyed by principal with no session id; B-2
the primary key that causes the overwrite; D-2 why the rolling update
stopped the accident halfway.

Also corrected the index's stale claim of 11 experiments without a
screenshot — it is 14, and the reason is recorded: those experiments were
measured from terminals, the database and logs, and the observability stack
does not scrape Redis, the BFF or PostgreSQL, so there is no console to
photograph.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 21:44:36 +09:00
DongHyeonkaandClaude Opus 5 a2f749292a docs: add architecture diagrams for the three follow-up experiments
Each of A-7a, B-7a and D-4a had the concepts, procedure, commands and
terminal evidence but no diagram, which the rest of the series carries.

  a7a-cache-temperature.svg  the three cache states and the SQL each issues
  b7a-orphan-lifecycle.svg   ticket structure, accumulation across two
                             rotations, and why TTL is the only signal
  d4a-hook-timeline.svg      the 38m25s gap against the 1-2s one, on one axis

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 21:37:00 +09:00
DongHyeonkaandClaude Opus 5 e9baac21d5 docs(d4a): the deploy hook works — issuance to serving drops from 38m25s to 1-2s
D-4 named the fix and never ran it. Running it:

  /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh
    #!/bin/sh
    nginx -t && nginx -s reload

certbot reports "Hook 'deploy-hook' ran", the master stays at PID 585 and
the worker is replaced 28829 -> 37252, and the served certificate changes.

One trap worth naming: certbot prefixes the hook output with "ran with error
output" whenever the hook writes anything to stderr, and nginx's routine
types_hash warning goes to stderr. Everything inside is success — "test is
successful", "signal process started". A monitor that greps for "error"
would read a working hook as a failure. The worker PID is what to check.

Timing needed a clock correction. test-server has NTP off and runs 106
seconds fast; the dev machine matches Google and the Let's Encrypt ACME
endpoint exactly. Corrected, the hook's nginx -t lands at 12:27:50 UTC
against the new certificate's SCT at 12:27:49.054 — one second. The
correction validates itself: uncorrected, the hook would have run 104
seconds before the certificate existed.

That same skew is why D-4's gap was reported 106 seconds short; corrected to
2305s in the previous commit.

Remaining and left alone: whether certbot-renew.timer performs a real
renewal, which cannot be tested for about 89 days.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 21:35:00 +09:00
DongHyeonkaandClaude Opus 5 3d7778bd3c docs(d4): correct the gap to 38m25s — the two timestamps came from different clocks
The 2199 seconds reported for D-4 subtracted a test-server timestamp
(archive/cert2.pem mtime) from a dev-machine timestamp (the serial change
observed by the poll), without noting they are different clocks.

Checked against external references: the dev machine matches Google and the
Let's Encrypt ACME endpoint to the second, while test-server is 105 seconds
fast and reports NTPSynchronized=no. Three round-trip measurements put the
offset at +106.1s every time.

Corrected:

  new certificate written to disk  08:20:27 UTC   (mtime 17:22:13 KST - 106s)
  actually served                  08:58:52 UTC   (dev observation, no correction)
  gap                              2305s = 38m25s

The correction validates itself in D-4a, where the new certificate's SCT —
signed by CT logs on their own accurate clock at 12:27:49.054 GMT — lands
one second before the skew-corrected hook time. Without the correction the
hook would appear to have run 104 seconds before the certificate existed.

Updated across the experiment doc, the index, the follow-up doc, the verdict
evidence file and the SVG.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 21:33:01 +09:00
DongHyeonkaandClaude Opus 5 919547a025 docs(b7a): the orphan sessions can be deleted — oauth2-proxy just cannot do it
B-7 stopped at "could not delete the server-side session". The reason it
gave was right: the ticket carries the session id, the ticket is encrypted
with the cookie secret, so after a rotation the proxy cannot work out which
Redis key to remove. But that is a limitation of the proxy, not of Redis.

Measured across two rotations:

- The orphan does expire. TTL falls one second per second and is not
  refreshed by requests (the startup log says refresh:disabled, and
  --cookie-refresh is unset), so it dies exactly one hour after creation.
- An operator can delete it. `redis-cli del` returned 1, dbsize went 2 to 1,
  and the live session answered /oauth2/userinfo with 200 immediately after.
- But nothing in Redis says which key is the orphan. Same name prefix, same
  type, the same 3510 bytes, and the values are encrypted.
- TTL is the only signal, and because it is never refreshed it is an exact
  function of creation time. Anything created before the rotation is an
  orphan. Derived creation time 11:30:26 against the AuthSuccess log line at
  11:30:27 — one second out. The rule was then run and removed the orphan
  while leaving the live session.
- They accumulate: the session that survived the first rotation became the
  orphan of the second.

The caveat is recorded too: turning on --cookie-refresh breaks the
derivation, and at that point flushing and forcing everyone to
re-authenticate is the more honest option.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 20:37:11 +09:00
DongHyeonkaandClaude Opus 5 b9f4ef7bc2 docs(a7a): the volatile refresh failure is a client-scope lookup, not REVOKED_TOKEN
A-7 left the cause as a hypothesis and said to confirm it with the statement
logging A-3 used. Doing that shows the hypothesis was wrong.

Login issues no SQL at all in volatile mode. Refresh issues exactly one
statement, and it is not the one A-7 guessed:

  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-...', $2 = 'f'

REVOKED_TOKEN never appears. DEFAULT_SCOPE='f' means this is the optional
client scope list, which refresh needs because it recomputes which scopes go
into the new access token.

The larger finding is that A-7's table was conditional and did not say so.
The single statement is issued once and then cached, so the same
configuration produces three different outcomes depending only on cache
warmth — all three reproduced:

  fully cold      login 400  (select ce1_0.ID from CLIENT ...)
  CLIENT warm     login 200, refresh 500  <- what A-7 measured
  fully warm      login 200, refresh 200

So "volatile means you can log in without a database" is also conditional:
on a cold start the client lookup itself fails. In each case the Keycloak
log names the failing SQL directly, so this is determined rather than
inferred.

A-7 keeps its original wording with a correction banner above it, so what
was mis-guessed and why stays visible.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 20:27:23 +09:00
DongHyeonkaandClaude Opus 5 9dbee18a42 docs(b4): item 3 is no longer unmeasured — link it to the follow-up result
B-4 left role propagation open because oauth2-proxy was not deployed yet.
B-7 deployed it and the follow-up measured it: the value does not change
with request count, only when a new session is created.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 18:10:43 +09:00
DongHyeonkaandClaude Opus 5 905392947d docs: close the three untested items — all three are now measured
D-2 forward upgrade and B-4 role propagation were filled earlier. D-4 forced
renewal was the third, and it needed a person at the keyboard because the
host asks for a sudo password.

It turned out to hold the largest finding of the three: the renewal
succeeded and went unserved for 36 minutes 39 seconds, because nothing in
the setup reloads nginx — no ExecStartPost, three empty hook directories,
no nginx plugin. And the half of the plan's question that was still an
expectation is now a measurement: the reload is graceful, 8856 connections
with zero failures and an in-flight request surviving intact.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 18:10:04 +09:00
DongHyeonkaandClaude Opus 5 faf55dc1be docs(d4): the renewal succeeded and was not served for 36 minutes 39 seconds
The forced renewal did work. What did not work was anything making nginx
notice.

  disk     cert2.pem written 2026-09-04 17:22:13 KST
  network  old serial through 08:58:47 UTC, new serial from 08:58:52 UTC
  gap      2199 seconds, and the old certificate was observed 428 times in it

The gap closed only because a person ran `nginx -s reload`. Nothing else
would have: certbot-renew.service is just `ExecStart=/usr/bin/certbot -q
renew` with no ExecStartPost, all three of renewal-hooks/{deploy,post,pre}
are empty, and certbot 5.7.0 here has no nginx plugin (dns-cloudflare,
manual, null, standalone, webroot). Three paths, all empty. Any one of them
would have been enough.

nginx holds the certificate in memory from startup, and certbot swaps the
live/ symlink rather than the path — so the config still looks right while
the served certificate is stale. Master 585 and worker 586 sharing a start
time 22.4 hours old is what proved no reload had happened.

This is invisible for 88 days. The timer ran twice today and exited
0/SUCCESS both times, because a renewal is not due yet. The day it becomes
due, the symptom is an expired certificate and the log still says SUCCESS.

The other half of the plan's question now has a measurement rather than an
expectation. The reload is genuinely graceful: 8856 polled connections, zero
non-200, p95 205.7ms before against 204.3ms after. And an in-flight request
— 845KB pulled at 20k/s, so still transferring 12 seconds in when the reload
landed — completed with all 845361 bytes over a single connection.

The earlier note that nginx reload "is graceful by design but must not be
written that way without checking" was right to hold back. The neighbouring
belief, that renewing gets the new certificate served, was the one that was
false, and there was no way to tell in advance which it would be.

Adds diagrams/d4-renewal-gap.svg and evidence 09-13.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 18:04:44 +09:00
DongHyeonkaandClaude Opus 5 0d84921a56 docs(d4): the renewal unit has no reload, and nginx has not reloaded in 22 hours
The forced renewal did not change the certificate being served — 161 serial
samples over 13 minutes, all identical, notBefore still Sep 3.

Two things were readable without sudo and both matter.

nginx has never been reloaded: master 585 and worker 586 share a start time
and both are 80529 seconds old. A reload keeps the master and replaces the
workers, so the original fork still being there means no reload has happened
since Sep 3 19:00. nginx reads the certificate at startup and holds it in
memory; without a reload it serves the old one no matter what is on disk.

And the unit that is supposed to renew has nothing that would reload it:

  [Service]
  Type=oneshot
  ExecStart=/usr/bin/certbot -q renew
  PrivateTmp=true

No ExecStartPost, no --deploy-hook. The timer runs twice a day and exited
0/SUCCESS both times today, which is precisely why this stays invisible —
the renewal is not due for 88 days, so nothing has failed yet.

What remains undetermined is whether the user's --force-renewal failed, or
succeeded and left new files that nginx never picked up. Telling those apart
needs /etc/letsencrypt, which is root-only. If it is the latter, that is the
most valuable thing this lab has produced: "renewal succeeded" and "the new
certificate is being served" are different events.

CT logs looked like a way around the permission problem and were not: the
served certificate carries two valid SCTs, yet crt.sh returns zero rows for
auth.hyeonworks.com. Same shape as A-2's `up` metric — the observing tool
sees a subset of the truth.

Also recorded: 76 in-flight failures at 08:15:04 are a local artifact, not a
server event. The concurrent 0.2s poll was clean across the same second,
num_connects was 0, and time_total was 50 microseconds. Not reproducible in
100 retries. The monitor now records curl's exit code so a recurrence is
diagnosable.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 17:28:01 +09:00
DongHyeonkaandClaude Opus 5 716e62524a docs(d4): measure the controls before the injection that needs a password
The forced renewal needs sudo on the host, and the host asks for a password.
That blocks the injection, not the experiment — the part worth doing first
was the control anyway.

Control 1 — new connections, 0.2s x 900 over 180s: 900/900 = 200, zero
failures. The noise floor is 0, so a single non-200 during the renewal can
be attributed to the renewal. Without this the observation would have been
uninterpretable, which is the mistake A-6 made calling a -41% control
"no effect" and A-8 made claiming zero-downtime from 9 samples.

Control 2 — the poll cannot answer the question the plan actually asked.
TLS handshakes were 900/900, meaning every request is a fresh connection,
so it measures "are new connections accepted", not "what happens to a
request already in flight". A separate device: the 845KB admin console
bundle pulled at --limit-rate 20k, holding one request open for 42 seconds.
Baseline 200 / 845361 bytes / 1 connection.

Monitors are running under setsid, and stop three minutes after the
certificate serial changes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 17:12:31 +09:00
DongHyeonkaandClaude Opus 5 74c9b3cea7 docs: replace prose placeholders in reproduction steps with executable commands
The audit found ~80 placeholders, and the damaging ones were where the
measuring apparatus itself was prose rather than a command:

  a6  "( curl ... ) & 를 20개 띄우고 wait"  — the 22.2s headline came from this
  a3  "<로그인 반복, sid 를 /tmp/sids 에>"  — the whole RPO measurement
  a3  "<sid 목록>"                          — the control it is compared against
  a5  "<수신 파드IP>"                       — the injection
  a8  writes /tmp/tok, reads /tmp/rt        — self-inconsistent, sent an empty token
  b3  $KC / $RT / $NEW never assigned
  c2  bare kcadm.sh with no kubectl exec
  a1  conntrack tuples written by hand, though the direction flips per restart

Each is now a shell-expandable form: pod IPs from jsonpath, the admin password
from the secret, ids from kcadm --format csv, conntrack tuples derived from
"conntrack -L" with awk rather than transcribed.

Then the rewritten commands were executed against the live cluster, and one
of them failed — the 20-way load generator, written as "kubectl run --rm -i",
lost its output stream twice in a row. That is a trap this series already hit
once, and the rewrite reintroduced it. A-6 now uses a resident probe pod that
collects into a file and is cat-ed once; verified 20/20 lines.

Evidence: docs/evidence/followup/05-command-reproducibility.txt

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 17:01:54 +09:00
DongHyeonkaandClaude Opus 5 f3f3a8da46 docs: re-measure B-4 role propagation and record it as evidence
The twelve-request result and the post-reauth value were printed in the document but never written to a file; the audit flagged it as a conclusion standing without evidence. Measured again with timestamps into 03-b4-role-propagation.txt, which also surfaced a 107-second clock skew between the browser host and the lab host that the file now records so the two timelines can be compared.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 16:54:05 +09:00
128 changed files with 30693 additions and 177 deletions
@@ -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
[ 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
[ 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 isnt 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 -6
View File
@@ -18,15 +18,23 @@
브라우저 / SSH (tailnet)
│ https://{auth,app1,app2}.hyeonworks.com → 100.83.212.4
lab host ── nginx :443 TLS 종료 · X-Forwarded-* 주입
nginx :80 301 → https
lab host ── nftables DNAT :80,:443 → 192.168.122.10
(물리 호스트가 실험대를 위해 하는 일의 전부)
│ virbr0 192.168.122.0/24 (libvirt NAT)
├──▶ kc-lab-1 .11 k3s server Traefik :80
└──▶ kc-lab-2 .12 k3s agent Traefik :80
└──▶ Pod
├──▶ kc-lab-edge .10 nginx :443 TLS 종료 · X-Forwarded-* 주입
│ │ nginx :80 301 → https
│ │ 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홉**이 운영 구조와 같다는 점이 이 배치의 핵심이다.
L7 프록시가 두 겹인 이유는 역할이 다르기 때문이다 — nginx는 바깥세상과의
접점(TLS·인증서·헤더)을, Traefik은 클러스터 내부의 동적 라우팅을 맡는다.
@@ -36,7 +44,11 @@ L7 프록시가 두 겹인 이유는 역할이 다르기 때문이다 — nginx
| 경로 | 역할 |
|---|---|
| `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홉 헤더 계약 측정용 워크로드 |
| `scripts/rebuild-seed.sh` | cloud-init 시드 ISO 재생성 + 풀 업로드 |
| `scripts/build-and-import.sh` | 이미지 빌드 → 각 노드 containerd 반입 |
+12 -1
View File
@@ -17,7 +17,12 @@ users:
shell: /bin/bash
# NOPASSWD is required: the k3s installer and the fault-injection scripts
# 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
# that cannot be logged into at all, so its own failure log is unreadable.
# ssh_pwauth stays false, so this never widens SSH exposure.
@@ -35,3 +40,9 @@ package_update: true
packages:
- curl
- 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
+33
View File
@@ -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
}
}
+13
View File
@@ -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 {
listen 443 ssl default_server;
http2 on;
# The http2 parameter of listen, not the separate `http2 on;` directive:
# 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 _;
# fullchain.pem, never cert.pem: omitting the intermediates passes on
+12
View File
@@ -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
+68
View File
@@ -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 는 남아 있다."
+48
View File
@@ -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

+47
View File
@@ -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

+55
View File
@@ -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

+52
View File
@@ -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

+49
View File
@@ -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

+51
View File
@@ -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

+70
View File
@@ -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

+47
View File
@@ -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

+45
View File
@@ -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

+52
View File
@@ -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

+74
View File
@@ -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-&lt;불투명한 32자 hex&gt; (동일 형식)</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

+44
View File
@@ -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

+48
View File
@@ -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

+94
View File
@@ -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

+57
View File
@@ -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초 오차**. 실제로 골라 지웠고 산 세션만 남았다.
Binary file not shown.

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:0008: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 를 찍는다.
+19 -5
View File
@@ -1,14 +1,28 @@
# D-4 — 인증서 갱신 증거
2026-09-04 17:2517:35 KST
2026-09-04 17:25 18:02 KST
해설: [`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` 로 강제 갱신 불가** |
| `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
+17
View File
@@ -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 을 바꾼다 ===
변경 시각: 15:27:52
IdP 의 값: [ {
IdP 의 값: "email" : "changed-labuser@example.com"
IdP 의 값: } ]
# B-4 ③ — IdP 의 클레임 변경은 언제 upstream 에 반영되는가
#
# 이 파일은 재수집본이다. 첫 수집본은 "이제 요청을 반복해 본다" 에서 끝났고,
# 그 뒤의 12회 결과는 브라우저 콘솔에서만 보고 파일로 남기지 않았다.
# 서브에이전트 감사가 "증거 없이 서 있는 결론" 으로 지적했고, 다시 측정했다.
#
# 수집 방법:
# - 요청부: Playwright 로 연 브라우저에서 fetch('/api/echo') 를 반복.
# oauth2-proxy 세션 쿠키가 HttpOnly 라 curl 로는 재현할 수 없어 브라우저를 썼다.
# 아래 출력은 브라우저 콘솔이 돌려준 문자열 그대로다.
# - 변경부·저장소 확인부: test-server 의 kubectl 출력 그대로.
=== IdP 쪽 세션과 oauth2-proxy 세션 ===
Redis 세션: 1 개
(세션은 로그인 시점의 클레임을 담고 있다 — 이제 요청을 반복해 본다)
=== [1] 기준선 — 변경 전 (브라우저 fetch) ===
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 이 그 차이를 보여준다 — 문법적으로는 멀쩡했고, 실행하면 조용히 실패했다.
+3 -1
View File
@@ -9,9 +9,11 @@
| `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초 동안 옛 값**. 세션 삭제 후 재인증에서야 새 값 |
| `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 의 결론을 정밀화한다.
2. **role 변경은 요청 횟수와 무관하게 반영되지 않는다.** `--cookie-refresh` 가 없으면 쿠키 만료나 재인증까지 옛 값이 간다.
3. **B층 실험에 Grafana 증거가 없는 이유가 확인됐다** — 관측 대상에 애초에 없다. 스크린샷이 없는 것이 아니라 지표가 없다.
3. **바꾼 명령은 돌려봐야 안다.** 문법이 멀쩡해도 일회성 파드는 동시 출력에서 조용히 샜다 — 05 참조.
4. **B층 실험에 Grafana 증거가 없는 이유가 확인됐다** — 관측 대상에 애초에 없다. 스크린샷이 없는 것이 아니라 지표가 없다.
+11 -2
View File
@@ -16,6 +16,15 @@
---
## 구조
![A-0 — 세션 공유는 PostgreSQL 을 거친다](diagrams/a0-session-replication.svg)
> 다이어그램 규약은 [`diagrams/_style.md`](diagrams/_style.md).
> 실험대 전체 구조는 [`diagrams/lab-topology.svg`](diagrams/lab-topology.svg).
---
## 0. 결론부터
| 물음 | 답 |
@@ -471,7 +480,7 @@ kubectl -n keycloak-lab exec keycloak-0 -- \
```sql
select offline_flag, count(*) from offline_user_session group by offline_flag;
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 어디 있냐"를
@@ -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"
# 노드별 캐시 엔트리 (파드 안에서)
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"'
```
+22 -3
View File
@@ -9,6 +9,15 @@
---
## 구조
![A-1 — 7800 차단과 그때 깨지는 것](diagrams/a1-transport-block.svg)
> 다이어그램 규약은 [`diagrams/_style.md`](diagrams/_style.md).
> 실험대 전체 구조는 [`diagrams/lab-topology.svg`](diagrams/lab-topology.svg).
---
## 0. 결론부터
| 예측 | 결과 |
@@ -446,7 +455,11 @@ kubectl -n keycloak-lab delete networkpolicy a1-block-jgroups-transport
```bash
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 와 포트 규약
@@ -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'
# 4. conntrack 삭제 (양쪽 노드, 양쪽 방향). 반영까지 약 3분
ssh kc-lab-1 'sudo conntrack -D -p tcp -s <k1ip> -d <k0ip> --sport <sp> --dport 7800'
ssh kc-lab-2 'sudo conntrack -D -p tcp -s <k0ip> -d <k1ip> --sport 7800 --dport <sp>'
# 노드마다 자기 conntrack 표에서 7800 튜플을 뽑아 그대로 지운다.
# 방향(누가 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. 분단 확인
curl -s "http://localhost:19090/api/v1/query?query=vendor_cluster_size"
+3 -1
View File
@@ -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 \
'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.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 정지
kubectl -n keycloak-lab scale deployment/postgres --replicas=0
+32 -5
View File
@@ -9,6 +9,15 @@
---
## 구조
![A-3 — 200 과 디스크 사이의 빈 구간](diagrams/a3-database-crash.svg)
> 다이어그램 규약은 [`diagrams/_style.md`](diagrams/_style.md).
> 실험대 전체 구조는 [`diagrams/lab-topology.svg`](diagrams/lab-topology.svg).
---
## 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 \
-c "delete from offline_user_session"
# 2. 로그인 루프 (호스트에서 백그라운드 exec — 파드 안 & 는 exec 종료와 함께 죽는다)
kubectl -n keycloak-lab exec a2-probe -- sh -c '<로그인 반복, sid 를 /tmp/sids 에>' &
# 2. 로그인 루프 호스트에서 백그라운드로 exec 한다.
# 파드 안에서 ( ... ) & 로 띄우면 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
kubectl -n keycloak-lab exec deploy/postgres -- \
@@ -340,9 +362,14 @@ kubectl -n keycloak-lab exec deploy/postgres -- \
# 4. 주입이 걸렸는지 확인 — 이게 없으면 결과를 해석하지 않는다
kubectl -n keycloak-lab logs deploy/postgres | grep -E "not properly shut down|redo"
# 5. 대조
kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak -tAc \
"select count(*) from offline_user_session where user_session_id in (<sid 목록>)"
# 5. 대조 — 클라이언트가 200 을 받은 sid 를 그대로 IN 절로 만든다
kill $LOOP 2>/dev/null
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))"
```
---
+10 -1
View File
@@ -17,6 +17,15 @@
---
## 구조
![A-4 — 두 가지 노드 상실과 서로 다른 이유](diagrams/a4-node-loss.svg)
> 다이어그램 규약은 [`diagrams/_style.md`](diagrams/_style.md).
> 실험대 전체 구조는 [`diagrams/lab-topology.svg`](diagrams/lab-topology.svg).
---
## 0. 결론부터
| | 4a 워커 상실 | 4b 컨트롤 플레인 상실 |
@@ -404,7 +413,7 @@ kubectl get node kc-lab-2 # 40초 뒤 NotReady
kubectl -n keycloak-lab get pods -o wide # Running 인 채로 얼어 있다
kubectl get node kc-lab-2 -o jsonpath='{.spec.taints}'
# 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 이 죽으므로 노드에서 직접 본다
virsh destroy kc-lab-1
+12 -3
View File
@@ -12,6 +12,15 @@
---
## 구조
![A-5 — 비대칭·양방향 차단과 세 번의 주입 실패](diagrams/a5-asymmetric-partition.svg)
> 다이어그램 규약은 [`diagrams/_style.md`](diagrams/_style.md).
> 실험대 전체 구조는 [`diagrams/lab-topology.svg`](diagrams/lab-topology.svg).
---
## 0. 결론부터
| 물음 | 답 |
@@ -318,14 +327,14 @@ JGroups 코디네이터는 **가장 오래된 멤버**다. 분단이 나면
ssh kc-lab-1 'sudo conntrack -L | grep 7800'
# 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 <수신 파드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 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 57800 -j DROP'
# 3. 걸렸는지 카운터로 확인 — 0 이면 해석 금지
ssh kc-lab-1 'sudo iptables -t raw -L PREROUTING -n -v'
# 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. 분단 확인
kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
+52 -7
View File
@@ -9,6 +9,15 @@
---
## 구조
![A-6 — 지연이 곱해지는 두 단계](diagrams/a6-latency-injection.svg)
> 다이어그램 규약은 [`diagrams/_style.md`](diagrams/_style.md).
> 실험대 전체 구조는 [`diagrams/lab-topology.svg`](diagrams/lab-topology.svg).
---
## 0. 결론부터
| 측정 | 값 |
@@ -337,15 +346,51 @@ ssh kc-lab-2 '
# 3. 걸렸는지 카운터로 확인 — Sent 가 0 이면 해석 금지
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 \
--command -- curl -s -o /dev/null -w '%{time_total}\n' -X POST http://<pod>:8080/realms/master/protocol/openid-connect/token ...
# 변수 준비 — 아래 명령들이 이 값을 쓴다
K0=$(kubectl -n keycloak-lab get pod keycloak-0 -o jsonpath='{.status.podIP}')
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. 동시 부하로 풀 고갈 재현
# ( curl ... ) & 를 20개 띄우고 wait
# 4. 상주 탐침을 먼저 띄운다.
# ★ 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. 풀 지표
curl -s http://<pod>:9000/metrics | grep -E '^agroal_(blocking_time|max_used|awaiting)'
# 5. 단일 요청 지연 — 대조군(keycloak-0, DB 와 같은 노드) 대 시험군(keycloak-1)
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. 해제
ssh kc-lab-2 'sudo tc qdisc del dev flannel.1 root'
+13 -1
View File
@@ -29,6 +29,11 @@
**세 개가 정반대로 뒤집혔다.** 예측한 그대로다.
> **★ 이 표에는 조건이 빠져 있다 (A-7a).** volatile 행의 A-2 결과는
> **캐시 온도에 따라 달라진다.** 완전 냉시동이면 로그인도 `400` 이고,
> 캐시가 완전히 더우면 refresh 도 `200` 이다. 여기 적힌 값은 그 사이의
> 한 상태다 — 셋 다 [A-7a](experiment-a7a-volatile-cause.md) 에서 재현했다.
> **"세션 공유는 7800 을 안 탄다"는 A-1 의 결론은 버전에 달린 사실이다.**
> 인터넷 자료 대부분이 24 이전 기준이므로 **거기서는 통념이 맞다.**
> 틀린 것은 자료가 아니라 **버전을 확인하지 않고 적용하는 것**이다.
@@ -164,6 +169,13 @@ partitioned roll out complete: 2 new pods have been updated...
### refresh 가 500 인 이유 — 가설
> **★ 정정 (A-7a)** — 이 가설은 **틀렸다.** 문장 로깅으로 확정한 결과
> 원인은 `REVOKED_TOKEN` 이 아니라 **`CLIENT_SCOPE_CLIENT` 조회**였다.
> 더 중요하게는 **위 표 자체가 캐시 온도에 따라 400/500/200 으로 갈린다.**
> [A-7a 문서](experiment-a7a-volatile-cause.md) 참조.
아래는 정정 전 원문이다 — 무엇을 어떻게 잘못 짚었는지 남긴다.
**측정은 확실하지만 원인은 확정하지 못했다.** 유력한 후보는
`REVOKED_TOKEN` 테이블이다 — refresh token 회전에서 **이미 쓴 토큰인지**
확인하려면 그 테이블을 봐야 하고, 그 경로는 캐시되지 않는다.
@@ -174,7 +186,7 @@ kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak -c
```
**확정하려면 A-3 에서 쓴 문장 로깅을 켜고 다시 재현해야 한다.** 여기서는
**가설로 남긴다.**
**가설로 남긴다.****A-7a 에서 그대로 실행해 확정했고, 가설은 틀렸다.**
> **volatile 이 "DB 없이 돌아간다"는 뜻은 아니다.**
> realm·사용자·클라이언트·취소 토큰은 **여전히 DB 에 있다.**
+292
View File
@@ -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:1820:24 KST
A-7 이 **가설로 남긴 것**을 확정하는 실험이다. 확정해 보니 가설이 틀렸고,
더 중요하게는 **A-7 의 표 자체가 조건부였다**는 것이 드러났다.
---
## 구조
![A-7a — 캐시 온도가 결과를 가른다](diagrams/a7a-cache-temperature.svg)
> 다이어그램 규약은 [`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).
+28 -5
View File
@@ -9,6 +9,15 @@
---
## 구조
![A-8 — 사라지는 캐시와 남는 세션](diagrams/a8-rolling-restart.svg)
> 다이어그램 규약은 [`diagrams/_style.md`](diagrams/_style.md).
> 실험대 전체 구조는 [`diagrams/lab-topology.svg`](diagrams/lab-topology.svg).
---
## 0. 결론부터
| 확인 | 결과 |
@@ -192,10 +201,23 @@ kubectl -n keycloak-lab run a8-probe --image=curlimages/curl:8.11.1 \
--restart=Never --command -- sleep 3600
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 \
'curl -s -X POST http://<pod>:8080/realms/master/protocol/openid-connect/token \
-d grant_type=password -d client_id=admin-cli -d username=admin -d password=<pw> > /tmp/tok'
"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
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
@@ -203,12 +225,13 @@ kubectl -n keycloak-lab rollout status statefulset/keycloak --timeout=420s
# 세션 생존 확인
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)'
# DB 대조
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)'"
```
---
+13 -4
View File
@@ -11,6 +11,15 @@
---
## 구조
![B-0 — 자동구성이 고른 것과 그 조회 키](diagrams/b0-autoconfiguration.svg)
> 다이어그램 규약은 [`diagrams/_style.md`](diagrams/_style.md).
> 실험대 전체 구조는 [`diagrams/lab-topology.svg`](diagrams/lab-topology.svg).
---
## 0. 결론부터
```
@@ -105,7 +114,7 @@ Bad Gateway
파드 안에서 직접 받아 해결했다. **alpine 기반 JRE 이미지에 `wget` 이 있다.**
```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
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 \
-s realm=keycloak-patterns -s enabled=true -s accessTokenLifespan=60
@@ -278,7 +287,7 @@ replica 를 늘리는 순간 로그인 자체가 실패한다.
**무엇을 골랐는지는 실행 중인 인스턴스를 봐야 안다.**
```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
# 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
list(d['contexts'].values())[0]['beans'].items() if 'AuthorizedClient' in i['type']]"
```
+4 -4
View File
@@ -122,7 +122,7 @@ value: http://echo.header-lab.svc:8081
## 2. 자동구성이 실제로 바뀌었는가 — B-0 의 방법을 다시 쓴다
```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
# 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 인가
# authorizedClientService 는 여전히 InMemory 인가 ← 이쪽이 핵심
# 5. Redis 를 직접 연다
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 ttl "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:$(kubectl -n keycloak-lab exec deploy/redis -- redis-cli --scan --pattern 'bff:session:sessions:*' | grep -v expires | head -1 | sed 's/.*://')"
# 6. 사용자 관점 확인
# 브라우저로 https://app1.hyeonworks.com/bff/token-boundary
+10 -1
View File
@@ -10,6 +10,15 @@
---
## 구조
![B-2 — 저장소 분리 후 남은 두 문제](diagrams/b2-store-split-consequences.svg)
> 다이어그램 규약은 [`diagrams/_style.md`](diagrams/_style.md).
> 실험대 전체 구조는 [`diagrams/lab-topology.svg`](diagrams/lab-topology.svg).
---
## 0. 결론부터
B-1 이 남긴 문제(세션만 공유되고 토큰은 안 됨)를 **JDBC 로 옮겨 해결했다.**
@@ -345,7 +354,7 @@ Spring Security 는 DDL 을 두 벌 제공한다.
# + spring-boot-starter-jdbc, 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
# 실제로는 nested jar 를 풀어서 -postgres.sql 을 꺼낸다
kubectl -n keycloak-lab exec -i deploy/postgres -- psql -U keycloak -d keycloak < oauth2-pg.sql
+23 -8
View File
@@ -124,7 +124,7 @@ DB 로 확인했다.
select us.user_session_id,
(select count(*) from offline_client_session cs
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 \
update realms/keycloak-patterns -s revokeRefreshToken=true -s refreshTokenMaxReuse=0
# 2. refresh token 하나 확보 (direct grant)
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
# 2. refresh token 하나 확보 (direct grant). 이후 단계가 전부 이 변수들을 쓴다.
KC=http://keycloak.keycloak-lab.svc:8080/realms/keycloak-patterns/protocol/openid-connect/token
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 이 없으면 재현되지 않는다
i=1; while [ $i -le 5 ]; do ( curl ... -d refresh_token=$RT > /tmp/c$i ) & i=$((i+1)); done; wait
# 3. 동시에 5개 — & 와 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. ★ 이긴 요청의 토큰을 다시 써본다 — 여기서 진짜 답이 나온다
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 이 지워졌는지
kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak -c \
"select us.user_session_id,
(select count(*) from offline_client_session cs
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 반복
```
@@ -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 로 옮기는 작업 |
| edge 에서 동명 헤더 덮어쓰기 | **nginx 설정 변경 필요**`proxy_set_header X-Auth-Request-Roles ""` 로 먼저 지우고 다시 설정 |
**③ 은 oauth2-proxy 배포가 선행이, 그것 B-7 의 주제와 겹다.**
**③ 은 oauth2-proxy 배포가 선행이었고, 그것 B-7 의 주제와 겹다.**
B-7 에서 oauth2-proxy 를 올린 뒤 후속 작업으로 측정했다 — 결론은
**"요청 횟수와 무관하다. 세션이 새로 만들어져야 한다"** 이다.
세션은 로그인 시점의 스냅샷이고, `--cookie-refresh` 가 없으면 갱신되지 않는다.
---
+2 -2
View File
@@ -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
# ② 왜 파드가 안 빠지는가 — 그룹별로 본다
kubectl -n keycloak-lab exec <bff-pod> -- 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
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}'
# ③ 복구
+1 -1
View File
@@ -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
# 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
```
---
+17 -1
View File
@@ -12,6 +12,15 @@
---
## 구조
![B-7 — 쿠키 세션의 성질과 회전의 대가](diagrams/b7-cookie-secret.svg)
> 다이어그램 규약은 [`diagrams/_style.md`](diagrams/_style.md).
> 실험대 전체 구조는 [`diagrams/lab-topology.svg`](diagrams/lab-topology.svg).
---
## 0. 결론부터
| 물음 | 답 |
@@ -172,7 +181,7 @@ kubectl -n keycloak-lab patch deployment oauth2-proxy --type=json \
|---|---|
| 옛 쿠키 | **검증 실패**`session ticket cookie failed validation` |
| 사용자 경험 | **Keycloak SSO 가 살아 있어 조용히 재로그인**됐다. 로그인 화면을 안 봤다 |
| **서버 쪽 세션** | **★ 지우지 못했다** |
| **서버 쪽 세션** | **★ 지우지 못했다****B-7a 에서 이어받았다** |
### 고아 세션이 남는다
@@ -196,6 +205,13 @@ _oauth2_proxy-b26111fbd1fdab3ae2182e287001b02a ← ★ 옛 세션. 남아 있
**로그인한 사용자 수만큼 고아가 생긴다.** 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 에 대한 답
+301
View File
@@ -0,0 +1,301 @@
# B-7a — 고아 세션은 지울 수 있는가
브랜치 `feature/keycloak-b7a-orphan-session` ·
증거 [`docs/evidence/b7a-orphan-session/`](evidence/b7a-orphan-session/) ·
2026-09-04 20:2920:34 KST
B-7 이 **「★ 지우지 못했다」** 로 남긴 자리를 잰다.
결론부터 — **oauth2-proxy 가 못 지우는 것이지, 지울 수 없는 것이 아니다.**
---
## 구조
![B-7a — 고아 세션의 발생·누적·정리](diagrams/b7a-orphan-lifecycle.svg)
> 다이어그램 규약은 [`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. 산 세션은 다치지 않는다.**
![고아 삭제 후 살아있는 세션](evidence/b7a-orphan-session/b7a-live-session-after-orphan-delete.png)
> **"지울 수 없다"는 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).
+2 -2
View File
@@ -132,7 +132,7 @@ https://auth.hyeonworks.com/realms/keycloak-patterns/protocol/openid-connect/aut
```bash
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 세션만 죽인다
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. 두 앱을 다시 연다 — 그대로 열리면 앱 세션이 독립적이라는 뜻
```
+15 -6
View File
@@ -8,6 +8,15 @@
---
## 구조
![C-2 — 백채널 로그아웃에 필요한 양쪽](diagrams/c2-backchannel-logout.svg)
> 다이어그램 규약은 [`diagrams/_style.md`](diagrams/_style.md).
> 실험대 전체 구조는 [`diagrams/lab-topology.svg`](diagrams/lab-topology.svg).
---
## 0. 결론부터
| 확인 | 결과 |
@@ -27,7 +36,7 @@
### IdP 쪽
```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 쪽만 설정하고 시험했다
```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",
"backchannel.logout.session.required":"true"}'
```
@@ -94,7 +103,7 @@ kcadm.sh update clients/<id> -r keycloak-patterns \
Redis: 1 키
=== 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 쪽은 끊겼다
@@ -227,12 +236,12 @@ app2(oauth2-proxy)는 못 한다. **한 SSO 안에서 로그아웃 전파가 앱
```bash
# 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/
curl -s -o /dev/null -w '%{http_code}\n' -X POST https://app1.hyeonworks.com/logout/connect/back-channel/keycloak
# 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"}'
# 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'"
# 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
# 5. 도달성 확인 — 클러스터 안에서 앱 공개 URL 로
+9
View File
@@ -23,6 +23,15 @@
---
## 구조
![D-2 — 방향에 따라 갈리는 결과](diagrams/d2-version-upgrade.svg)
> 다이어그램 규약은 [`diagrams/_style.md`](diagrams/_style.md).
> 실험대 전체 구조는 [`diagrams/lab-topology.svg`](diagrams/lab-topology.svg).
---
## 0. 결론부터
| 확인 | 결과 |
+2 -2
View File
@@ -114,7 +114,7 @@ ssh kc-lab-1 'sudo grep -c "lab-postgres-change-me" /var/lib/rancher/k3s/server/
## 3. 파드 안에서는 환경변수다
```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'
# 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. 누가 읽을 수 있는가
kubectl auth can-i get secrets -n keycloak-lab \
+246 -53
View File
@@ -10,6 +10,10 @@
![D-4 구조 — 체인과 SAN 제약](diagrams/d4-cert-chain.svg)
**핵심 발견** — 갱신은 성공했는데 **38분 25초** 동안 반영되지 않았다.
![D-4 — 갱신과 서빙 사이의 공백](diagrams/d4-renewal-gap.svg)
> 다이어그램 규약은 [`diagrams/_style.md`](diagrams/_style.md).
> 실험대 전체 구조는 [`diagrams/lab-topology.svg`](diagrams/lab-topology.svg).
@@ -21,12 +25,19 @@
|---|---|
| 인증서 구성 | **SAN 3개** (`auth`/`app1`/`app2`), 와일드카드 아님 |
| 체인 완결성 | **정상.** `Verify return code: 0 (ok)`, 4단계 |
| 갱신 자동화 | **동작.** `certbot-renew.timer` enabled·active, 11시간 전 실행됨 |
| 남은 기간 | **88일** (갱신까지 약 58일) |
| **강제 갱신 실측** | **★ 못 했다.** `sudo: a password is required` |
| 갱신 자동화 | 타이머는 **동작한다.** 오늘 두 번 `status=0/SUCCESS` |
| **★ 갱신 반영** | **안 된다.** 새 인증서가 디스크에 있는 채로 **38분 25초** 동안 옛 인증서를 서빙했다 |
| **★ 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
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
sudo: a password is required
$ sudo -n systemctl reload nginx
sudo: a password is required
08:58:40 요청 시작 (845KB @ 20k/s)
08:58:52 ← nginx -s reload. 요청 시작 12초 뒤, 전송 한가운데
08:59:21 종료: 코드=200 바이트=845361(전량) 연결수=1 curl종료=0
```
**test-server 의 sudo 는 비밀번호를 요구한다.** 게스트(kc-lab-1/2)는 무암호라
A층에서 `conntrack`·`tc` 를 자유롭게 썼는데, **호스트는 다르다.**
> **이 사실은 B-7 에서 처음 드러났다** — nginx 설정을 읽으려던 시도가 계속
> 빈 결과였고, 그게 **sudo 의 조용한 실패**였다. 여기서 다시 확인된다.
### 그래서 답하지 못한 것
| 계획서의 항목 | 상태 |
| 관측 | 읽는 법 |
|---|---|
| nginx reload 타이밍에 무중단인가 | **미측정** |
| 갱신 중 진행 중이던 요청은 | **미측정** |
| `certbot-renew.timer` 가 실제 갱신을 하는가 | **미측정** (58일 뒤에야 알 수 있다) |
| 바이트가 전량이다 | 잘리지 않았다 |
| **연결수가 1이다** | 중간에 끊겨 재연결한 게 아니다 |
| 코드 200 | 옛 워커가 이 요청을 끝까지 책임졌다 |
### 이론적으로는 무엇을 기대하는가
옛 인증서로 시작한 연결이 **새 워커 전환을 관통해** 끝까지 갔다.
in-flight 전체 50건 중 종료코드 ≠ 0 은 **0건**이다.
```
certbot renew → 새 인증서 파일 저장
└─ deploy-hook: nginx -s reload
└─ nginx 는 새 워커를 띄우고 옛 워커는 진행 중 요청을 끝낸 뒤 종료
→ graceful. 진행 중 요청은 옛 인증서로 완결된다
```
> 문서가 "nginx 의 reload 는 설계상 무중단이지만 **확인하지 않았으므로 그렇게
> 쓰면 안 된다**"고 유보했던 명제가, 이제 측정으로 뒷받침된다.
> **그리고 유보가 옳았다** — 같은 자리에서 유보 없이 썼다면 맞았겠지만,
> 바로 옆의 "갱신하면 반영된다"는 명제는 틀렸다. 어느 쪽이 틀릴지는 미리 알 수 없다.
**nginx 의 reload 는 설계상 무중단**이지만, **확인하지 않았으므로 그렇게
쓰면 안 된다.** 이 실험대에서 반복해 배운 것이 바로 그것이다 —
A-1 의 NetworkPolicy, A-3 의 `--grace-period=0`, B-5 의 AOF 모두
**"그럴 것이다" 가 틀렸던 사례**다.
### 4-4. 남은 미측정
---
| 항목 | 상태 |
|---|---|
| `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).
## 5. 재현 절차 (명령어)
```bash
# 1. 인증서 내용 — 밖에서 볼 수 있다
echo | openssl s_client -connect auth.hyeonworks.com:443 -servername auth.hyeonworks.com 2>/dev/null \
| openssl x509 -noout -subject -issuer -dates -ext subjectAltName
# ─────────────────────────────────────────────────────────────
# 1. 인증서 내용 — 밖에서 볼 수 있다(sudo 불필요)
# ─────────────────────────────────────────────────────────────
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 을 쓴 것이다
# ─────────────────────────────────────────────────────────────
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
systemctl is-enabled certbot-renew.timer
# ─────────────────────────────────────────────────────────────
# 3. ★ 대조군을 먼저 잡는다 — 이걸 건너뛰면 주입 결과를 해석할 수 없다
# ─────────────────────────────────────────────────────────────
# 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
# 갱신 중 다른 창에서:
# 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. 남긴 것
+274
View File
@@ -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 는 결함을 찾고 **처방을 적어두고 검증하지 않았다.**
처방이 듣는지 모르는 채 "이렇게 고치면 된다"고 쓰는 것은,
이 실험대가 스물세 번 경계해 온 바로 그 실수다.
---
## 구조
![D-4a — 훅 유무에 따른 갱신→서빙 타임라인](diagrams/d4a-hook-timeline.svg)
> 다이어그램 규약은 [`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).
+144 -27
View File
@@ -4,8 +4,21 @@
증거 [`docs/evidence/followup/`](evidence/followup/) ·
2026-09-04 17:3518:20 KST
23개 실험을 마치며 **세 항목을 "못 했다" 로 남겼다.** 그중 둘을 채우고,
셋째(D-4 강제 갱신)는 권한이 필요해 별도로 진행한다.
23개 실험을 마치며 **세 항목을 "못 했다" 로 남겼다. 이제 셋 다 채웠다.**
셋째(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 의 결론이 정밀해졌다** | **"롤백 불가" 는 조건부다** — 스키마가 바뀌었을 때만 |
| **B-4 ③ role 변경 반영 시점** | **요청 횟수와 무관하다.** 세션이 새로 만들어져야 한다 |
| **B층에 Grafana 증거가 없는 이유** | **관측 대상에 없다.** 안 찍은 것이 아니다 |
| **D-4 강제 갱신** | **★ 갱신은 성공했는데 38분 25초 동안 반영되지 않았다** |
| **D-4 reload 무중단** | **무중단이다.** 8856건 전부 200 · 전송 한가운데였던 요청이 전량 수신 |
---
@@ -130,30 +145,66 @@ B-4 를 할 때 **oauth2-proxy 가 아직 배포되지 않아** "proxy session"
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
# 1. 기준선
fetch('/api/echo') → x-forwarded-email = labuser@example.com
# 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. 반복 요청
for (i=1..12) fetch('/api/echo') # 0.5초 간격
# 4. Redis 세션을 지워 재인증을 강제한다 (반영 조건 확인)
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
2: labuser@example.com
...
12: labuser@example.com ← 12회 · 6초 동안 옛 값
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: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: a password is required
```
**호스트 sudo 가 비밀번호를 요구해 `certbot renew --force-renewal`
`systemctl reload nginx` 를 실행할 수 없다.** 사람이 함께 있어야 한다.
호스트 sudo 가 비밀번호를 요구해 `certbot renew --force-renewal` 을 실행할 수
없었다. 게스트(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초 간격으로는 전환을 거의 못 잡았다.
nginx reload 는 그보다 훨씬 짧을 것이므로 해상도를 올려야 한다.
| | 시각 (UTC) |
|---|---|
| 새 인증서 디스크 기록 | 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).
---
+14 -4
View File
@@ -26,8 +26,11 @@
| **D-1** | 백업·복구 | `...d1-backup-restore` | **빈 데이터베이스가 `200` 을 냈다** |
| **D-2** | 버전 업그레이드 | `...d2-version-upgrade` | **이미지를 되돌려도 스키마는 안 돌아온다** |
| **D-3** | 비밀 관리 | `...d3-secret-management` | **RBAC 만 실제로 감춘다** |
| **D-4** | 인증서 갱신 | `...d4-certificate-renewal` | 구성은 정상. **강제 갱신은 사람 손이 필요** |
| **후속** | 미측정 항목 채우기 | `...followup-untested-items` | 정방향 업그레이드 무중단 · **롤백 불가는 조건부였다** · role 변경은 요청으로 반영 안 됨 |
| **D-4** | 인증서 갱신 | `...d4-certificate-renewal` | **갱신은 됐는데 38분 25초 반영 안 됨** (훅 3경로 전부 비었음). reload 자체는 **무중단**(8856건 0실패) |
| **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개 |
| `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 을 긁지 않아** 만들 수가 없다 —
[`followup/04-observability-gap.txt`](evidence/followup/04-observability-gap.txt) 에 측정해 두었다.
@@ -85,7 +95,7 @@
| A-6 | `tc ... dev eth0` | 인터페이스가 `enp1s0` 이다 |
| A-6 | `enp1s0` 에 파드 IP 필터 | VXLAN 캡슐화로 **안 보인다** |
| 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 패킷 카운터,
+209
View File
@@ -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
```
+445
View File
@@ -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층에 있다.
+407
View File
@@ -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` 로 강제 |
+391
View File
@@ -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자로 온전히
남아 있었다. 잘린 문자열을 놓고 원인을 추측하면 없는 문제를 좇게 된다.
+449
View File
@@ -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초 먼저 실행된 것이 되어 물리적으로
불가능했다 — **음수 지연이 나오면 계산이 아니라 시계를 의심한다.**
+514
View File
@@ -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)
+198
View File
@@ -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 서비스 이름 확인 |
+113
View File
@@ -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/) 에 있다.
지어낸 실패 사례는 없다.
+112
View File
@@ -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 가 여기 해당하며, 각 편이 어느 단계가 그런지 적는다.
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More