728x90

결제·주문·정산 같은 장기 실행 로직을 다루는 팀이면 최근 1~2년 사이에 한 번쯤 "Temporal 도입 검토"라는 문서를 봤을 거다. 그리고 그 문서 옆에 요즘 Restate가 같이 올라온다. 원문 글의 요지는 딱 한 줄로 압축된다. "쓸 코드로 고르지 말고, 운영할 것으로 골라라(pick on what you have to operate, not what you have to write)."

실제로 두 엔진 모두 각 단계를 저널(journal)에 기록하고 크래시 후 리플레이해서 "카드는 긁혔는데 재고는 안 잡힌" 상태를 방지한다. 내구성 자체를 차별점으로 파는 영업은 걸러도 된다. 차이는 저널 주변에 있다. 어디에 저장하고, 무엇이 그걸 돌리고, 내 앱이 어떤 모양으로 쪼개져야 하는가.

Durable Execution이 필요해지는 순간

주문 처리 핸들러를 예로 들자. 카드 승인 → 재고 예약 → 메일 발송 → 배송 상태 갱신. 이 중간에 파드가 OOMKill 되면 메모리에 있던 진행 상태는 전부 날아간다. 그래서 우리는 보통 이런 걸 직접 만든다.

  • 진행 상태를 담는 order_saga 테이블과 상태 enum
  • 멱등키 테이블 + PG사 재조회로 "이미 승인됐나?" 확인
  • 실패 단계만 다시 태우는 재시도 워커, 그리고 지수 백오프 큐
  • 보상 트랜잭션(환불, 재고 해제) 코드와 그 코드의 실패 처리

이게 나쁘다는 게 아니다. 다만 이 코드는 도메인 로직보다 빨리 늘어나고, 장애 리포트의 절반은 여기서 나온다. Durable execution 엔진은 이 "상태 저장 + 재시도 + 이어서 실행"을 프레임워크로 흡수한다. 완료된 스텝은 저널에 남고, 프로세스가 되살아나면 저널을 리플레이해 끝난 단계를 건너뛰고 정확히 멈춘 지점부터 이어간다.

Temporal은 클러스터, Restate는 바이너리 한 개(단, 별표 붙음)

Temporal: 4개 서비스 + 외부 DB + 내 워커

Temporal 서버는 한 프로세스가 아니다. 게이트웨이 역할의 Frontend, 워크플로 상태와 타이머를 소유하는 History, 태스크 큐를 호스팅하는 Matching, 그리고 Temporal 자체 시스템 워크플로용 내부 Worker. 각각 별도 프로세스에 자기 gRPC 엔드포인트를 가지며, 규모가 커졌을 때 독립적으로 스케일하려고 이렇게 쪼개 놨다.

그리고 이 클러스터는 스스로 아무것도 기억하지 못한다. 영속성은 외부 DB(실무에선 PostgreSQL/MySQL, 로컬 개발용 SQLite)가 담당한다. 오래된 블로그 보고 Cassandra를 떠올렸다면 접어라. 원문에 따르면 Cassandra는 Server v1.21에서 deprecated, v1.24에서 제거됐다. 검색(Visibility)도 오해가 많은데, Server v1.20부터 SQL DB가 Advanced Visibility를 지원하므로 Elasticsearch가 필수는 아니다. "볼륨이 커지면 결국 쓰게 될 것"이 정직한 표현이다.

여기에 하나 더. 내 워크플로 코드는 Worker라는 별도 배포 단위에서 돌고, 클러스터의 태스크 큐를 long-poll한다. 즉 도입은 "서버 하나 띄우기"가 아니라 클러스터 + DB + (나중에) 검색 클러스터 + API/워커 분리다.

// workflows.ts — 오케스트레이션. 결정적(deterministic)이어야 한다
import { proxyActivities } from '@temporalio/workflow';
import type * as activities from './activities';

const { greet } = proxyActivities<typeof activities>({
  startToCloseTimeout: '1 minute',
});

export async function example(name: string): Promise<string> {
  // Date.now(), Math.random(), 직접 I/O 전부 금지
  return await greet(name);
}

greet를 직접 부르지 않고 proxyActivities를 통과시키는 이유가 핵심이다. 워크플로 코드는 복구할 때마다 이벤트 히스토리로 리플레이되므로, 같은 입력이면 항상 같은 커맨드 시퀀스를 뱉어야 한다. 아니면 Temporal이 비결정성 에러를 내고 진행을 거부한다. 대신 이 모델 덕분에 "30일 sleep 후 로컬 변수 그대로 깨어나기"가 가능하다. 프로세스를 살려두는 게 아니라 히스토리를 다시 재생해 상태를 복원하니까.

Restate: DB가 바이너리 안에 들어있다

Restate는 반대쪽에 베팅한다. Rust로 쓰인 단일 바이너리에 스트림 처리 아키텍처, 그리고 저널과 durable state를 담는 내장 RocksDB가 함께 들어있다. 프로비저닝할 Postgres도, 돌봐야 할 Elasticsearch도 없다.

다만 원문이 짚은 정직한 별표: "단일 바이너리"가 "프로세스 하나로 끝"을 뜻하지는 않는다. HA를 원하면 그 바이너리 여러 인스턴스를 띄우고, RocksDB가 주기적으로 오브젝트 스토어(S3/GCS/Azure Blob)에 스냅샷을 남겨야 한다. 그래야 노드가 죽어도 다른 노드가 상태를 복구하고 로그를 트림할 수 있다. 그래서 실제 비교는 "1 프로세스 vs 클러스터"가 아니라 "같은 바이너리 몇 개 + 버킷 하나" vs "4종 서비스 + RDB + (옵션) 검색 클러스터 + 쪼갠 워커"다. 그래도 운영 표면적 차이는 여전히 극적이다.

import * as restate from "@restatedev/restate-sdk";

// Virtual Object: id로 키잉된 상태 + 단일 writer 보장
export const myObject = restate.object({
  name: "MyObject",
  handlers: {
    myHandler: async (ctx: restate.ObjectContext, greeting: string) => {
      // ctx.sleep(), awakeable 등 durable 연산은 ctx 경유
      return `${greeting} ${ctx.key}!`;
    },
  },
});

restate.serve({ services: [myObject] });

Restate의 간판 프리미티브인 Virtual Object는 id로 키잉된 상태 엔티티다. 자기만의 KV 상태를 갖고, 특정 객체 상태는 한 번에 하나의 핸들러만 변경하는 단일 writer 보장이 붙는다. 별도 상태 저장소 없이 keyed state를 얻는 셈이다. 외부 이벤트를 기다리는 durable promise(awakeable), ctx.sleep 기반 durable timer도 있고, SDK는 TypeScript/Java·Kotlin/Python/Go/Rust를 지원한다. 중요한 건 Temporal식 강한 결정성 계약을 핸들러에 같은 방식으로 강요하지 않는다는 점이다. durable 스텝은 context를 통과하고, 프레임워크가 저널을 리플레이한다.

실무 관점: 5분 실습, 운영 표면적, 그리고 함정

일단 띄워보기

$ temporal server start-dev --db-filename temporal.db
CLI 1.x.x (Server 1.x.x, UI 2.x.x)

Server:  localhost:7233
UI:      http://localhost:8233
Metrics: http://localhost:64000/metrics

(버전 문자열과 포트는 릴리스마다 다르다. 이건 로컬 개발용 dev 서버이고, 프로덕션은 앞서 말한 4종 서비스 + RDB 구성이다. 여기서 "로컬은 한 줄인데 왜 운영은 이렇게 복잡하냐"는 갭이 도입 검토에서 가장 자주 새는 지점이다.)

$ docker run --name restate -d --rm \
    -p 8080:8080 -p 9070:9070 -p 9071:9071 \
    docker.io/restatedev/restate:latest

$ restate deployments register http://host.docker.internal:9080
✅ Successfully registered deployment  (services: MyObject)

# 서비스 호출 (ingress)
$ curl -s localhost:8080/MyObject/alice/myHandler -H 'content-type: application/json' -d '"Hello"'
"Hello alice!"

포트/플래그는 버전에 따라 달라질 수 있으니 공식 문서 확인이 필요하다. 다만 흐름 자체는 "바이너리 실행 → 내 핸들러 프로세스 등록 → HTTP 호출"이 끝이다.

운영 표면적 비교

  • 배포 토폴로지: Temporal = Frontend/History/Matching/내부 Worker + 내 Worker Deployment + API Deployment. Restate = 바이너리 N개 + 내 핸들러 서비스.
  • 스토리지 의존성: Temporal = PostgreSQL/MySQL(+ 볼륨 커지면 ES/OpenSearch). Restate = 로컬 디스크(RocksDB) + 오브젝트 스토리지 버킷.
  • 커플링: Temporal 구성은 API·워커·서버 셋이 서로를 물고 있어 하나가 죽으면 나머지가 같이 흔들린다. 원문이 인용한 DBOS 벤치마크(경쟁사가 자기 제품과 비교한 자료다)에서는 샘플 앱에 Temporal을 붙이며 100줄 이상 수정, 110→187줄 증가, 두 서비스 분리 + 세 번째(Temporal 서버) 런타임 의존이 생겼다고 한다. 자연법칙처럼 인용할 숫자는 아니고, Restate 수치로 옮겨 붙이면 더 안 된다. 다만 밑에 깔린 아키텍처 주장은 Temporal 공식 문서로도 검증되는 사실이다.
  • 장애 복구: Temporal은 DB가 진실의 원천이라 DB HA/백업 전략이 곧 워크플로 복구 전략이다. Restate는 노드 로컬 RocksDB + 오브젝트 스토어 스냅샷이라, 스냅샷 대상 버킷을 설정하지 않은 채 단일 노드로 굴리는 것이 가장 위험한 구성이다.

흔한 함정

1) 코드 한 줄 고쳤는데 진행 중 워크플로가 멈춘다. Temporal 도입 팀의 통과의례다. 워크플로 함수 안에서 액티비티 호출 순서를 바꾸거나 분기를 추가하면 리플레이 시 커맨드 시퀀스가 어긋난다. SDK·버전에 따라 문구는 다르지만 대략 이런 형태의 에러를 보게 된다.

DeterminismViolationError: Workflow activation completed with a different
command list than the one in the event history.
  expected: ScheduleActivityTask(greet)
  actual:   StartTimer

io.temporal.worker.NonDeterministicException: Failure handling event 5 of type
'EVENT_TYPE_ACTIVITY_TASK_SCHEDULED' during replay

해법은 정공법뿐이다. 워크플로 로직 변경은 버전 게이팅(patch/versioning API)으로 감싸고, 태스크 큐를 분리해 신규 워크플로만 새 코드로 받는 식으로 배포한다. Date.now()·Math.random()·직접 HTTP 호출은 워크플로 안에서 전부 금지어라는 걸 팀 컨벤션과 린트로 못 박아야 한다.

2) long-poll과 프록시 타임아웃. Temporal 워커는 태스크 큐를 long-poll 한다. 앞에 idle timeout이 짧은 LB/프록시(또는 사내 방화벽)를 두면 폴이 계속 끊기면서 context deadline exceeded 류의 로그가 도배된다. gRPC 경로에는 가급적 L4로 통과시키고 idle timeout을 폴 주기보다 넉넉히 잡는 편이 안전하다(정확한 기본값은 SDK 문서 확인 필요).

3) "ES 없이 가자"고 결정한 다음 운영에서 후회. v1.20부터 SQL만으로 Advanced Visibility가 되지만, 워크플로 수가 늘고 커스텀 속성으로 조회하기 시작하면 DB 부하로 돌아온다. "ES 필수"는 미신, "결국 필요해질 것"은 사실 — 이 중간 지점을 도입 문서에 명시해 두자.

4) Restate에서 상태를 ctx 밖에 두기. 핸들러 안에서 전역 변수나 외부 캐시에 진행 상태를 들고 있으면 리플레이 시 그 값은 복원되지 않는다. durable하게 남길 값은 반드시 context를 경유해야 한다. Virtual Object 키 설계도 초기에 잡아야 한다. 단일 writer 보장은 같은 키에 대한 것이라, 키를 너무 굵게 잡으면 그게 곧 직렬화 병목이 된다(주문번호 단위 vs 사용자 단위 같은 결정).

정리: 무엇을 언제 고를까

한 줄 요약: 코드 델타는 생각보다 작고, 운영 델타는 생각보다 크다. 새벽 2시에 당신이 붙잡을 대상이 무엇인지로 고르면 후회가 적다.

  • Restate 쪽에 기울 때: 팀에 전담 플랫폼 인력이 없다. 이미 굴리는 RDB/ES에 또 하나를 얹기 부담스럽다. 필요한 건 "이 핸들러를 크래시에 강하게 만들고 약간의 durable state를 붙이기"이고, 전면적인 워크플로 오케스트레이션 세계관까지는 원치 않는다. 마이크로서비스/이벤트 핸들러/에이전트처럼 백엔드 전반에 얇게 깔고 싶다.
  • Temporal 쪽에 기울 때: 30일 대기·복잡한 보상 트랜잭션·child workflow가 얽힌 정말 험한 장기 오케스트레이션이 있다. 재시도/타임아웃 설정을 세밀하게 통제해야 한다. 조직에 이미 Postgres·ES 운영 역량과 SRE가 있다. 혹은 그냥 Temporal Cloud에 돈을 내고 클러스터 운영을 아웃소싱할 수 있다(규모 있게 갈 거라면 보통 이게 합리적이다).

국내 환경 도입 팁

  • EKS: Temporal은 History/Matching/Frontend를 별도 Deployment + HPA로 잡고, 내 워커는 태스크 큐별로 분리해 스케일 축을 나눈다. RDS(Postgres) 파라미터·커넥션 수 산정을 초기에 해두지 않으면 워커 스케일아웃이 곧 DB 커넥션 고갈로 이어진다. Restate는 상태를 로컬 디스크에 두므로 StatefulSet + 적절한 PVC(가급적 gp3 이상), 스냅샷용 S3 버킷과 IRSA 권한이 세트다.
  • 온프레미스: 오브젝트 스토리지가 없으면 Restate HA 전제가 흔들린다. MinIO 같은 S3 호환 스토리지를 함께 검토하고, 지원 여부는 공식 문서로 확인하자.
  • 마이그레이션: 기존 사가 테이블을 한 번에 걷어내지 말 것. 신규 주문 흐름만 durable execution으로 받고 기존 건은 옛 워커가 소진시키는 이중 운영 기간을 두는 게 안전하다. 그리고 어느 쪽을 택하든 워크플로 히스토리/저널 보존 기간과 스토리지 증가율을 도입 첫 주에 측정해 두자. 이걸 놓치면 반년 뒤 디스크가 먼저 알려준다.

참고 자료

728x90
728x90

어느 날 아침 Hacker News 상단에 스크롤 애니메이션만 잔뜩 있는 랜딩 페이지 하나가 올라왔다. Discovery Loop. 제품도, 문서도, 가격표도 없다. 있는 건 "과학적 발견은 병목에 걸려 있다"는 문장과, 창업 멤버 네 명의 이름(Jeff Dean, Sanjay Ghemawat, Quoc Le, Oriol Vinyals), 그리고 채용 링크뿐이다.

MapReduce·GFS·BigTable·Spanner·TensorFlow·TPU를 만든 사람들이 "실험 루프를 자동화하겠다"고 하면, 그건 연구 선언문이 아니라 인프라 선언문으로 읽어야 한다. 이 글은 그 관점에서 쓴다. 우리가 이미 사내에서 굴리고 있는 실험 파이프라인과 뭐가 같고, 뭐가 다르고, 스케일을 올릴 때 실제로 어디서 터지는지.

1. 왜 지금 이게 화제인가

원문의 문제 정의는 간결하다. 과학적 방법론 자체는 훌륭한데, 실행 단계가 반복 노동이라는 것.

you propose an experiment, implement and run it, examine the results, then iterate to refine your approach.

실험 제안 → 구현/실행 → 결과 확인 → 개선. 이거 어디서 많이 본 루프다. 우리가 매일 하는 것이기도 하다. 코드 푸시 → CI 빌드 → 테스트 리포트 → 수정. 다른 점은 CI는 이미 자동화됐는데, ML 연구나 과학 실험의 루프는 아직 사람이 "다음에 뭘 해볼까"를 판단하고 손으로 잡을 던진다는 것.

Discovery Loop가 하겠다는 건 그 판단 단계까지 프론티어 모델에 맡기고, 대규모 컴퓨트로 수천 개 실험을 동시에 굴려 반복 주기를 압축하는 것이다. 첫 타깃도 명시돼 있다: ML 리서치/엔지니어링 자체를 먼저 자동화하고, 그걸로 자기네 스택을 최적화한다(act as our own first customer). 그다음이 NAE Grand Challenges — 신약, 헬스 인포매틱스, 태양광 경제성, 깨끗한 물, 사이버스페이스 보안.

참고로 사이트에는 투자 규모, 출시 일정, 벤치마크 수치 같은 건 일절 없다. 지금 시점에서 확인 가능한 건 "미션 + 팀 + 채용"이 전부고, 그 이상은 추측이다. 그러니 여기서는 기술 주장 대신 이 구조를 실제로 만들면 어디가 아픈지를 이야기하는 게 낫다.

2. 핵심 원리: 결국 상태 머신 + 스케줄러 + 평가 하네스

"AI가 실험을 자동으로 설계한다"는 말은 멋있지만, 구현체로 내려오면 놀랄 만큼 익숙한 세 조각이다.

  • 제안기(proposer): 다음에 시도할 설정을 만든다. 하이퍼파라미터든, 코드 패치든, 아키텍처든. 기존에는 베이지안 최적화나 사람이었고, 이제는 LLM이 들어간다.
  • 실행기(runner): 그 설정을 격리된 환경에서 돌린다. 컨테이너, GPU 할당, 타임아웃, 재시도. 딱 잡 스케줄러 영역.
  • 평가기(scorer): 결과를 기계가 읽을 수 있는 숫자로 만든다. 원문이 "learning loop with measurable outcomes"라고 못 박은 이유가 여기 있다. 측정이 안 되면 루프가 안 돈다.

가장 단순한 형태를 코드로 쓰면 이렇다. 실전 파이프라인의 골격도 사실 이것과 크게 다르지 않다(상태 저장을 sqlite로 두는 게 포인트 — 재시작이 공짜가 된다).

import json, sqlite3, subprocess

db = sqlite3.connect("loop.db")
db.execute("CREATE TABLE IF NOT EXISTS trial(id INTEGER PRIMARY KEY, cfg TEXT, score REAL)")

def propose(history):            # LLM이든 optuna든 여기만 갈아끼운다
    best = max(history, key=lambda h: h[1], default=({"lr": 1e-3}, 0))[0]
    return {"lr": best["lr"] * 0.5}

def run_and_score(cfg):
    out = subprocess.run(["python", "train.py", "--lr", str(cfg["lr"])],
                         capture_output=True, text=True, timeout=1800)
    return json.loads(out.stdout.strip().splitlines()[-1])["val_acc"]

hist = [(json.loads(c), s) for c, s in db.execute("SELECT cfg, score FROM trial")]
for _ in range(20):
    cfg = propose(hist)
    score = run_and_score(cfg)
    db.execute("INSERT INTO trial(cfg, score) VALUES (?,?)", (json.dumps(cfg), score))
    db.commit()
    hist.append((cfg, score))
    print(f"cfg={cfg} score={score:.4f}")
$ python loop.py
cfg={'lr': 0.0005} score=0.8123
cfg={'lr': 0.00025} score=0.8340
cfg={'lr': 0.000125} score=0.8291

여기서 for _ in range(20)을 순차 실행이 아니라 수천 개 병렬로 바꾸고, propose를 프론티어 모델로 바꾸고, run_and_score를 클러스터 잡 제출로 바꾸면 — 원문이 말하는 그림이 된다. 어려운 건 아이디어가 아니라 병렬도를 3자리수로 올렸을 때 무너지는 주변부다.

3. 실무 관점: 병렬도를 올리면 어디가 먼저 터지나

(1) 스케줄링 — GPU는 CPU처럼 오버커밋되지 않는다

사내에서 튜닝 잡 200개를 한 번에 던지면 제일 먼저 만나는 화면이다.

$ kubectl describe pod trial-0147
Events:
  Type     Reason            Age   From               Message
  ----     ------            ----  ----               -------
  Warning  FailedScheduling  3m    default-scheduler  0/8 nodes are available: 8 Insufficient nvidia.com/gpu.
           preemption: 0/8 nodes are available: 8 No preemption victims found for incoming pod.

Pending이 수백 개 쌓이면 스케줄러 큐가 지저분해지고, 팀 동료의 대화형 노트북 잡까지 밀린다. 대응은 셋 중 하나다.

  • 큐 자체를 분리한다. Kueue / Volcano / Slurm 파티션으로 "탐색용 저우선순위 큐"를 따로 두고 PriorityClass에 preemption을 허용한다.
  • 동시 실행 상한을 루프 쪽에서 건다. Argo Workflows의 parallelism, Slurm array의 %N 같은 것. 큐에 던지는 개수와 실제 도는 개수를 분리하는 게 핵심이다.
  • 실험 하나가 GPU를 다 안 쓰면 MIG나 time-slicing으로 쪼갠다. 다만 스루풋 측정값이 흔들리므로 성능 비교 실험에는 쓰지 않는다.
# Slurm array로 동시 16개만 유지하며 512개 trial 굴리기
$ sbatch --array=1-512%16 --gres=gpu:1 --time=00:30:00 trial.sbatch
Submitted batch job 918234

$ squeue -u $USER -h -o "%t" | sort | uniq -c
     16 R
    496 PD

(2) 아티팩트 스토리지와 메타데이터 DB

trial 하나가 체크포인트·로그·텐서보드 이벤트를 남긴다. 1000개면 그게 1000배다. 오브젝트 스토리지에 초당 수백 개 PUT을 때리면 이런 걸 본다.

botocore.exceptions.ClientError: An error occurred (SlowDown) when calling the
PutObject operation (reached max retries: 4): Please reduce your request rate.

실전 처방: ① 작은 파일 다발을 tar로 묶어 한 번에 올린다, ② 프리픽스에 해시를 넣어 샤딩한다, ③ 중간 체크포인트는 기본적으로 버린다(상위 N개 trial만 재현용으로 보존). 그리고 MLflow/W&B 같은 트래킹 서버의 DB도 병목이 된다 — 메트릭을 스텝마다 동기 전송하면 트래킹 서버가 먼저 죽는다. 배치 로깅으로 바꾸는 게 첫 수순이다.

(3) 평가가 흔들리면 루프가 거짓말을 학습한다

개인적으로 가장 비싸게 배운 부분. 자동화 루프는 "점수를 올리는 방향"으로만 움직이는데, 그 점수가 오염돼 있으면 루프는 오염을 최적화한다. 실제로 겪은 패턴들:

  • 테스트셋 누수. 제안기가 데이터 전처리 코드까지 수정할 권한을 갖는 순간, 검증셋이 학습셋에 섞이는 변경을 "성능 개선"으로 발견해버린다.
  • 노이즈보다 작은 개선. 시드 하나로 측정하면 ±0.5%p 흔들리는 지표에서 0.2%p 개선을 진짜라고 믿는다. 최소 3시드 중앙값, 아니면 이전 최고 기록과의 차이가 표준편차를 넘을 때만 채택하는 게이트가 필요하다.
  • 타임아웃 = 실패 = 낮은 점수 처리. 클러스터가 붐빌 때 죽은 잡이 "나쁜 설정"으로 기록되면, 제안기는 클러스터 혼잡도를 학습한다. 인프라 실패와 실험 실패는 반드시 다른 상태 코드로 구분해야 한다.

(4) 코드 생성이 들어올 때의 격리

LLM이 제안기 자리에 앉으면 제안 내용이 하이퍼파라미터가 아니라 실행 가능한 패치가 된다. 그 순간 이건 MLOps 문제가 아니라 보안 문제다. 네트워크 egress 차단(패키지 미러만 허용), 읽기 전용 데이터 마운트, seccomp 프로파일, 잡별 서비스 어카운트 토큰 비활성화 — CI 러너에 이미 적용해둔 규칙을 그대로 가져오면 된다. 클라우드 크리덴셜이 붙은 노드에서 생성 코드를 그냥 돌리는 건, 인터넷에서 받은 스크립트를 프로덕션에서 sudo로 실행하는 것과 같다.

(5) 지금 당장 쓸 수 있는 대안

Discovery Loop는 아직 쓸 수 있는 제품이 없다. 반면 루프 자동화의 80%는 이미 오픈소스로 가능하다. 탐색 알고리즘은 Optuna/Ray Tune, 워크플로 오케스트레이션은 Argo Workflows나 Slurm, 추적은 MLflow. 여기에 "제안기만 LLM으로 교체"하는 게 현실적인 진입 경로다. 순서를 바꾸면(LLM 먼저 붙이고 인프라 나중) 십중팔구 실패한다 — 평가 하네스와 잡 스케줄링이 안정적이지 않은 상태에서 제안 속도만 올리면, 쓰레기를 더 빨리 만들 뿐이다.

4. 정리

한 줄 요약: Discovery Loop는 "실험의 제안–실행–평가–반영 루프를 사람 손에서 떼어내겠다"는 미션 선언이고, 그 승부처는 모델이 아니라 스케줄러·스토리지·평가 하네스라는 인프라 쪽에 있다.

누가 언제 봐야 하나로 정리하면:

  • 지금 당장 할 것: 실험이 하루 열 개 이상 돌아가는 팀이라면, 측정 가능한 단일 스칼라 지표와 재현 가능한 실행 단위(컨테이너 + 고정 시드 + 잠긴 의존성)를 먼저 만들어라. 이게 없으면 어떤 자동화도 못 붙인다.
  • 주시만 할 것: 제품이 없으니 도입 검토 대상이 아니다. 다만 채용 공고와 향후 공개 자료를 보면 "그들이 실험 루프를 어떤 인프라로 감싸는지"에 대한 힌트가 나올 가능성이 있다. 그건 우리 파이프라인 설계에 그대로 쓸 수 있는 정보다.
  • 경계할 것: 자동화 루프의 결과를 사람이 검증 없이 머지하는 관행. 루프는 지표를 올리지, 문제를 이해하지 않는다.

MapReduce가 "분산 처리를 map/reduce 두 함수로 추상화"해서 판을 바꿨듯이, 이번에도 "실험을 propose/run/score 세 함수로 추상화"하는 그림으로 보인다. 그 추상화가 실제로 어디까지 유효한지는 공개된 결과물이 나와야 판단할 수 있다.

참고 자료

728x90
728x90

kubebuilder로 컨트롤러 한번 만들어본 사람이라면 다들 겪는 순간이 있다. r.Get()으로 방금 r.Update() 친 오브젝트를 다시 읽었는데 옛날 값이 나온다. "어? 방금 바꿨는데?" 하고 로그 찍어보고, 재현 안 되고, 그러다 넘어간다. 근데 이게 부하 올라가면 실서비스에서 터진다.

이 글은 그 "왜 옛날 값이 나오지?"의 근본 원인인 controller-runtime 캐시를 뜯어본다. 원문은 Kubernetes 공식 블로그의 How the controller-runtime Cache Actually Works(Andrei Kvapil, Timofei Larkin)를 기반으로 하고, 실무에서 실제로 밟는 지뢰 위주로 재구성했다.

1. 왜 지금 이걸 알아야 하는가

요즘 사내 플랫폼팀에서 CRD + 컨트롤러 조합으로 자동화 짜는 게 거의 표준이 됐다. kubebuilder 스캐폴딩 돌리면 몇 시간 만에 동작하는 컨트롤러가 나온다. 문제는 그다음이다. 트래픽 없을 땐 잘 돌던 게, 오브젝트 수만 개 규모에서 갑자기 이상하게 군다.

대부분의 사람이 갖고 있는 잘못된 멘탈 모델은 이렇다:

  • r.Get()은 kube-apiserver를 직접 조회한다
  • r.List()는 실시간 상태를 돌려준다
  • r.Update() 직후 다시 읽으면 새 값이 바로 보인다

셋 다 틀렸다. 실제로는 정반대다. controller-runtime은 list + watch로 채워둔 로컬 인메모리 복사본을 상대로 읽는다. 그래서 reconcile 안에서 초당 수백 번 읽어도 컨트롤 플레인에 부하가 거의 안 간다. 대신 대가로 조용히 메모리를 수 GB씩 먹고, 숨은 O(n) 스캔을 돌리고, stale read(오래된 값 읽기)에 걸려 넘어진다.

이 트레이드오프를 이해하고 있느냐 아니냐가 실서비스에서 컨트롤러 굴려본 사람과 예제만 돌려본 사람의 차이다.

2. 핵심: 캐시가 실제로 어떻게 채워지고 읽히는가

한 줄 요약부터

reconcile 안의 r.Get(), r.List()API 서버를 안 읽는다. 매니저가 시작할 때 list로 워밍업한 뒤 watch로 계속 최신 상태를 유지하는 로컬 캐시에서 읽는다. 나머지 성질들은 전부 이 한 줄에서 파생된다.

  • 읽기는 싸다. 하지만 쓰기 직후 강한 일관성(strong consistency)은 보장 안 된다.
  • 쓰기는 캐시를 우회해서 API 서버로 직행한다.
  • 캐시 크기와 인덱스 개수가 곧 메모리 사용량이다.
  • 잘못 짠 List()는 수만 개 오브젝트에 대한 선형 스캔으로 조용히 변한다.

파이프라인: API 서버에서 이벤트 핸들러까지

sigs.k8s.io/controller-runtime/pkg/cache는 사실 k8s.io/client-go/tools/cache를 얇게 감싼 것뿐이다. Kubernetes 전체를 굴리는 것과 똑같은 프리미티브가 밑에 깔려 있다.

API 서버
   │  (list 1회 + watch 지속)
   ▼
Reflector      ← API 서버와 직접 대화하는 유일한 컴포넌트
   │  (delta 스트림)
   ▼
DeltaFIFO      ← key별로 변경 이력을 순서대로 누적
   │  (Pop)
   ▼
Indexer(Store) ← 실제 오브젝트가 사는 인메모리 저장소 + 인덱스
   │
   ▼
Event Handlers ← 당신 컨트롤러가 여기 붙는다 (OnAdd/OnUpdate/OnDelete)

각 링크를 실무자 관점으로 풀어보면:

Reflector는 시작할 때 딱 한 번 list를 친다. API 서버는 오브젝트 목록과 함께 그 스냅샷이 찍힌 시점의 resourceVersion을 돌려준다. 그러면 Reflector가 "버전 X부터 watch 열어줘"라고 요청하고, 그 이후에 일어난 모든 이벤트 스트림을 받는다. list와 watch 사이에 이벤트가 새는 위험이 없는 이유가 이거다. watch가 list가 끝난 지점에서 정확히 이어붙는다.

연결이 끊기면 마지막으로 알던 resourceVersion으로 재연결한다. API 서버가 410 Gone("그 버전은 이미 히스토리에서 밀려났어, 너무 뒤처졌어")을 돌려주면 그때 fresh list를 다시 친다. 이걸 relist라고 하는데, 스케줄로 도는 게 아니라 이런 실패 시나리오에서만 발생한다. "주기적으로 API 서버 긁는 거 아니냐"는 오해가 여기서 깨진다.

DeltaFIFO는 Reflector와 informer 사이의 버퍼다. 세 가지를 해결한다:

  • 순서 보존: default/my-deploy에 대해 흘러온 변경 순서를 소비자도 똑같이 본다.
  • key별 그룹핑: 같은 namespace/name의 delta가 한 슬롯에 쌓인다. Pop()은 delta 하나가 아니라 마지막 호출 이후 그 key에 쌓인 delta 전체를 슬라이스로 돌려준다.
  • 선택적 중복 제거: 연속된 Deleted delta는 합친다. 근데 연속된 Added나 Updated는 안 합친다. 중간 상태를 최종 하나로 뭉개는 건 원칙적으로 DeltaFIFO의 일이 아니다.

비유하자면

Git으로 생각하면 편하다. Reflector가 git fetch로 원격 변경을 계속 당겨오고, Indexer는 로컬 워킹 카피다. 당신의 r.Get()은 로컬 파일을 읽는 것이지 매번 GitHub에 요청 날리는 게 아니다. 그래서 빠르지만, 방금 push한(=Update한) 내용이 아직 fetch되기 전이면 로컬에선 옛날 상태가 보인다. 이게 stale read의 정체다.

3. 실무 관점: 트레이드오프와 흔한 함정

함정 1: Update 직후 Get 하면 옛날 값이 나온다

가장 흔하고 가장 오래 헤매는 케이스다. 쓰기는 API 서버로 직행하지만, 그 변경이 watch를 타고 로컬 캐시에 반영되기까지는 시간이 걸린다. 코드로 보면:

func (r *Reconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
    var cm corev1.ConfigMap
    if err := r.Get(ctx, req.NamespacedName, &cm); err != nil {
        return ctrl.Result{}, client.IgnoreNotFound(err)
    }

    cm.Data["updated"] = "true"
    if err := r.Update(ctx, &cm); err != nil {   // API 서버로 직행
        return ctrl.Result{}, err
    }

    // 함정: 여기서 다시 Get 하면 캐시에서 읽으므로
    // 방금 쓴 값이 아직 안 보일 수 있다
    var again corev1.ConfigMap
    _ = r.Get(ctx, req.NamespacedName, &again)
    // again.Data["updated"] 가 "true"가 아닐 수 있음!

    return ctrl.Result{}, nil
}

여기서 "그럼 재조회해서 값 맞을 때까지 루프 돌리자"는 최악의 대응이다. 캐시가 갱신될 때까지 busy loop를 도는 순간 CPU를 태운다. 올바른 접근은 reconcile을 멱등하게(idempotent) 짜는 것이다. Update 직후 재조회에 의존하지 말고, 다음 reconcile에서 다시 원하는 상태로 맞추면 된다. watch가 변경을 감지해서 어차피 다시 큐에 넣어준다.

정말로 방금 쓴 최신 값을 즉시 읽어야 하는 예외적 상황(예: optimistic lock 충돌 재시도, 상태 머신에서 순서가 중요한 경우)에서만 APIReader를 쓴다. 이건 캐시를 우회해서 API 서버를 직접 읽는다. 대신 값이 비싸니 남발하면 처음에 캐시 만든 의미가 없어진다.

// 캐시 우회 직접 읽기 - 정말 필요할 때만
if err := r.APIReader.Get(ctx, req.NamespacedName, &cm); err != nil {
    return ctrl.Result{}, err
}

함정 2: RBAC 누락 → 캐시 워밍업 자체가 실패

이건 검색으로 제일 많이 유입되는 에러다. 새 타입을 watch에 등록했는데 ServiceAccount에 해당 리소스 list/watch 권한이 없으면, informer가 캐시를 못 채우고 시작 단계에서 이런 로그가 뜬다:

W0729 10:23:41.882314   1 reflector.go:539] pkg/mod/k8s.io/client-go/tools/cache/reflector.go:229:
  failed to list *v1.ConfigMap: configmaps is forbidden:
  User "system:serviceaccount:my-ns:my-controller" cannot list resource "configmaps"
  in API group "" at the cluster scope

E0729 10:23:41.882390   1 reflector.go:147] pkg/mod/k8s.io/client-go/tools/cache/reflector.go:229:
  Failed to watch *v1.ConfigMap: failed to list *v1.ConfigMap: configmaps is forbidden

핵심 포인트: 컨트롤러는 크래시하지 않고 이 로그만 계속 토해낸다. Reconcile은 호출되긴 하는데 캐시가 비어 있어서 r.Get()이 NotFound를 돌려주고, 왜 아무 일도 안 일어나는지 한참 헤매게 된다. 로그를 안 보면 진짜 잡기 어렵다.

kubebuilder를 쓴다면 리컨사일러 위에 marker 주석을 달고 make manifests로 RBAC를 재생성하는 게 정석이다:

//+kubebuilder:rbac:groups="",resources=configmaps,verbs=get;list;watch;create;update;patch;delete

여기서 listwatch가 빠지면 캐시가 안 채워진다. get만 있어도 informer 워밍업은 실패한다는 걸 기억하자.

함정 3: List()가 조용히 선형 스캔으로 변한다

필드 셀렉터로 필터링한다고 생각하고 짠 List()가 인덱스가 없으면 전체 스토어를 훑는 O(n) 스캔이 된다. 오브젝트 수천 개까진 티도 안 나다가, 수만 개 규모에서 reconcile 지연이 확 늘어난다. 특정 필드로 자주 조회한다면 매니저 셋업 시점에 인덱서를 미리 등록하자:

// main.go 또는 SetupWithManager 근처
err := mgr.GetFieldIndexer().IndexField(
    ctx, &corev1.Pod{}, "spec.nodeName",
    func(o client.Object) []string {
        pod := o.(*corev1.Pod)
        return []string{pod.Spec.NodeName}
    },
)
if err != nil {
    return err
}

// 이후 이렇게 조회하면 인덱스를 탄다
var pods corev1.PodList
err = r.List(ctx, &pods,
    client.MatchingFields{"spec.nodeName": "node-1"},
)

인덱스 없이 client.MatchingFields를 쓰면 에러가 나거나(등록 안 된 필드일 때) 전체 스캔으로 떨어진다. 자주 쓰는 조회 패턴은 반드시 인덱스로 뒷받침해야 한다.

함정 4: 캐시 스코프를 안 좁혀서 메모리 폭발

기본값은 클러스터 전체의 해당 타입을 전부 캐시에 올린다. Secret이나 ConfigMap처럼 개수가 많은 리소스를 무심코 watch하면 메모리가 순식간에 GB 단위로 뛴다. 특히 대규모 클러스터에서 Pod 전체를 캐시하면 OOMKilled로 재시작 루프에 빠지기도 한다.

매니저 생성 시 캐시 범위를 좁히자. 특정 네임스페이스만, 혹은 특정 라벨만:

mgr, err := ctrl.NewManager(cfg, ctrl.Options{
    Cache: cache.Options{
        // 특정 네임스페이스로 제한
        DefaultNamespaces: map[string]cache.Config{
            "my-ns": {},
        },
        // 타입별로 필터를 다르게
        ByObject: map[client.Object]cache.ByObject{
            &corev1.Secret{}: {
                Label: labels.SelectorFromSet(labels.Set{
                    "managed-by": "my-controller",
                }),
            },
        },
    },
})

이렇게 ByObject로 라벨 셀렉터를 걸면 애초에 캐시에 안 올라온다. 다만 셀렉터에 안 걸린 오브젝트는 캐시에서 안 보이므로, 그걸 r.Get()하면 NotFound가 난다는 점을 팀에 공유해야 한다. 스코프 좁히기와 "필요한 걸 못 읽는 버그"는 종이 한 장 차이다.

확인 명령어: watch가 어떻게 도는지 눈으로 보기

컨트롤러가 소비하는 것과 같은 이벤트 스트림을 kubectl로도 볼 수 있다. 이걸 한번 돌려보면 "단일 최종 오브젝트가 아니라 상태의 연쇄"라는 감이 온다:

$ kubectl get pods --watch
NAME          READY   STATUS              RESTARTS   AGE
my-pod        0/1     Pending             0          0s
my-pod        0/1     ContainerCreating   0          1s
my-pod        0/1     Running             0          4s
my-pod        1/1     Running             0          8s

스케줄러가 노드를 배정하고, kubelet이 status를 갱신하고, 여러 컨트롤러가 각자 변경을 얹으면서 한 오브젝트가 여러 상태를 거쳐 간다. 컨트롤러도 정확히 이 스트림을 받아서 로컬 캐시를 최신으로 유지한다.

4. 정리

한 줄 요약: reconcile 안의 읽기는 API 서버가 아니라 list+watch로 채운 로컬 인메모리 캐시에서 나온다. 그래서 읽기는 싸지만 쓰기 직후 강한 일관성이 없고, 캐시 크기·인덱스가 곧 메모리이며, 잘못된 List는 조용히 선형 스캔이 된다.

누가 언제 신경 써야 하나:

  • 이미 controller-runtime으로 컨트롤러를 짜고 있고, "왜 방금 쓴 값이 안 보이지?"를 겪은 사람 → 함정 1을 멱등 설계로 해결하라.
  • 대규모 클러스터(오브젝트 수만 개 이상)에 컨트롤러를 배포할 사람 → 함정 3, 4를 배포 전에 반드시 점검하라. 메모리와 reconcile 지연이 걸린다.
  • 새 CRD/리소스를 watch에 추가하는 사람 → RBAC의 list/watch부터 확인하라. 함정 2는 크래시 없이 조용히 아무것도 안 하게 만든다.

반대로 소규모 클러스터에서 몇 개 오브젝트만 다루는 컨트롤러라면 기본값으로도 충분하다. 캐시 커스터마이징은 실제로 메모리나 일관성 문제를 겪은 뒤에 손대도 늦지 않다. 다만 멘탈 모델만큼은 처음부터 정확히 갖고 있어야 프로덕션에서 비싼 놀라움을 피한다.

참고 자료

728x90
728x90

시간은 없고 내용은 많으니 TL;DR 하겠습니다

 

결론
1. 실제로 프로덕트 환경에서는 안쓰일거같다 왜냐하면 컨테이너 개발이 상당히 어려울거같기 떄문..

 

반박 시 님들말이 맞습니다. ㅎㅎ 

 

이번에는 폐쇄망 환경에서 윈도우즈 2019 서버를 클러스터에 노드로 추가해보겠습니다. 

 

개념은 온라인 설치와 똑같습니다 

 

CalicoNode와 CalicoFelix 서비스를 설치한 뒤 Kubelet과 Kube-proxy를 설치하면 클러스터에 붙습니다. 

 

대신 폐쇄망이므로 온라인설치에 필요한 모든 파일을 사전에 다운로드 받아 노드로 추가할 윈도우즈 서버에 옮겨야 합니다. 

 

시작해보겠습니다

 

필요 파일 리스트 
* 7zip.msi (압축풀기용으로 필요)

(https://www.7-zip.org/download.html)
* Containers 설치 파일

(https://github.com/containerd/containerd/blob/main/docs/getting-started.md 에서 Windows검색하여 최신버전 받을 수 있음)
* calico-windows(v3.27.0버전 사용)

(https://docs.tigera.io/calico/latest/getting-started/kubernetes/windows-calico/manual-install/standard 에서 최신버전의 Calico 설치 스크립트를 받을 수 있습니다)
* kubernetes-node-windows-amd64(k8s 요소 설치 관련)


* helper.psm1, helper.psm1, hns.psm1(calico install script에서 필요 모듈 파일)

(helperv2 -> https://raw.githubusercontent.com/Microsoft/SDN/master/Kubernetes/windows/helper.v2.psm1

helper -> https://raw.githubusercontent.com/Microsoft/SDN/master/Kubernetes/windows/helper.psm1

hns -> https://github.com/microsoft/SDN/blob/master/Kubernetes/windows/hns.psm1  )

 

* install-calico-windows.ps1(calico install 스크립트)
(https://github.com/projectcalico/calico/blob/master/node/windows-packaging/install-calico-windows.ps1)


* kube config 파일 (calico 설치시 필요)

(마스터의 ~/.kube/config 파일입니다.)

 

위 파일들을 모두 받아 윈도우즈 서버 C:\k 디렉토리로 옮겨둡니다(k 디렉토리가 없으면 생성해줍니다). 

 

사전작업

    1. HNS 서비스 활성화 하기 

Install-WindowsFeature -Name containers
Restart-Computer -Force

위 명령어를 윈도우 powershell에 입력하여 HNS서비스를 활성해 좁니다. 

(인터넷 없어도 실행됩니다)

 

    2. 2개 폴더를 생성해 줍니다. 

mkdir "C:\Program Files\containerd\cni\bin"
mkdir "C:\k"

해당 폴더가 자동으로 생성되지 않으므로 사전에 생성해둠
“C:\k” 폴더는 calico설치시 사용되는 폴더이므로 인터넷에서 받은 파일을 넣어줍니다

 

본작업

 

    1. 7zip.msi를 설치해 줍니다

    2. ContainerD 설치

# *인터넷필요*
# 인터넷에서 해당 파일을 받고 폐쇄망 서버로 옮긴다. 이 단계 이후 설치 방법은 내용을 참조
# [ContainerD버전]를 받은 버전으로 수정한다 
curl.exe -L https://github.com/containerd/containerd/releases/download/v[ContainerD버전]/containerd-[ContainerD버전]-windows-amd64.tar.gz -o containerd-windows-amd64.tar.gz
tar.exe xvf .\containerd-windows-amd64.tar.gz
#____________________________________________________

# Copy and configure
Copy-Item -Path ".\bin" -Destination "$Env:ProgramFiles\containerd" -Recurse -Container:$false -Force
cd $Env:ProgramFiles\containerd\
.\containerd.exe config default | Out-File config.toml -Encoding ascii

# Review the configuration. Depending on setup you may want to adjust:
# - the sandbox_image (Kubernetes pause image)
# - cni bin_dir and conf_dir locations
Get-Content config.toml

# Register and start service
.\containerd.exe --register-service
Start-Service containerd

 

    3. Calico 설치 

        1. Install-calico-windows.ps1 스크립트를 참조하여 인터넷으로 다운로드 받는 파일들을 미리 폐쇄망에 옮겨놓는다 

           -> Line 48, 49, 54, 56, 주석처리한다 [스크립트에 인터넷을 사용하여 다운로드 하는 코드를 주석처리 한다]

           -> 스크립트 처음 Param부분에서 $RelaseFile, $KubeVersion, $ServiceCidr, $DNSServerIPs를 알맞게 수정
         

        2. kubectl 파일 미리 받아둠 - 7zipFM으로 압축풀기 (인스톨스크립트에서 인터넷에서 소스땡겨오는부분 주석처리)
           -> kubernetes-node-windows-amd64.tar 파일을 7zip으로 압축풀어 내용물을 k 폴더에 넣어놓는다

 

        3.  k8s마스터 노드의 /root/.kube/config 파일 k 폴더로 옮기기

        4. calico-windows.zip 파일을 C:\로 옮긴다

        5. Install-calico-windows.ps1 스크립트를 실행한다

        6. CalicoNode, CalicoFelix 서비스가 정상적으로 실행되는지 확인한다

            [Get-Service 명령어 사용]

 

    4. Calico 설치 이후 C:\CalicoWindows\Kubernetes 내부 install-kube-services.ps1 스크립트를 실행하여 Kubelet, kube-proxy 설치 후 실행까지 확인

    5. K8s Master에서 Windows노드가 정상적으로 추가되었는지 확인

 

아쉽게도 아직 윈도우 노드와 빌드가 맞게 생성된 pod이 없어서 pod이 올라가는지 테스트는 못해봤지만 노드가 정상적으로 추가되는것을 확인했습니다. 

 

궁금하시거나 이상하다 하시는점 있으시면 알려주세요!

728x90
728x90

시간은 없고 내용은 많으니 TL;DR 하겠습니다 

 

결론
1. 윈도우즈 노드가 추가되는 시점부터 특정 서비스 사용이 강제된다
   -> 기존 환경과 다를경우 다시 서비스를 구성해야 할수도 있다 
2. 윈도우즈 노드의 k8s 업데이트는 현재 방법이 없다. K8s 업데이트시 다시 설치해야한다. 
3. 윈도우즈 컨테이너를 빌딩할 때 현재 사용중인 노드의 빌드와 맞게 구성해야하므로 윈도우즈 업데이트에 제약이 생긴다

 

제가 설치 및 자료 찾아보면서 느낀 결과이니 반박 시 님들말이 맞습니다ㅎㅎ

 

*2024년 2월 부로 윈도우즈 서버 노드 추가시 제약사항

* 윈도우즈 서버로 컨트롤플레인 구성 불가
* 윈도우 특정 버전만 지원(2019, 2022)
* 윈도우 컨테이너가 리눅스 컨테이너와 함께 동작하지 않음
* 윈도우에서 볼륨 마운트 옵션을 지원하지 않음
* 윈도우에서는 호스트 네트워크 모드를 지원하지 않음

 

사전 체크 사항 
* 유휴 윈도우즈 서버 버전 체크
  - 윈도우즈 서버 버전 체크 (2019, 2022만 가능)
* Host OS 버전이 배포할 Container OS 버전과 일치해야 한다
(예를들어 Node가 2019.17763이면 안의 컨테이너도 2019.17763버전 이어야한다)
* 윈도우즈 업데이트 설치 확인
  - 2019 Server기준 **KB4489899 업데이트 이후 버전이 설치되어있으면 됨.**
* Calico의 Overlay모드를 사용할때 IPIP모드는 지원하지 않는다. 따라서 VXLAN모드로 변경하여 사용해야 한다

 

  윈도우 리눅스
컨테이너 런타임 여러 런타임 지원 ContainerD
리소스 Isolation Cgroups 프로세스 및 Namespace Isolation
Network 기존 네트워크 사용 Host Networking Service(HNS)사용
컨테이너 가벼운용량, 쉬운 컨테이너 개발 무거운 용량, 상대적으로 어려운 컨테이너개발
네트워크 여러 Networking Plugin 사용가능  Calico 혹은 Flannel

 

클러스터 사전작업

    1. 윈도우즈 업데이트 확인

    2. Master Node 에서 CalicoCTL 설치 

        1. curl -O -L https://github.com/projectcalico/calicoctl/releases/download/v3.17.1/calicoctl
        2. chmod +x calicoctl
        3. sudo mv calicoctl /usr/local/bin

    3. IPIP를 VXLAN모드로 변경

calicoctl get ippool default-ipv4-ippool -o wide
calicoctl get ippool default-ipv4-ippool -o yaml | sed -e "s/ipipMode: Always/ipipMode: Never/" | calicoctl apply -f -
calicoctl get ippool default-ipv4-ippool -o yaml | sed -e "s/vxlanMode: Never/vxlanMode: Always/" | calicoctl apply -f -
calicoctl get ippool default-ipv4-ippool -o wide

    4. IPAM 옵션 수정

calicoctl ipam configure --strictaffinity=true

 

    5. 디렉토리 생성 및 Config 복사

       

(윈도우즈 서버에서) mkdir c:\k
(윈도우즈 powershell에서) scp root@[마스터서버]:~/.kube/config c:\k\

참고로 만약 권한 없음으로 scp 가 되지 않을경우 config파일을 일반 유저 권한으로 변경 후 scp 를 실행한다 

 

 

인터넷망에서 윈도우즈 서버 노드 추가방법 - 본 작업

 

모든 작업은 추가하고자 하는 윈도우즈 서버에서 작업한다 

 

    1. ContainerD 설치

Invoke-WebRequest -UseBasicParsing "https://raw.githubusercontent.com/microsoft/Windows-Containers/Main/helpful_tools/Install-ContainerdRuntime/install-containerd-runtime.ps1" -o install-containerd-runtime.ps1

.\install-containerd-runtime.ps1

 

    2. Calico Component 설치 및 구동

Invoke-WebRequest -Uri https://github.com/projectcalico/calico/releases/download/v3.27.0/install-calico-windows.ps1 -OutFile c:\k\install-calico-windows.ps1

    (*Calico 버전은 수정될 수 있다)

c:\k\install-calico-windows.ps1 -ReleaseBaseURL "https://github.com/projectcalico/calico/releases/download/v3.27.0" -ReleaseFile "calico-windows-v3.27.0.zip" -KubeVersion "1.28.2" -DownloadOnly "yes" -ServiceCidr "10.96.0.0/12" -DNSServerIPs "10.96.0.10"

 

    (*Calico 버전은 수정될 수 있다.)

    (* KubeVersion은 현재 클러스터에 설치된 K8s 버전이다)

    (* ServiceCidr과 DNSServerIPs는 클러스터의 Master서버에서 kubectl cluster-info dump > dump.log 명령어 실행후                   dump.log파일을 확인하여 찾을 수 있다 )

 

    환경변수 설정

$ENV:CNI_BIN_DIR="c:\program files\containerd\cni\bin" 
$ENV:CNI_CONF_DIR="c:\program files\containerd\cni\conf" 
c:\calicowindows\install-calico.ps1
c:\calicowindows\start-calico.ps1

 

    Service 실행

Start-Service CalicoFelix
Start-Service CalicoNode
Get-Service CalicoFelix
Get-Service CalicoNode

-> 각각 Running 상태이면 된다

 

    3. 쿠버네티스 컴포넌트 설치 및 구동

c:\calicowindows\kubernetes\install-kube-services.ps1

Start-Service kubelet 
Start-Service kube-proxy
Get-Service Kubelet
Get-Service kube-proxy

-> 각각 Running 상태이면 된다

New-NetFirewallRule -Name 'Kubelet-In-TCP' -DisplayName 'Kubelet (node)' -Enabled True -Direction Inbound -Protocol TCP -Action Allow -LocalPort 10250
(* 위 명령어는 방화벽이 막혀있을때 사용하면 된다)

 

설치는 간단하게 이것으로 끝이다.  실제로 Pod까지 띄울수 있는지 확인해 보자 

 

다음과 YAML을 작성 후 마스터노드에서 실행해보자 

---
apiVersion: v1
kind: Service
metadata:
  name: win-webserver
  labels:
    app: win-webserver
spec:
  ports:
    # the port that this service should serve on
    - port: 80
      targetPort: 80
  selector:
    app: win-webserver
  type: NodePort
---
apiVersion: apps/v1
kind: Deployment
metadata:
  labels:
    app: win-webserver
  name: win-webserver
spec:
  replicas: 2
  selector:
    matchLabels:
      app: win-webserver
  template:
    metadata:
      labels:
        app: win-webserver
      name: win-webserver
    spec:
     containers:
      - name: windowswebserver
        image: mcr.microsoft.com/windows/servercore:ltsc2019
        command:
        - powershell.exe
        - -command
        - "<#code used from https://gist.github.com/19WAS85/5424431#> ; $$listener = New-Object System.Net.HttpListener ; $$listener.Prefixes.Add('http://*:80/') ; $$listener.Start() ; $$callerCounts = @{} ; Write-Host('Listening at http://*:80/') ; while ($$listener.IsListening) { ;$$context = $$listener.GetContext() ;$$requestUrl = $$context.Request.Url ;$$clientIP = $$context.Request.RemoteEndPoint.Address ;$$response = $$context.Response ;Write-Host '' ;Write-Host('> {0}' -f $$requestUrl) ;  ;$$count = 1 ;$$k=$$callerCounts.Get_Item($$clientIP) ;if ($$k -ne $$null) { $$count += $$k } ;$$callerCounts.Set_Item($$clientIP, $$count) ;$$ip=(Get-NetAdapter | Get-NetIpAddress); $$header='<html><body><H1>Windows Container Web Server</H1>' ;$$callerCountsString='' ;$$callerCounts.Keys | % { $$callerCountsString+='<p>IP {0} callerCount {1} ' -f $$ip[1].IPAddress,$$callerCounts.Item($$_) } ;$$footer='</body></html>' ;$$content='{0}{1}{2}' -f $$header,$$callerCountsString,$$footer ;Write-Output $$content ;$$buffer = [System.Text.Encoding]::UTF8.GetBytes($$content) ;$$response.ContentLength64 = $$buffer.Length ;$$response.OutputStream.Write($$buffer, 0, $$buffer.Length) ;$$response.Close() ;$$responseStatus = $$response.StatusCode ;Write-Host('< {0}' -f $$responseStatus)  } ; "
     nodeSelector:
      kubernetes.io/os: windows

 

사진으로 찍진 못했지만 윈도우즈 노드로 Pod이 배치되었을 것이다. 

728x90

'Docker & K8s' 카테고리의 다른 글

Windows 2019 폐쇄망 K8s(Kubernetes) 노드 추가 방법  (0) 2024.02.13
ubuntu 20.04 K8s 설치 노트  (0) 2024.01.08
728x90

OpenStack에서 Ubuntu 20.04위에 Multi-cluster k8s설치 기록입니다

 

1. 도커 설치 

sudo apt-get update
 
sudo apt-get install \
    ca-certificates \
    curl \
    gnupg \
    lsb-release

curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /usr/share/keyrings/docker-archive-keyring.gpg

echo \
  "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/docker-archive-keyring.gpg] https://download.docker.com/linux/ubuntu \
  $(lsb_release -cs) stable" | sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
  
sudo apt-get update
sudo apt-get install docker-ce docker-ce-cli containerd.io

sudo systemctl enable docker
sudo systemctl start docker

 

1.1 설치 이후 containerd설정파일을 수정

vim /etc/containerd/config.toml

이부분을 주석처리 해준다
#disable_plugins = ["cri"]

containerd 재시작

systemctl stop containerd
systemctl start containerd

 

2. K8s 설치

#swap영역을 꺼준다
swapoff -a && sed -i '/swap/s/^/#/' /etc/fstab
#ubuntu 20.04에서는 기본적으로 keyrings폴더가 없어서 다르게 설정함
sudo curl -fsSLo /usr/share/keyrings/kubernetes-archive-keyring.gpg https://dl.k8s.io/apt/doc/apt-key.gpg
echo "deb [signed-by=/usr/share/keyrings/kubernetes-archive-keyring.gpg] https://apt.kubernetes.io/ kubernetes-xenial main" | sudo tee /etc/apt/sources.list.d/kubernetes.list
sudo apt-get update -y
#k8s 설치 및 버전 홀드 명령어
sudo apt-get install -y kubelet kubeadm kubectl
sudo apt-mark hold kubelet kubeadm kubectl

#설치 후 환경설정
 mkdir -p $HOME/.kube
 sudo cp -i /etc/kubernetes/admin.conf $HOME/.kube/config
 sudo chown $(id -u):$(id -g) $HOME/.kube/config

 export KUBECONFIG=/etc/kubernetes/admin.conf

 

3. K8s control-plane 설정

 

sudo kubeadm init --control-plane-endpoint [controlplane IP]:6443 --upload-certs

#이후 다음과같은 URL들이 생성됨

kubeadm join 10.10.10.46:6443 --token ygmu0q.07f8v7xz3ghq6k1t \
	--discovery-token-ca-cert-hash sha256:f16a98dbc6fedbe229f57ba2c8bf531623be8ca8d0f536afb3e363a3ca14f527 \
	--control-plane --certificate-key 3964d2b0dc528a3c2000d51383aa2825197305d31ab935a92069741628e1ca8d
#위 URL은 join할 control-plane node에서 실행해 주면 됨

kubeadm join 10.10.10.46:6443 --token nf8qv9.g3iu91lv102w17yt --discovery-token-ca-cert-hash sha256:f16a98dbc6fedbe229f57ba2c8bf531623be8ca8d0f536afb3e363a3ca14f527
#위 URL은 join할 worker-node 에서 실행해 주면 됨

 

만약 url을 잃어버렸을경우 재 발급 방법

#일반 node join문 재 출력 명령어
kubeadm token create --print-join-command

#Control-plane join문 재 출력 
echo $(kubeadm token create --print-join-command) --control-plane --certificate-key $(kubeadm init phase upload-certs --upload-certs | grep -vw -e certificate -e Namespace)

 

4. Calico 설치

kubectl apply -f https://docs.projectcalico.org/manifests/calico.yaml
#시간좀 지난뒤 Pod 생성 및 구동 확인

kubectl get pods -A

NAMESPACE     NAME                                       READY   STATUS              RESTARTS      AGE
kube-system   calico-kube-controllers-7ddc4f45bc-vhsxq   0/1     ContainerCreating   0             17s
kube-system   calico-node-4rwvz                          0/1     Init:2/3            0             17s
kube-system   calico-node-v7x54                          0/1     Init:2/3            0             17s
kube-system   coredns-5dd5756b68-7q2vm                   0/1     ContainerCreating   0             19m
kube-system   coredns-5dd5756b68-vzr8g                   0/1     ContainerCreating   0             19m
kube-system   etcd-issac-master                          1/1     Running             1             19m
kube-system   etcd-issac-master3                         1/1     Running             0             18m
kube-system   kube-apiserver-issac-master                1/1     Running             1             19m
kube-system   kube-apiserver-issac-master3               1/1     Running             0             18m
kube-system   kube-controller-manager-issac-master       1/1     Running             1 (18m ago)   19m
kube-system   kube-controller-manager-issac-master3      1/1     Running             0             18m
kube-system   kube-proxy-6kd4x                           1/1     Running             0             18m
kube-system   kube-proxy-8f8h9                           1/1     Running             0             19m
kube-system   kube-scheduler-issac-master                1/1     Running             2 (18m ago)   19m
#k8s control-plane node ready상태 확인

kubectl get nodes

root@issac-master:/home/ubuntu# k get nodes
NAME            STATUS   ROLES           AGE   VERSION
issac-master    Ready    control-plane   20m   v1.28.2
issac-master3   Ready    control-plane   18m   v1.28.2
728x90

+ Recent posts