Time Namespaces
Time Namespace를 컨테이너 체크포인트/복원과 시간 일관성 보장 관점에서 심층 분석합니다.
timens_offsets 구조체의 내부 필드와 커널 적용 경로,
vDSO/VVAR 페이지 분리 메커니즘과 clock_gettime() 오프셋 적용 체인,
CRIU 기반 라이브 마이그레이션에서 시간 연속성을 보장하는 원리,
futex/timerfd/nanosleep/timer_create에 대한 시간 네임스페이스 영향,
호스트-게스트 시간 경계 관리, 관측 지표 왜곡을 줄이기 위한 운영 패턴,
디버깅 시 clock_gettime 비교 검증 절차까지 시간 격리가 필요한 컨테이너 환경의 실전 포인트를 다룹니다.
핵심 요약
- 격리 대상 —
CLOCK_MONOTONIC,CLOCK_BOOTTIME및 관련 변종 오프셋 - 비격리 대상 —
CLOCK_REALTIME은 전역 시간 공유 (의도된 설계) - timens_offsets — monotonic과 boottime 각각의 오프셋을 저장하는 핵심 구조체
- vDSO/VVAR — 시스템 콜 없이 userspace에서 시간을 읽을 때도 오프셋 적용 보장
- CRIU — 체크포인트/복원 시 시간 연속성 유지의 핵심 소비자
- frozen — 첫 프로세스 진입 후 오프셋 변경 불가 (일관성 보장)
단계별 이해
- 지원 시계 구분
어떤 clock이 격리되고 어떤 clock은 공유되는지 먼저 표로 고정합니다. - 오프셋 모델 이해
절대 시간을 바꾸는 것이 아니라, monotonic/boottime 읽기값에 오프셋을 더하는 모델임을 확인합니다. - 실습으로 검증
네임스페이스 진입 전후clock_gettime()값을 비교해 격리 동작을 관찰합니다. - 복원 시나리오 연결
CRIU 복원 시 동일한 시간축을 재현해야 하는 이유를 운영 관점에서 정리합니다.
CLOCK_MONOTONIC/CLOCK_BOOTTIME 계열의 관측값을 분리하지만,
CLOCK_REALTIME까지 숨기는 기능은 아닙니다. 따라서 "시간 오프셋 재현"과 "wall clock 은닉"은 별개의 요구사항으로 다뤄야 합니다.
개요
Time Namespace는 Linux 5.6(2020년 3월)에서 도입된 8번째 namespace 타입으로,
CLOCK_MONOTONIC과 CLOCK_BOOTTIME 시계의 오프셋을 프로세스별로 격리합니다.
커널 소스에서는 kernel/time/namespace.c(약 500줄)에 핵심 로직이 집중되어 있습니다.
도입 배경
| 동기 | 문제 | Time Namespace 해결 |
|---|---|---|
| CRIU 복원 | Host B에서 복원 시 monotonic clock이 불연속 | 오프셋으로 원래 값 재현 |
| 컨테이너 격리 | 모든 컨테이너가 호스트의 uptime을 공유 | 컨테이너별 독립 uptime 제공 |
| 시간 의존 테스트 | 미래 시간 시뮬레이션 불가 | monotonic 오프셋으로 시간 앞당기기/되돌리기 |
| 보안 | 호스트 부팅 시간이 컨테이너에 노출 | 오프셋으로 실제 부팅 시간 은닉 |
지원하는 Clock 상세
| Clock ID | 설명 | Time NS 영향 | 오프셋 소스 |
|---|---|---|---|
CLOCK_REALTIME |
UTC 기반 실시간 (wall clock) | 격리 안 됨 | 없음 |
CLOCK_MONOTONIC |
부팅 이후 단조 증가 (suspend 제외) | 격리됨 | offsets.monotonic |
CLOCK_MONOTONIC_RAW |
NTP 조정 없는 monotonic | 격리됨 | offsets.monotonic |
CLOCK_MONOTONIC_COARSE |
낮은 정밀도 monotonic | 격리됨 | offsets.monotonic |
CLOCK_BOOTTIME |
부팅 이후 (suspend 포함) | 격리됨 | offsets.boottime |
CLOCK_BOOTTIME_ALARM |
부팅 시간 기반 알람 | 격리됨 | offsets.boottime |
CLOCK_REALTIME_ALARM |
실시간 기반 알람 | 격리 안 됨 | 없음 |
CLOCK_PROCESS_CPUTIME_ID |
프로세스 CPU 시간 | 격리 안 됨 | 없음 |
CLOCK_THREAD_CPUTIME_ID |
스레드 CPU 시간 | 격리 안 됨 | 없음 |
CLOCK_REALTIME은 time namespace의 영향을 받지 않습니다.
이는 의도된 설계입니다 -- 파일 타임스탬프, TLS 인증서 검증, 네트워크 프로토콜 등이 전역적으로 일관된 wall clock을 필요로 하기 때문입니다.
아키텍처
time_namespace 구조체
/* kernel/time/namespace.c — Linux 6.x */
struct time_namespace {
struct user_namespace *user_ns; /* 소유 user namespace */
struct ucounts *ucounts; /* namespace 카운트 */
struct ns_common ns; /* 공통 namespace 정보 */
struct timens_offsets offsets; /* clock 오프셋 (핵심) */
struct page *vvar_page; /* namespace별 VVAR 페이지 */
bool frozen_offsets; /* 오프셋 변경 가능 여부 */
};
/* 오프셋 구조체 */
struct timens_offsets {
struct timespec64 monotonic; /* CLOCK_MONOTONIC 오프셋 */
struct timespec64 boottime; /* CLOCK_BOOTTIME 오프셋 */
};
/* timespec64: 64비트 시간 표현 */
struct timespec64 {
time64_t tv_sec; /* 초 (음수 가능 = 과거 오프셋) */
long tv_nsec; /* 나노초 (0 ~ 999,999,999) */
};
Task 구조체 통합
struct nsproxy {
struct uts_namespace *uts_ns;
struct ipc_namespace *ipc_ns;
struct mnt_namespace *mnt_ns;
struct pid_namespace *pid_ns_for_children;
struct net *net_ns;
struct cgroup_namespace *cgroup_ns;
struct time_namespace *time_ns; /* 현재 time NS */
struct time_namespace *time_ns_for_children; /* 자식용 time NS */
};
/* unshare(CLONE_NEWTIME) 호출 시:
* 1. 새 time_namespace 할당
* 2. current->nsproxy->time_ns_for_children = new_ns
* 3. current->nsproxy->time_ns는 변경되지 않음 (부모는 기존 NS 유지)
* 4. fork() 후 자식의 time_ns = time_ns_for_children
*/
unshare(CLONE_NEWTIME) 호출 프로세스 자체는 새 time namespace에 들어가지 않습니다.
자식 프로세스만 새 namespace에 진입합니다. 이는 실행 중인 프로세스의 VDSO 페이지 매핑을 안전하게 전환할 수 없기 때문입니다.
Clock Offset 관리
timens_offsets 파일
Time namespace의 오프셋은 /proc/self/timens_offsets 파일로 설정합니다.
이 파일은 proc_timens_offsets_operations에 의해 관리됩니다.
# 파일 형식: clock_name seconds nanoseconds
$ cat /proc/self/timens_offsets
monotonic 0 0
boottime 0 0
# 오프셋 설정 (frozen 전에만 가능)
$ echo "monotonic 1000 0" > /proc/self/timens_offsets
$ echo "boottime 2000 500000000" >> /proc/self/timens_offsets
오프셋 쓰기 커널 경로
오프셋 적용 시점과 조건
| 단계 | 동작 | 상태 |
|---|---|---|
| 1. unshare(CLONE_NEWTIME) | 새 time_namespace 생성, time_ns_for_children에 할당 | frozen=false |
| 2. write timens_offsets | 오프셋 값 설정 (여러 번 덮어쓰기 가능) | frozen=false |
| 3. fork() | 자식이 새 time_ns로 진입, VVAR 페이지 매핑 | frozen=true (이 시점부터) |
| 4. 이후 write 시도 | -EACCES (Device or resource busy) |
frozen=true |
/* 커널에서 frozen 체크하는 코드 */
static ssize_t timens_offsets_write(struct file *file,
const char __user *buf, size_t count, loff_t *ppos)
{
struct time_namespace *ns = current->nsproxy->time_ns_for_children;
/* 이미 frozen이면 쓰기 거부 */
if (ns->frozen_offsets)
return -EACCES;
/* "monotonic sec nsec" 또는 "boottime sec nsec" 파싱 */
...
return count;
}
timens_offsets 구현 상세
timens_offsets 구조체는 매우 단순하지만, 커널 전반에 걸쳐 다양한 위치에서 참조됩니다.
오프셋이 적용되는 정확한 코드 경로와 조건을 분석합니다.
/* kernel/time/namespace.c — 오프셋 적용 헬퍼 함수들 */
/* monotonic 시간에 오프셋 추가 */
void timens_add_monotonic(struct timespec64 *ts)
{
struct timens_offsets *ns_offsets;
/* init namespace에서는 오프셋 0 → 빠른 경로 */
if (likely(!timens_offset_exists(current)))
return;
ns_offsets = ¤t->nsproxy->time_ns->offsets;
*ts = timespec64_add(*ts, ns_offsets->monotonic);
}
/* boottime 시간에 오프셋 추가 */
void timens_add_boottime(struct timespec64 *ts)
{
struct timens_offsets *ns_offsets;
if (likely(!timens_offset_exists(current)))
return;
ns_offsets = ¤t->nsproxy->time_ns->offsets;
*ts = timespec64_add(*ts, ns_offsets->boottime);
}
/* 절대 시간을 호스트 시간으로 역변환 (타이머/sleep) */
ktime_t timens_ktime_to_host(clockid_t clockid, ktime_t tim)
{
struct time_namespace *ns;
struct timens_offsets *offsets;
ktime_t offset;
ns = current->nsproxy->time_ns;
if (likely(ns == &init_time_ns))
return tim;
offsets = &ns->offsets;
switch (clockid) {
case CLOCK_MONOTONIC:
case CLOCK_MONOTONIC_RAW:
case CLOCK_MONOTONIC_COARSE:
offset = timespec64_to_ktime(offsets->monotonic);
break;
case CLOCK_BOOTTIME:
case CLOCK_BOOTTIME_ALARM:
offset = timespec64_to_ktime(offsets->boottime);
break;
default:
return tim; /* REALTIME 등은 변환 없음 */
}
/* NS 시간 - 오프셋 = 호스트 시간 */
return ktime_sub(tim, offset);
}
/* 빠른 경로 체크: init NS인지 확인 */
static inline bool timens_offset_exists(
struct task_struct *task)
{
return task->nsproxy->time_ns != &init_time_ns;
}
procfs 인터페이스 커널 구현
/* proc_timens_offsets_write() — 오프셋 쓰기 전체 경로 */
static ssize_t proc_timens_offsets_write(
struct file *filp,
const char __user *ubuf,
size_t count, loff_t *ppos)
{
struct proc_timens_offset offsets[2];
char *kbuf;
int noffsets, ret;
/* 대상 프로세스의 time_ns_for_children 가져오기 */
struct time_namespace *ns =
current->nsproxy->time_ns_for_children;
/* 이미 frozen이면 거부 */
if (ns->frozen_offsets)
return -EACCES;
/* 권한 확인: CAP_SYS_TIME 필요 */
if (!ns_capable(ns->user_ns, CAP_SYS_TIME))
return -EPERM;
/* 사용자 공간에서 문자열 복사 */
kbuf = memdup_user_nul(ubuf, count);
/* "monotonic sec nsec" 또는 "boottime sec nsec" 파싱 */
noffsets = parse_timens_offsets(kbuf, offsets, ARRAY_SIZE(offsets));
/* 값 검증 */
for (int i = 0; i < noffsets; i++) {
/* nsec 범위: 0 ~ 999,999,999 */
if (offsets[i].val.tv_nsec < 0 ||
offsets[i].val.tv_nsec >= NSEC_PER_SEC)
return -EINVAL;
}
/* 오프셋 적용 */
mutex_lock(&offset_lock);
for (int i = 0; i < noffsets; i++) {
switch (offsets[i].clockid) {
case CLOCK_MONOTONIC:
ns->offsets.monotonic = offsets[i].val;
break;
case CLOCK_BOOTTIME:
ns->offsets.boottime = offsets[i].val;
break;
}
}
mutex_unlock(&offset_lock);
return count;
}
오프셋 동결(frozen) 메커니즘
오프셋 동결은 첫 번째 프로세스가 새 time namespace에 진입하는 시점에 자동으로 발생합니다.
구체적으로 timens_on_fork()에서 frozen_offsets가 true로 설정됩니다.
/* fork() 시 time namespace 처리 */
void timens_on_fork(struct nsproxy *nsproxy,
struct task_struct *tsk)
{
struct time_namespace *ns =
nsproxy->time_ns_for_children;
/* 현재 NS와 자식용 NS가 다르면 = 새 NS로 전환 */
if (nsproxy->time_ns == ns)
return;
get_time_ns(ns);
put_time_ns(nsproxy->time_ns);
nsproxy->time_ns = ns;
/* 오프셋 동결: 이후 쓰기 불가 */
ns->frozen_offsets = true;
/* VVAR 페이지 설정: vDSO에서 오프셋 접근 가능하게 */
timens_commit(ns);
}
frozen_offsets는 한번 true가 되면 되돌릴 수 없습니다.
새로운 오프셋이 필요하면 새 time namespace를 생성해야 합니다.
이 설계는 이미 NS에 진입한 프로세스의 시간이 갑자기 바뀌는 것을 방지합니다.
System Call 통합
clock_gettime() 오프셋 적용 체인
/* 커널 내부 오프셋 적용 흐름 (간략화) */
/* 1. 시스템 콜 진입 */
SYSCALL_DEFINE2(clock_gettime, const clockid_t, which_clock,
struct __kernel_timespec __user *, tp)
{
struct timespec64 kernel_tp;
kc->clock_get_timespec(which_clock, &kernel_tp);
/* 여기서 오프셋이 이미 적용됨 */
return put_timespec64(&kernel_tp, tp);
}
/* 2. posix clock 구현에서 timens 오프셋 적용 */
static int posix_get_monotonic_timespec(clockid_t which_clock,
struct timespec64 *tp)
{
ktime_get_ts64(tp); /* 실제 하드웨어 시간 */
timens_add_monotonic(tp); /* 오프셋 적용 */
return 0;
}
/* 3. 오프셋 적용 함수 */
void timens_add_monotonic(struct timespec64 *ts)
{
struct timens_offsets *ns_offsets =
¤t->nsproxy->time_ns->offsets;
*ts = timespec64_add(*ts, ns_offsets->monotonic);
}
/* boottime도 동일한 패턴 */
void timens_add_boottime(struct timespec64 *ts)
{
*ts = timespec64_add(*ts,
current->nsproxy->time_ns->offsets.boottime);
}
영향받는 시스템 콜
| 시스템 콜 | 영향 | 오프셋 적용 위치 |
|---|---|---|
clock_gettime() | MONOTONIC/BOOTTIME 계열 오프셋 적용 | timens_add_monotonic/boottime() |
clock_nanosleep() | 절대 시간 지정 시 오프셋 역변환 | timens_ktime_to_host() |
timerfd_settime() | 절대 타이머 시 오프셋 역변환 | timens_ktime_to_host() |
timer_settime() | POSIX 타이머 절대 시간 역변환 | timens_ktime_to_host() |
clock_getres() | 영향 없음 (정밀도는 동일) | 없음 |
/proc/uptime | 오프셋 적용된 monotonic 표시 | proc_uptime_show() |
/proc/timer_list | 호스트 시간 기준 (오프셋 미적용) | 없음 |
clock_nanosleep(CLOCK_MONOTONIC, TIMER_ABSTIME, ...)처럼 절대 시간을 지정하는 경우,
커널은 timens_ktime_to_host()를 통해 namespace 시간을 호스트 시간으로 역변환합니다.
이렇게 해야 올바른 시점에 타이머가 만료됩니다.
vDSO 통합
vDSO 문제점
clock_gettime()은 성능을 위해 vDSO(Virtual Dynamic Shared Object)를 통해 userspace에서 직접 실행됩니다.
시스템 콜을 거치지 않으므로, time namespace 오프셋도 vDSO 코드가 직접 접근할 수 있어야 합니다.
이를 해결하기 위해 namespace별 별도의 VVAR 페이지를 사용합니다.
VVAR 페이지 분리 메커니즘
/* vDSO 내부에서 시간 읽기 (간략화) */
static int __vdso_clock_gettime_common(
const struct vdso_data *vd,
clockid_t clk, struct __kernel_timespec *ts)
{
/* vd는 프로세스별 매핑된 VVAR 페이지를 가리킴
* init NS: 글로벌 vdso_data
* custom NS: namespace별 vdso_data (오프셋 포함)
*/
switch (clk) {
case CLOCK_MONOTONIC:
/* VVAR 데이터에서 직접 읽기 (이미 오프셋 반영됨) */
do_monotonic(vd, ts);
break;
case CLOCK_BOOTTIME:
do_boottime(vd, ts);
break;
...
}
}
/* 커널 측: VVAR 페이지 업데이트 */
static void timens_setup_vvar_page(struct time_namespace *ns)
{
struct vdso_data *vdata;
/* namespace별 VVAR 페이지 할당 */
ns->vvar_page = alloc_page(GFP_KERNEL_ACCOUNT | __GFP_ZERO);
/* 오프셋 반영 데이터 복사 */
vdata = page_address(ns->vvar_page);
timens_update_vvar(vdata, ns);
}
vDSO 성능 영향
| 항목 | init time NS | custom time NS | 차이 |
|---|---|---|---|
| clock_gettime() 경로 | vDSO (글로벌 VVAR) | vDSO (NS VVAR) | 동일 |
| 메모리 오버헤드 | 없음 | +1 page (4KB) per NS | 미미 |
| fork() 비용 | 기본 | VVAR 페이지 재매핑 | 약간 증가 |
| context switch | 기본 | 기본 (VVAR은 프로세스별) | 없음 |
| 호출 속도 | 약 20~30ns | 약 20~30ns | 차이 없음 |
clock_gettime() 호출 성능에는 실질적인 차이가 없습니다.
추가 비용은 namespace 생성 시 VVAR 페이지 할당(1회)과 fork 시 재매핑뿐입니다.
아키텍처별 vDSO 구현
vDSO의 time namespace 지원은 아키텍처별로 다르게 구현됩니다.
x86_64에서는 arch/x86/entry/vdso/에, ARM64에서는 arch/arm64/kernel/vdso/에 위치합니다.
/* x86_64 vDSO: __vdso_clock_gettime() 구현 (간략화)
* arch/x86/entry/vdso/vclock_gettime.c */
notrace int __vdso_clock_gettime(
clockid_t clock,
struct __kernel_timespec *ts)
{
/* VVAR 페이지에서 vdso_data 가져오기
* init NS: 글로벌 vdso_data 직접 참조
* custom NS: NS별 매핑된 VVAR 페이지 참조 */
const struct vdso_data *vd = __arch_get_vdso_data();
/* 시계 종류에 따라 오프셋이 이미 반영된 데이터 읽기 */
return __cvdso_clock_gettime_common(vd, clock, ts);
}
/* VVAR 페이지 매핑 — arch/x86/entry/vdso/vma.c */
static vm_fault_t vvar_fault(const struct vm_special_mapping *sm,
struct vm_area_struct *vma,
struct vm_fault *vmf)
{
struct page *timens_page =
find_timens_vvar_page(vma);
if (timens_page) {
/* Custom time NS: NS별 VVAR 페이지 사용 */
vmf->page = timens_page;
} else {
/* Init time NS: 글로벌 VVAR 페이지 사용 */
vmf->page = virt_to_page(&vdso_data);
}
get_page(vmf->page);
return 0;
}
VVAR 페이지 업데이트 메커니즘
커널의 timekeeping 시스템이 시간을 갱신할 때, NS별 VVAR 페이지도 함께 갱신해야 합니다.
이 과정은 update_vsyscall()에서 처리됩니다.
/* timekeeping 갱신 시 VVAR 페이지 업데이트
* kernel/time/vsyscall.c */
void update_vsyscall(struct timekeeper *tk)
{
struct vdso_data *vdata = __arch_get_k_vdso_data();
/* 글로벌 VVAR 데이터 갱신 (init NS) */
vdso_write_begin(vdata);
vdata[CS_HRES_COARSE].basetime[CLOCK_MONOTONIC] =
tk_to_timespec64(tk, CLOCK_MONOTONIC);
vdata[CS_HRES_COARSE].basetime[CLOCK_BOOTTIME] =
tk_to_timespec64(tk, CLOCK_BOOTTIME);
vdso_write_end(vdata);
/* 각 NS별 VVAR 페이지 업데이트
* timens_commit()에서 설정한 NS 페이지에
* 오프셋이 반영된 값을 기록 */
update_timens_vvar();
}
/* NS별 VVAR 데이터에 오프셋 반영 */
static void timens_setup_vvar_data(
struct vdso_data *vdata,
struct time_namespace *ns)
{
/* 글로벌 데이터 복사 */
memcpy(vdata, __arch_get_k_vdso_data(),
sizeof(*vdata) * CS_BASES);
/* MONOTONIC 계열: monotonic 오프셋 적용 */
vdata[CS_HRES_COARSE].basetime[CLOCK_MONOTONIC] =
timespec64_add(
vdata[CS_HRES_COARSE].basetime[CLOCK_MONOTONIC],
ns->offsets.monotonic);
/* BOOTTIME 계열: boottime 오프셋 적용 */
vdata[CS_HRES_COARSE].basetime[CLOCK_BOOTTIME] =
timespec64_add(
vdata[CS_HRES_COARSE].basetime[CLOCK_BOOTTIME],
ns->offsets.boottime);
}
seqcount 기반 동기화를 사용합니다. vDSO 코드는 vdso_read_begin()/vdso_read_retry()로
읽기 중 갱신이 발생했는지 감지하고, 발생했으면 재시도합니다. 이 메커니즘은 lock-free이므로 성능 영향이 없습니다.
CRIU 통합
CRIU (Checkpoint/Restore In Userspace)
CRIU는 실행 중인 프로세스/컨테이너를 디스크에 저장(checkpoint)하고 다른 호스트에서 복원(restore)하는 도구입니다. Time namespace는 CRIU의 핵심 요구사항인 시간 연속성을 충족하기 위해 설계되었습니다.
CRIU 복원 흐름
/* CRIU 복원 시 time namespace 설정 순서 (개념 코드) */
/* 1. 새 time namespace 생성 */
unshare(CLONE_NEWTIME);
/* 2. 체크포인트 이미지에서 원본 시간 읽기 */
time64_t saved_monotonic = 500; /* checkpoint 시점의 monotonic */
time64_t saved_boottime = 500; /* checkpoint 시점의 boottime */
/* 3. 현재 호스트 시간 확인 */
struct timespec now;
clock_gettime(CLOCK_MONOTONIC, &now); /* 호스트: 8000s */
/* 4. 오프셋 계산 및 설정 */
int fd = open("/proc/self/timens_offsets", O_WRONLY);
char buf[128];
snprintf(buf, sizeof(buf), "monotonic %lld 0\n",
saved_monotonic - now.tv_sec); /* 500 - 8000 = -7500 */
write(fd, buf, strlen(buf));
snprintf(buf, sizeof(buf), "boottime %lld 0\n",
saved_boottime - now.tv_sec);
write(fd, buf, strlen(buf));
close(fd);
/* 5. 복원 프로세스 생성 (새 time NS에 진입) */
pid_t child = fork();
if (child == 0) {
/* 자식: clock_gettime(MONOTONIC) = 500s (원래 값) */
restore_process_state(...);
}
Checkpoint/Restore 시나리오 상세
# 1. Source Host에서 컨테이너 실행 중
$ docker exec container1 cat /proc/uptime
500.123 1800.456
# 2. Checkpoint
$ sudo criu dump -t $(docker inspect -f '{{.State.Pid}}' container1) \
-D /tmp/checkpoint --shell-job --tcp-established
# 3. 이미지 전송
$ rsync -avz /tmp/checkpoint/ dest-host:/tmp/checkpoint/
# 4. Destination Host에서 복원
$ sudo criu restore -D /tmp/checkpoint --shell-job --tcp-established
# 5. 복원 확인 -- uptime이 연속됨
$ docker exec container1 cat /proc/uptime
502.789 1802.345 # checkpoint 시점 + 경과 시간
--time-namespace 옵션으로 명시적으로 활성화할 수 있으며, 최신 버전에서는 자동 감지합니다.
CRIU 라이브 마이그레이션 연동
라이브 마이그레이션에서는 다운타임을 최소화하면서 프로세스/컨테이너를 다른 호스트로 이전합니다. Time namespace는 이 과정에서 시간 연속성을 보장하는 핵심 역할을 합니다.
CRIU 오프셋 계산 알고리즘
/* CRIU 복원 시 time namespace 오프셋 계산 알고리즘 */
struct criu_timens_data {
struct timespec64 saved_monotonic; /* checkpoint 시 값 */
struct timespec64 saved_boottime; /* checkpoint 시 값 */
time64_t transfer_delay; /* 전송 지연 보정 */
};
int criu_setup_timens(struct criu_timens_data *data)
{
struct timespec64 host_mono, host_boot;
int64_t mono_offset, boot_offset;
/* 1. 현재 호스트 시간 측정 */
clock_gettime(CLOCK_MONOTONIC, &host_mono);
clock_gettime(CLOCK_BOOTTIME, &host_boot);
/* 2. 전송 지연 보정 포함 오프셋 계산 */
mono_offset = (data->saved_monotonic.tv_sec +
data->transfer_delay) -
host_mono.tv_sec;
boot_offset = (data->saved_boottime.tv_sec +
data->transfer_delay) -
host_boot.tv_sec;
/* 3. unshare(CLONE_NEWTIME) */
unshare(CLONE_NEWTIME);
/* 4. 오프셋 설정 */
int fd = open("/proc/self/timens_offsets", O_WRONLY);
dprintf(fd, "monotonic %lld %ld\n",
mono_offset, data->saved_monotonic.tv_nsec);
dprintf(fd, "boottime %lld %ld\n",
boot_offset, data->saved_boottime.tv_nsec);
close(fd);
/* 5. fork() 후 자식에서 프로세스 복원 */
return 0;
}
/* 주의: 전송 지연(transfer_delay)은 pre-dump 기간과
* 최종 dump~restore 사이의 시간을 포함해야 합니다.
* 이 값이 정확하지 않으면 복원된 프로세스의 타이머가
* 약간 빠르거나 느리게 동작할 수 있습니다. */
- CLOCK_REALTIME 불일치: 소스와 대상 호스트의 wall clock이 NTP로 동기화되어 있어야 합니다
- 전송 지연 보정: dump~restore 사이의 시간을 오프셋에 반영해야 타이머가 정확합니다
- 다운타임 최소화: pre-dump으로 메모리를 사전 복사하고, 최종 dump는 dirty 페이지만 전송
- TCP 연결 유지:
--tcp-established옵션으로 TCP 상태 보존 시 시간 연속성이 중요
futex/timerfd/nanosleep 동작
timerfd와 time namespace
timerfd는 MONOTONIC/BOOTTIME 시계를 사용하는 파일 디스크립터 기반 타이머입니다.
Time namespace에서 timerfd가 올바르게 동작하려면, 절대 시간 지정 시 오프셋 역변환이 필요합니다.
#include <sys/timerfd.h>
/* timerfd + time namespace 동작 */
int tfd = timerfd_create(CLOCK_MONOTONIC, 0);
/* 상대 시간: 오프셋 영향 없음 (5초 후) */
struct itimerspec rel = {
.it_value = { .tv_sec = 5, .tv_nsec = 0 },
};
timerfd_settime(tfd, 0, &rel, NULL);
/* 정확히 5초 후 만료 (모든 NS에서 동일) */
/* 절대 시간: 오프셋 역변환 적용 */
struct timespec now;
clock_gettime(CLOCK_MONOTONIC, &now); /* NS 시간: 6000s (호스트: 1000s, offset: +5000) */
struct itimerspec abs = {
.it_value = { .tv_sec = now.tv_sec + 10, .tv_nsec = 0 },
/* NS 시간 6010s 에 만료하고 싶음
* 커널은 이를 호스트 시간 1010s로 역변환 (6010 - 5000 = 1010)
* 실제로 호스트 monotonic이 1010에 도달하면 만료 */
};
timerfd_settime(tfd, TFD_TIMER_ABSTIME, &abs, NULL);
futex와 time namespace
FUTEX_WAIT_BITSET에 FUTEX_CLOCK_REALTIME이 아닌 MONOTONIC 기반 절대 타임아웃을 사용하면 time namespace 오프셋이 적용됩니다.
/* futex의 time namespace 영향 */
/* 상대 타임아웃: 영향 없음 */
struct timespec timeout = { .tv_sec = 5, .tv_nsec = 0 };
futex(addr, FUTEX_WAIT, val, &timeout, NULL, 0);
/* 정확히 5초 대기 */
/* 절대 타임아웃 (CLOCK_MONOTONIC): 오프셋 적용 */
struct timespec abs_time;
clock_gettime(CLOCK_MONOTONIC, &abs_time);
abs_time.tv_sec += 5;
futex(addr, FUTEX_WAIT_BITSET, val, &abs_time, NULL, FUTEX_BITSET_MATCH_ANY);
/* 커널이 절대 시간을 호스트 시간으로 역변환 후 대기 */
nanosleep/clock_nanosleep
| 함수 | 모드 | Time NS 영향 |
|---|---|---|
nanosleep() | 상대 시간만 | 영향 없음 |
clock_nanosleep(MONOTONIC, 0, ...) | 상대 | 영향 없음 |
clock_nanosleep(MONOTONIC, TIMER_ABSTIME, ...) | 절대 | 오프셋 역변환 적용 |
clock_nanosleep(BOOTTIME, TIMER_ABSTIME, ...) | 절대 | 오프셋 역변환 적용 |
clock_nanosleep(REALTIME, TIMER_ABSTIME, ...) | 절대 | 영향 없음 |
sleep(5), nanosleep({5,0}))는 time namespace의 영향을 받지 않습니다.
오프셋은 오직 절대 시간 기반 타이머/대기에만 역변환이 적용됩니다.
POSIX 타이머와 time namespace
timer_create()로 생성한 POSIX 타이머도 time namespace의 영향을 받습니다.
절대 시간 지정 시 오프셋 역변환이 적용되며, 커널 내부에서 timens_ktime_to_host()가 호출됩니다.
/* POSIX 타이머 + time namespace 동작 예시 */
#include <signal.h>
#include <time.h>
timer_t timerid;
struct sigevent sev = {
.sigev_notify = SIGEV_SIGNAL,
.sigev_signo = SIGALRM,
};
/* MONOTONIC 기반 타이머 생성 */
timer_create(CLOCK_MONOTONIC, &sev, &timerid);
/* 상대 시간: 오프셋 영향 없음 */
struct itimerspec its_rel = {
.it_value = { .tv_sec = 10, .tv_nsec = 0 },
.it_interval = { .tv_sec = 1, .tv_nsec = 0 },
};
timer_settime(timerid, 0, &its_rel, NULL);
/* 정확히 10초 후 첫 번째 만료, 이후 1초 간격 */
/* 절대 시간: 오프셋 역변환 적용 */
struct timespec now;
clock_gettime(CLOCK_MONOTONIC, &now);
struct itimerspec its_abs = {
.it_value = {
.tv_sec = now.tv_sec + 30, /* NS 기준 30초 후 */
.tv_nsec = 0,
},
};
timer_settime(timerid, TIMER_ABSTIME, &its_abs, NULL);
/* 커널이 NS 시간을 호스트 시간으로 역변환 후
* 호스트 MONOTONIC 기준으로 hrtimer 설정 */
타이머 커널 경로 분석
/* do_timer_settime() 내 time namespace 처리
* kernel/time/posix-timers.c (간략화) */
static int do_timer_settime(timer_t timer_id,
int tmr_flags,
struct itimerspec64 *new_spec64,
struct itimerspec64 *old_spec64)
{
struct k_itimer *timr;
timr = lock_timer(timer_id, &flags);
/* 절대 시간 + MONOTONIC/BOOTTIME이면 오프셋 역변환 */
if (tmr_flags & TIMER_ABSTIME) {
ktime_t exp = timespec64_to_ktime(new_spec64->it_value);
/* NS 시간 → 호스트 시간 변환 */
exp = timens_ktime_to_host(timr->it_clock, exp);
new_spec64->it_value = ktime_to_timespec64(exp);
}
/* hrtimer에 호스트 시간 기준으로 설정 */
common_timer_set(timr, tmr_flags, new_spec64);
return 0;
}
futex2와 time namespace
Linux 5.16에서 도입된 futex_waitv() 시스템 콜도 time namespace를 지원합니다.
절대 타임아웃 지정 시 오프셋 역변환이 적용됩니다.
/* futex_waitv + time namespace 동작 */
struct futex_waitv waiters[2] = {
{ .val = 0, .uaddr = (__u64)&futex1,
.flags = FUTEX_32 | FUTEX_PRIVATE_FLAG },
{ .val = 0, .uaddr = (__u64)&futex2,
.flags = FUTEX_32 | FUTEX_PRIVATE_FLAG },
};
struct timespec timeout;
clock_gettime(CLOCK_MONOTONIC, &timeout);
timeout.tv_sec += 10; /* NS 시간 기준 10초 후 */
/* futex_waitv는 절대 시간 지정 시
* 커널 내부에서 timens_ktime_to_host() 호출 */
syscall(SYS_futex_waitv, waiters, 2,
0, &timeout, CLOCK_MONOTONIC);
- 영향받는 것: clock_gettime(MONOTONIC/BOOTTIME), 절대 시간 지정 타이머/sleep, /proc/uptime
- 영향받지 않는 것: 상대 시간 sleep, CLOCK_REALTIME, CLOCK_PROCESS_CPUTIME_ID, CPU 시간, perf 타임스탬프
- 역변환 필요: 절대 시간 지정 시에만 (TIMER_ABSTIME, TFD_TIMER_ABSTIME)
시간 네임스페이스와 컨테이너 런타임
컨테이너 런타임별 지원 현황
| 런타임/도구 | Time NS 지원 | 버전 | 설정 방법 |
|---|---|---|---|
| runc | 지원 | 1.1+ | OCI spec linux.namespaces |
| crun | 지원 | 1.0+ | OCI spec (C 구현) |
| containerd | 부분 지원 | 1.6+ | OCI spec 전달 |
| Docker | 미지원 (기본) | - | CRIU checkpoint만 |
| Podman | 부분 지원 | 4.0+ | OCI spec 직접 작성 |
| Kubernetes | 실험적 | 1.25+ | CRI + checkpoint |
| LXC/LXD | 지원 | 4.0+ | 설정 파일 |
| systemd-nspawn | 미지원 | - | - |
OCI Runtime Spec 통합
/* OCI Runtime Spec에서 time namespace 설정
* config.json (JSON 형식) */
# OCI config.json 예시 (time namespace 포함)
# {
# "linux": {
# "namespaces": [
# { "type": "pid" },
# { "type": "network" },
# { "type": "mount" },
# { "type": "ipc" },
# { "type": "uts" },
# { "type": "cgroup" },
# { "type": "time" }
# ],
# "timeOffsets": {
# "monotonic": { "secs": 1000, "nanosecs": 0 },
# "boottime": { "secs": 2000, "nanosecs": 0 }
# }
# }
# }
# runc에서 직접 실행
$ sudo runc create --bundle /path/to/bundle container1
$ sudo runc start container1
# 컨테이너 내부에서 확인
$ runc exec container1 cat /proc/uptime
1000.123 ... # monotonic offset 반영
LXC/LXD에서 time namespace
# LXC 설정 파일에서 time namespace 활성화
# /var/lib/lxc/mycontainer/config
lxc.namespace.time = ""
# LXD에서 time namespace 사용
$ lxc config set mycontainer raw.lxc "lxc.namespace.time ="
$ lxc restart mycontainer
# 확인
$ lxc exec mycontainer -- cat /proc/uptime
$ lxc exec mycontainer -- readlink /proc/self/ns/time
Kubernetes Checkpoint/Restore
Kubernetes 1.25부터 컨테이너 체크포인트 기능이 알파로 도입되었습니다. CRI (Container Runtime Interface)를 통해 CRIU를 호출하며, time namespace 오프셋은 자동으로 처리됩니다.
# Kubernetes 컨테이너 체크포인트 (알파 기능, 1.25+)
# Feature gate 활성화 필요: ContainerCheckpoint=true
# 1. 체크포인트 생성
$ kubectl checkpoint pod/myapp --container=main \
--timeout=30s
# 2. 체크포인트 이미지 확인
$ ls /var/lib/kubelet/checkpoints/
checkpoint-myapp-main-2024-01-15T10:30:00Z.tar
# 3. 다른 노드에서 복원 (Pod 생성 시 지정)
# apiVersion: v1
# kind: Pod
# metadata:
# annotations:
# io.kubernetes.cri.checkpoint-restore: "true"
# spec:
# containers:
# - name: main
# image: checkpoint-myapp-main:latest
# crictl을 통한 직접 체크포인트 (CRI 레벨)
$ crictl checkpoint --export=/tmp/cp.tar $CONTAINER_ID
$ crictl create --restore=/tmp/cp.tar $POD_ID config.json
- CRIU 기반 체크포인트/복원에서는 time namespace가 자동으로 설정됩니다
- 수동으로 오프셋을 설정하려면 OCI spec의
timeOffsets필드를 사용합니다 - 모니터링 에이전트가 컨테이너 내부에서 실행되면 MONOTONIC 기반 지표가 오프셋 적용됩니다
- Prometheus
node_boot_time_seconds는 time namespace 영향을 받으므로 주의
예제 코드
Time Namespace 생성
#define _GNU_SOURCE
#include <sched.h>
#include <stdio.h>
#include <stdlib.h>
#include <time.h>
#include <unistd.h>
#include <fcntl.h>
#include <string.h>
#include <sys/wait.h>
void print_clocks(const char *label)
{
struct timespec mono, boot;
clock_gettime(CLOCK_MONOTONIC, &mono);
clock_gettime(CLOCK_BOOTTIME, &boot);
printf("[%s] MONOTONIC=%ld.%09ld BOOTTIME=%ld.%09ld\n",
label, mono.tv_sec, mono.tv_nsec,
boot.tv_sec, boot.tv_nsec);
}
int main(void)
{
print_clocks("Parent before");
/* 새 Time Namespace 생성 */
if (unshare(CLONE_NEWTIME) == -1) {
perror("unshare(CLONE_NEWTIME)");
return 1;
}
/* 오프셋 설정: MONOTONIC +1000초, BOOTTIME +2000초 */
int fd = open("/proc/self/timens_offsets", O_WRONLY);
if (fd < 0) { perror("open"); return 1; }
dprintf(fd, "monotonic %d %d\n", 1000, 0);
dprintf(fd, "boottime %d %d\n", 2000, 0);
close(fd);
print_clocks("Parent after offset");
/* 부모는 여전히 기존 NS: 값 변화 없음 */
pid_t pid = fork();
if (pid == -1) { perror("fork"); return 1; }
if (pid == 0) {
/* 자식: 새 time NS에 진입 */
print_clocks("Child (new NS)");
/* MONOTONIC +1000s, BOOTTIME +2000s 적용됨 */
_exit(0);
}
waitpid(pid, NULL, 0);
print_clocks("Parent after child");
return 0;
}
/* 예상 출력:
[Parent before] MONOTONIC=1234.000000000 BOOTTIME=1234.000000000
[Parent after offset] MONOTONIC=1234.000100000 BOOTTIME=1234.000100000
[Child (new NS)] MONOTONIC=2234.000200000 BOOTTIME=3234.000200000
[Parent after child] MONOTONIC=1234.000300000 BOOTTIME=1234.000300000
*/
nsenter/unshare 명령행 예제
# 호스트 현재 시간 확인
$ cat /proc/uptime
12345.67 48000.89
# Time namespace 생성 + 오프셋 설정 + 진입
$ sudo unshare --time --fork --map-current-user /bin/bash -c '
echo "monotonic 86400 0" > /proc/self/timens_offsets
echo "boottime 86400 0" >> /proc/self/timens_offsets
exec /bin/bash
'
# 새 namespace에서 확인 (+86400초 = +1일)
$ cat /proc/uptime
98745.67 ...
# namespace ID 확인
$ ls -l /proc/self/ns/time
lrwxrwxrwx ... /proc/self/ns/time -> 'time:[4026532XXX]'
# 다른 터미널에서 nsenter로 같은 namespace 진입
$ sudo nsenter -T -t $PID /bin/bash
$ cat /proc/uptime # 같은 오프셋 적용
Python에서 Time Namespace 활용
#!/usr/bin/env python3
"""time_namespace.py -- Python에서 time namespace 생성 및 검증"""
import ctypes
import os
import time
import struct
# libc 함수 바인딩
libc = ctypes.CDLL("libc.so.6", use_errno=True)
# clone(2) 플래그
CLONE_NEWTIME = 0x00000080
# unshare(2) 호출
def unshare(flags):
ret = libc.unshare(flags)
if ret != 0:
errno = ctypes.get_errno()
raise OSError(errno, os.strerror(errno))
def get_monotonic():
return time.clock_gettime(time.CLOCK_MONOTONIC)
def get_boottime():
return time.clock_gettime(time.CLOCK_BOOTTIME)
def main():
print(f"Before unshare:")
print(f" MONOTONIC = {get_monotonic():.9f}")
print(f" BOOTTIME = {get_boottime():.9f}")
# 새 time namespace 생성 (현재 프로세스는 영향 없음)
unshare(CLONE_NEWTIME)
# 오프셋 설정: +1000초 monotonic, +2000초 boottime
with open("/proc/self/timens_offsets", "w") as f:
f.write("monotonic 1000 0\n")
f.write("boottime 2000 0\n")
# fork()로 자식이 새 NS에 진입
pid = os.fork()
if pid == 0:
# 자식: 새 time namespace
print(f"Child (new NS):")
print(f" MONOTONIC = {get_monotonic():.9f} (+1000s)")
print(f" BOOTTIME = {get_boottime():.9f} (+2000s)")
# 오프셋 확인
with open("/proc/self/timens_offsets") as f:
print(f" Offsets: {f.read().strip()}")
os._exit(0)
else:
os.waitpid(pid, 0)
print(f"Parent (still init NS):")
print(f" MONOTONIC = {get_monotonic():.9f}")
if __name__ == "__main__":
main()
Go에서 Time Namespace 활용
// timens_example.go -- Go에서 time namespace 생성
package main
import (
"fmt"
"os"
"os/exec"
"syscall"
"time"
)
const CLONE_NEWTIME = 0x00000080
func getMonotonic() time.Duration {
var ts syscall.Timespec
syscall.ClockGettime(syscall.CLOCK_MONOTONIC, &ts)
return time.Duration(ts.Sec)*time.Second +
time.Duration(ts.Nsec)*time.Nanosecond
}
func main() {
if os.Getenv("IN_TIMENS") == "1" {
// 자식 프로세스: 새 time NS 내부에서 실행 중
fmt.Printf("Child MONOTONIC: %v\n", getMonotonic())
return
}
fmt.Printf("Parent MONOTONIC: %v\n", getMonotonic())
// 자식 프로세스를 새 time namespace에서 실행
cmd := exec.Command("/proc/self/exe")
cmd.Env = append(os.Environ(), "IN_TIMENS=1")
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
cmd.SysProcAttr = &syscall.SysProcAttr{
Cloneflags: CLONE_NEWTIME,
}
// 오프셋은 /proc/[child-pid]/timens_offsets에 설정해야 함
// SysProcAttr에서는 직접 지원하지 않으므로
// 별도 wrapper 스크립트나 helper 프로세스 필요
if err := cmd.Run(); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
}
syscall.SysProcAttr.Cloneflags에 CLONE_NEWTIME을 설정하면
자식 프로세스가 새 time namespace에서 시작됩니다. 다만 오프셋 설정은 /proc/[pid]/timens_offsets에
직접 쓰기가 필요하며, 이는 부모 프로세스에서 자식 PID를 알아야 하므로 helper 프로세스 패턴이 일반적입니다.
커널 설정
# Time Namespace 필수
CONFIG_TIME_NS=y # Time Namespace 지원 (Linux 5.6+)
# 관련 namespace (일반적으로 함께 활성화)
CONFIG_UTS_NS=y
CONFIG_IPC_NS=y
CONFIG_PID_NS=y
CONFIG_NET_NS=y
CONFIG_USER_NS=y
CONFIG_CGROUP_NS=y
CONFIG_MNT_NS=y
# vDSO 지원 (time NS에 필수)
CONFIG_GENERIC_VDSO_TIME_NS=y # 아키텍처별 vDSO time NS 지원 */
아키텍처별 지원 현황
| 아키텍처 | Time NS 지원 | 커널 버전 | 비고 |
|---|---|---|---|
| x86_64 | 지원 | 5.6+ | 최초 구현 |
| ARM64 (aarch64) | 지원 | 5.7+ | vDSO 연동 완료 |
| ARM (32-bit) | 지원 | 5.11+ | vDSO 제한적 |
| RISC-V | 지원 | 5.12+ | |
| s390 | 지원 | 5.7+ | |
| PowerPC | 지원 | 5.11+ | |
| MIPS | 미지원 | - | vDSO 미구현 |
procfs/sysfs 인터페이스
| 경로 | 읽기/쓰기 | 설명 |
|---|---|---|
/proc/[pid]/ns/time | R | Time namespace 심볼릭 링크 (nsenter 대상) |
/proc/[pid]/ns/time_for_children | R | 자식용 time namespace 심볼릭 링크 |
/proc/[pid]/timens_offsets | R/W | 오프셋 설정/확인 (frozen 전에만 쓰기 가능) |
/proc/uptime | R | 오프셋 적용된 monotonic 시간 |
/proc/timer_list | R | 호스트 시간 기준 (오프셋 미적용) |
# namespace ID 확인
$ ls -l /proc/self/ns/time
lrwxrwxrwx 1 user user 0 ... /proc/self/ns/time -> 'time:[4026531834]'
# 자식용 namespace 확인 (unshare 후 달라짐)
$ ls -l /proc/self/ns/time_for_children
# 오프셋 확인
$ cat /proc/self/timens_offsets
monotonic 0 0
boottime 0 0
# 특정 PID의 time NS 확인
$ readlink /proc/1234/ns/time
time:[4026532001]
제약사항
CLOCK_REALTIME 격리 불가
CLOCK_REALTIME은 time namespace의 영향을 받지 않습니다. 이는 의도된 설계입니다.
| 격리 불가 이유 | 설명 |
|---|---|
| 파일 타임스탬프 | 파일 생성/수정 시각은 전역 일관성 필요 |
| TLS/SSL 인증서 | 인증서 유효기간 검증이 실패할 수 있음 |
| 네트워크 프로토콜 | NTP, Kerberos, DNSSEC 등이 wall clock 의존 |
| 로그 일관성 | 호스트/컨테이너 간 로그 시간대가 달라지면 분석 곤란 |
| CRIU 불필요 | CRIU는 monotonic만 복원하면 됨 (realtime은 NTP로 동기화) |
VDSO 페이지 전환 제약
실행 중인 프로세스의 VDSO 페이지 매핑을 안전하게 교체할 수 없기 때문에,
setns()로 다른 time namespace에 직접 진입하는 것은 불가능합니다.
반드시 fork()를 통해 자식 프로세스가 진입해야 합니다.
/* setns()로 time NS 직접 진입 시도 -- 동작하지 않음 */
int ns_fd = open("/proc/1234/ns/time", O_RDONLY);
int ret = setns(ns_fd, CLONE_NEWTIME);
/* ret == -1, errno == EINVAL
* setns()는 time_ns_for_children만 변경
* 실제 전환은 fork() 후 자식에서 발생 */
Frozen Offset
# 오프셋 동결 후 쓰기 시도
$ echo "monotonic 2000 0" > /proc/self/timens_offsets
bash: /proc/self/timens_offsets: Permission denied
# errno: EACCES (frozen_offsets == true)
clock_gettime()이 0을 반환하는 등 예측 불가능한 동작이 발생합니다.
운영 패턴
컨테이너별 독립 uptime
#!/bin/bash
# 컨테이너별 독립 uptime 구현
# 현재 호스트 monotonic 확인
HOST_MONO=$(awk '{print int($1)}' /proc/uptime)
# 컨테이너에서 0부터 시작하는 uptime 제공
OFFSET="-${HOST_MONO}"
sudo unshare --time --fork /bin/bash -c "
echo 'monotonic ${OFFSET} 0' > /proc/self/timens_offsets
echo 'boottime ${OFFSET} 0' >> /proc/self/timens_offsets
exec /bin/bash
"
# 컨테이너 내부: /proc/uptime = 0 부터 시작
모니터링 영향
| 도구/지표 | Time NS 영향 | 대응 |
|---|---|---|
| Prometheus node_exporter | node_boot_time_seconds 오프셋 적용됨 | 호스트에서 수집 또는 레이블로 구분 |
uptime 명령 | 오프셋 적용된 값 표시 | NS 내부 기준임을 인지 |
| systemd 타이머 | MONOTONIC 기반 타이머 오프셋 적용 | REALTIME 기반 타이머는 영향 없음 |
| perf/ftrace 타임스탬프 | 호스트 기준 (영향 없음) | NS 시간과 매핑 필요 |
| dmesg 타임스탬프 | 호스트 기준 | NS와 차이 있을 수 있음 |
CLOCK_MONOTONIC 기반 지표가 오프셋 적용된 값을 보고합니다.
호스트 레벨 모니터링에서는 영향이 없으므로, 가능하면 모니터링은 호스트 namespace에서 실행하는 것을 권장합니다.
CI/CD 파이프라인에서 시간 테스트
#!/bin/bash
# ci-time-test.sh -- CI 파이프라인에서 시간 의존 테스트 자동화
# Time namespace를 활용하여 시간 이동 테스트 수행
set -euo pipefail
# 테스트 매트릭스: 다양한 시간 오프셋으로 테스트
declare -a OFFSETS=(
"0" # 기본 (오프셋 없음)
"86400" # +1일
"2592000" # +30일
"31536000" # +1년
"-3600" # -1시간 (과거)
)
PASS=0
FAIL=0
for offset in "${OFFSETS[@]}"; do
echo "=== Testing with monotonic offset: ${offset}s ==="
if sudo unshare --time --fork /bin/bash -c "
if [ '${offset}' != '0' ]; then
echo 'monotonic ${offset} 0' > /proc/self/timens_offsets
echo 'boottime ${offset} 0' >> /proc/self/timens_offsets
fi
exec ./run_tests.sh --time-offset=${offset}
"; then
echo "PASS: offset=${offset}"
PASS=$((PASS + 1))
else
echo "FAIL: offset=${offset}"
FAIL=$((FAIL + 1))
fi
done
echo "Results: ${PASS} passed, ${FAIL} failed"
[ "${FAIL}" -eq 0 ]
멀티 테넌트 시간 관리
여러 컨테이너/테넌트가 각각 독립된 시간을 갖도록 구성하는 패턴입니다.
#!/bin/bash
# multi-tenant-time.sh -- 테넌트별 독립 시간 할당
create_tenant_ns() {
local tenant_id="$1"
local mono_offset="$2"
local boot_offset="$3"
echo "[${tenant_id}] Creating time NS: mono=${mono_offset}s, boot=${boot_offset}s"
sudo unshare --time --fork /bin/bash -c "
echo 'monotonic ${mono_offset} 0' > /proc/self/timens_offsets
echo 'boottime ${boot_offset} 0' >> /proc/self/timens_offsets
# PID 파일 생성 (다른 프로세스가 nsenter로 진입 가능)
echo \$\$ > /run/tenant-${tenant_id}.pid
# 테넌트 서비스 실행
exec /usr/bin/tenant-service --id=${tenant_id}
" &
}
# 테넌트별 시간 격리
# 테넌트 A: 10일 전 uptime 시뮬레이션 (-864000초)
create_tenant_ns "tenant-a" "-864000" "-864000"
# 테넌트 B: 기본 호스트 시간
create_tenant_ns "tenant-b" "0" "0"
# 테넌트 C: 미래 시간 (+30일)
create_tenant_ns "tenant-c" "2592000" "2592000"
wait
안전한 오프셋 계산 패턴
#define _GNU_SOURCE
#include <stdio.h>
#include <time.h>
#include <sched.h>
#include <fcntl.h>
#include <unistd.h>
#include <errno.h>
/*
* 안전한 오프셋 계산:
* 결과 시간이 음수가 되지 않도록 검증
*/
int safe_set_timens_offset(
long target_mono_sec, /* 원하는 monotonic 시작값 */
long target_boot_sec /* 원하는 boottime 시작값 */
)
{
struct timespec cur_mono, cur_boot;
clock_gettime(CLOCK_MONOTONIC, &cur_mono);
clock_gettime(CLOCK_BOOTTIME, &cur_boot);
long mono_offset = target_mono_sec - cur_mono.tv_sec;
long boot_offset = target_boot_sec - cur_boot.tv_sec;
/* 결과가 음수가 되지 않도록 검증 */
if (cur_mono.tv_sec + mono_offset < 0) {
fprintf(stderr,
"Warning: mono offset %ld would make time negative, clamping to -%ld\n",
mono_offset, cur_mono.tv_sec);
mono_offset = -cur_mono.tv_sec;
}
if (cur_boot.tv_sec + boot_offset < 0) {
fprintf(stderr,
"Warning: boot offset %ld would make time negative, clamping to -%ld\n",
boot_offset, cur_boot.tv_sec);
boot_offset = -cur_boot.tv_sec;
}
int fd = open("/proc/self/timens_offsets", O_WRONLY);
if (fd < 0) {
if (errno == EACCES)
fprintf(stderr, "Error: offsets already frozen\n");
return -1;
}
dprintf(fd, "monotonic %ld 0\n", mono_offset);
dprintf(fd, "boottime %ld 0\n", boot_offset);
close(fd);
printf("Set offsets: mono=%+ld boot=%+ld\n",
mono_offset, boot_offset);
return 0;
}
clock_gettime() 호출과 timens_offsets 쓰기 사이의 시간차를 고려해야 합니다.
정밀한 오프셋이 필요하면 나노초 단위까지 계산하고, 오프셋 적용 후 자식 프로세스에서 실제 값을 검증하세요.
성능 분석
vDSO 오프셋 적용 오버헤드
Time namespace가 활성화되면 clock_gettime()의 vDSO 경로에서 오프셋 덧셈이 추가됩니다.
이 연산의 오버헤드를 측정합니다.
| 시나리오 | clock_gettime() 평균 (ns) | 상대 오버헤드 | 비고 |
|---|---|---|---|
| init time NS (vDSO) | ~15-20 | 기준 | VVAR 페이지 직접 읽기 |
| custom time NS (vDSO) | ~18-25 | +15-25% | 오프셋 덧셈 + seqcount 추가 |
| syscall fallback | ~200-400 | +1000%+ | vDSO 불가 시 커널 진입 |
| CLOCK_REALTIME (vDSO) | ~15-20 | 기준 (영향 없음) | 오프셋 적용 안 됨 |
/*
* timens_vdso_bench.c -- vDSO clock_gettime 벤치마크
* 컴파일: gcc -O2 -o timens_bench timens_vdso_bench.c
*/
#define _GNU_SOURCE
#include <stdio.h>
#include <time.h>
#include <stdint.h>
#define ITERATIONS 10000000
static inline uint64_t rdtsc(void)
{
uint32_t lo, hi;
__asm__ volatile ("rdtsc" : "=a"(lo), "=d"(hi));
return ((uint64_t)hi << 32) | lo;
}
int main(void)
{
struct timespec ts;
uint64_t start, end;
int clocks[] = { CLOCK_MONOTONIC, CLOCK_MONOTONIC_RAW,
CLOCK_BOOTTIME, CLOCK_REALTIME };
const char *names[] = { "MONOTONIC", "MONOTONIC_RAW",
"BOOTTIME", "REALTIME" };
for (int c = 0; c < 4; c++) {
start = rdtsc();
for (int i = 0; i < ITERATIONS; i++)
clock_gettime(clocks[c], &ts);
end = rdtsc();
printf("%-18s %8.1f cycles/call (%llu total cycles)\n",
names[c],
(double)(end - start) / ITERATIONS,
(unsigned long long)(end - start));
}
return 0;
}
메모리 오버헤드
| 리소스 | init NS (공유) | custom NS (개별) | 설명 |
|---|---|---|---|
| time_namespace 구조체 | 전역 1개 | NS당 ~128B | kref, offsets, vvar_page 포인터 |
| VVAR 페이지 | 커널 전역 공유 | NS당 1-2 페이지 (4-8KB) | 별도 물리 페이지 할당 |
| timens_offsets | 0B (offsets 없음) | 16B (2 x timespec64) | monotonic + boottime |
| procfs 엔트리 | 프로세스당 공유 | 프로세스당 별도 inode | timens_offsets 파일 |
확장성 테스트
#!/bin/bash
# timens_scalability.sh -- Time namespace 확장성 벤치마크
MAX_NS=500
echo "Creating ${MAX_NS} time namespaces..."
# 메모리 사용량 기준선
MEM_BEFORE=$(awk '/MemAvailable/ {print $2}' /proc/meminfo)
PIDS=()
for i in $(seq 1 $MAX_NS); do
sudo unshare --time --fork /bin/bash -c "
echo 'monotonic ${i}00 0' > /proc/self/timens_offsets
echo 'boottime ${i}00 0' >> /proc/self/timens_offsets
exec sleep 300
" &
PIDS+=($!)
done
sleep 2
MEM_AFTER=$(awk '/MemAvailable/ {print $2}' /proc/meminfo)
MEM_USED=$(( (MEM_BEFORE - MEM_AFTER) ))
echo "Created ${MAX_NS} namespaces"
echo "Memory used: ${MEM_USED} KB ($(( MEM_USED / MAX_NS )) KB/ns)"
echo "Active time namespaces:"
sudo lsns -t time | wc -l
# 정리
for pid in "${PIDS[@]}"; do kill $pid 2>/dev/null; done
wait
타이머 정확도 영향
Time namespace의 오프셋은 ktime_add()으로 적용되며, 타이머 정확도 자체에는 영향을 미치지 않습니다.
다만 VVAR seqcount 경쟁(contention) 시 읽기 재시도가 발생할 수 있습니다.
| 지표 | init NS | custom NS | 차이 원인 |
|---|---|---|---|
| clock_gettime 지터 | ~5ns | ~5ns | 오프셋 덧셈은 결정적 연산 |
| nanosleep 정확도 | ±50us | ±50us | hrtimer 해상도에 의존 |
| timerfd 만료 지연 | ~1-10us | ~1-10us | 커널 스케줄러에 의존 |
| seqcount 재시도율 | ~0.001% | ~0.01% | VVAR 업데이트 빈도 차이 |
디버깅
Time Namespace 확인
# 프로세스의 time namespace 확인
$ ls -l /proc/$$/ns/time
lrwxrwxrwx 1 user user 0 ... /proc/12345/ns/time -> 'time:[4026531834]'
# init namespace인지 확인 (4026531834는 보통 init)
$ readlink /proc/1/ns/time # init 프로세스의 time NS
time:[4026531834]
# 현재 프로세스가 init NS에 있는지 비교
$ if [ "$(readlink /proc/self/ns/time)" = "$(readlink /proc/1/ns/time)" ]; then
echo "init time namespace"
else
echo "custom time namespace"
fi
# 오프셋 확인
$ cat /proc/self/timens_offsets
monotonic 1000 0
boottime 2000 500000000
# 시간 비교 (호스트 vs NS)
$ nsenter -T -t 1 /usr/bin/cat /proc/uptime # 호스트 시간
$ cat /proc/uptime # NS 시간
Tracing
# ftrace로 timens 관련 함수 추적
# echo 'timens_*' > /sys/kernel/debug/tracing/set_ftrace_filter
# echo function > /sys/kernel/debug/tracing/current_tracer
# echo 1 > /sys/kernel/debug/tracing/tracing_on
# bpftrace로 오프셋 적용 추적
$ sudo bpftrace -e '
kprobe:timens_add_monotonic {
printf("pid=%d comm=%s\n", pid, comm);
}'
# strace로 clock_gettime 확인
$ strace -e trace=clock_gettime -p 1234 2>&1 | head
clock_gettime(CLOCK_MONOTONIC, {tv_sec=6234, tv_nsec=...}) = 0
디버깅 체크리스트
| 증상 | 원인 | 확인 방법 |
|---|---|---|
| 오프셋 쓰기 실패 (EACCES) | frozen_offsets=true | 이미 프로세스가 NS에 진입 |
| 자식에서 시간 변화 없음 | 오프셋 미설정 또는 0 | cat /proc/[pid]/timens_offsets |
| unshare 실패 (EPERM) | CAP_SYS_ADMIN 없음 | root 또는 user NS 내에서 실행 |
| uptime이 음수로 보임 | 음수 오프셋이 현재 시간보다 큼 | 오프셋 값 재계산 |
| 타이머가 즉시 만료 | 절대 시간 지정 시 역변환 문제 | 상대 시간 지정으로 변경 |
| vDSO에서 잘못된 시간 | VVAR 페이지 매핑 오류 | /proc/[pid]/maps에서 vvar 확인 |
| setns() 반환 EINVAL | time NS에 직접 setns 불가 | fork() 후 자식에서 진입해야 함 |
| nsenter -T 무반응 | 대상 NS가 frozen 아님 | 대상 PID가 실제 time NS에 있는지 확인 |
| clock_nanosleep 오동작 | TIMER_ABSTIME + 오프셋 충돌 | 상대 타이머 사용 또는 오프셋 조정 |
고급 bpftrace 추적
#!/usr/bin/env bpftrace
# timens_offset_trace.bt - Time namespace 오프셋 적용 상세 추적
# 오프셋 적용 추적 (monotonic)
$ sudo bpftrace -e '
kprobe:timens_add_monotonic {
@mono[comm, pid] = count();
printf("%-16s pid=%-6d applying monotonic offset\n", comm, pid);
}
kprobe:timens_add_boottime {
@boot[comm, pid] = count();
printf("%-16s pid=%-6d applying boottime offset\n", comm, pid);
}
kretprobe:timens_ktime_to_host {
printf("%-16s pid=%-6d ns->host ktime conversion ret=%lld\n",
comm, pid, retval);
}
END {
printf("\n=== Monotonic offset calls ===\n");
print(@mono);
printf("\n=== Boottime offset calls ===\n");
print(@boot);
}
'
# vDSO 경로 vs syscall 경로 비교 추적
$ sudo bpftrace -e '
tracepoint:syscalls:sys_enter_clock_gettime {
@syscall_path[comm, pid, args->which_clock] = count();
}
kprobe:do_clock_gettime {
@kernel_path[comm, pid] = count();
}
interval:s:5 {
printf("\n=== syscall fallback (vDSO miss) ===\n");
print(@syscall_path);
printf("\n=== kernel path ===\n");
print(@kernel_path);
clear(@syscall_path);
clear(@kernel_path);
}
'
perf 기반 성능 분석
# clock_gettime syscall 오버헤드 측정 (vDSO bypass 시)
$ sudo perf stat -e 'syscalls:sys_enter_clock_gettime' \
-e 'syscalls:sys_exit_clock_gettime' \
-p $PID -- sleep 10
# vDSO 내부 실행 시간 프로파일링
$ sudo perf record -e cpu-clock -g -p $PID -- sleep 5
$ sudo perf report --no-children --sort=symbol | grep -i vdso
# VVAR 페이지 접근 패턴 확인
$ sudo perf mem record -p $PID -- sleep 5
$ sudo perf mem report | grep vvar
# time namespace 전환 시 TLB flush 영향 측정
$ sudo perf stat -e 'dTLB-load-misses,dTLB-store-misses,iTLB-load-misses' \
-p $PID -- sleep 10
VVAR 페이지 매핑 디버깅
# 프로세스의 VVAR/VDSO 매핑 확인
$ cat /proc/$PID/maps | grep -E '(vvar|vdso)'
7ffce16f5000-7ffce16f9000 r--p 00000000 00:00 0 [vvar]
7ffce16f9000-7ffce16fb000 r-xp 00000000 00:00 0 [vdso]
# 두 프로세스의 VVAR 페이지가 다른 물리 주소인지 확인
# (다른 time NS에 속하면 별도 VVAR 페이지 사용)
$ sudo cat /proc/$PID1/pagemap | xxd | head
$ sudo cat /proc/$PID2/pagemap | xxd | head
# VVAR 페이지 크기 확인 (time NS 활성 시 추가 페이지 할당됨)
$ sudo cat /proc/$PID/smaps | grep -A 15 vvar
Size: 16 kB # 일반: 16KB, time NS: 추가 페이지
Rss: 16 kB
Pss: 4 kB
AnonHugePages: 0 kB
# systemtap으로 vvar_fault 추적 (아키텍처 디버깅용)
# stap -e 'probe kernel.function("vvar_fault") {
# printf("%s(%d): vvar fault vmf->pgoff=%d\n",
# execname(), pid(), $vmf->pgoff)
# }'
디버깅 도구 요약
| 도구 | 용도 | 관련 명령/옵션 |
|---|---|---|
bpftrace | 오프셋 적용 커널 함수 추적 | kprobe:timens_add_* |
perf stat | syscall 오버헤드 측정 | -e syscalls:sys_*_clock_gettime |
perf record | vDSO 프로파일링 | perf report | grep vdso |
strace | syscall 레벨 시간 확인 | -e clock_gettime,clock_nanosleep |
ftrace | 커널 함수 추적 | set_ftrace_filter timens_* |
nsenter | NS 진입 / 시간 비교 | nsenter -T -t PID |
/proc | NS ID, 오프셋, 매핑 확인 | ns/time, timens_offsets, maps |
lsns | 시스템 NS 목록 | lsns -t time |
보안 고려사항
CLOCK_REALTIME을 격리하지 않으므로, 컨테이너 내에서 date 명령이나 CLOCK_REALTIME 기반 시간 조회로 호스트의 실제 시간을 알 수 있습니다.
또한 clock_gettime()/gettimeofday()는 vDSO fast path를 사용할 수 있어 seccomp의 시스템 콜 필터만으로 관측 자체를 완전히 숨길 수 없습니다.
wall clock 은닉이 핵심 요구라면 time namespace만으로는 부족하며, 더 강한 격리 계층(VM) 또는 애플리케이션 레벨 시간 주입이 필요합니다.
공격 표면 분석
| 공격 벡터 | 설명 | 위험도 | 완화 방법 |
|---|---|---|---|
| 시간 기반 부채널 | CLOCK_REALTIME으로 호스트 wall clock 추론 후 부팅 시각을 역산 | 중간 | time namespace만으로 숨길 수 없는 정보임을 전제하고, 필요 시 VM 격리 또는 앱 레벨 시간 주입 사용 |
| 타이머 조작 | 네임스페이스 내부의 MONOTONIC/BOOTTIME 오프셋 때문에 컨테이너 내부 만료 판단이 달라질 수 있음 | 높음 | 보안 결정은 신뢰된 네임스페이스의 monotonic clock 또는 외부 검증 서비스에서 수행 |
| 로그 위변조 | 컨테이너 내 로그 타임스탬프가 호스트와 불일치 | 낮음 | 중앙 로깅 시스템에서 호스트 시간 사용 |
| 임의 NS 생성 | user NS 내에서 time NS 무제한 생성 | 낮음 | user.max_time_namespaces sysctl 제한 |
| 오프셋 남용 | 극단적 음수 오프셋으로 시간 0 또는 음수 시뮬레이션 | 중간 | 오프셋 범위 검증, 운영 정책 |
보안 강화 설정
# 1. time namespace 생성 수 제한 (sysctl)
$ sudo sysctl -w user.max_time_namespaces=10
# 시스템 전체에서 최대 10개 time NS만 허용
# 2. seccomp으로 "시간 변경" syscall 제한
# Docker seccomp 프로파일 예시
# {
# "names": ["clock_settime", "settimeofday", "adjtimex"],
# "action": "SCMP_ACT_ERRNO",
# "errnoRet": 1
# }
# 주의: 관측용 clock_gettime()/gettimeofday()는 vDSO 경로 때문에
# seccomp만으로 완전 차단되지 않을 수 있음
# 3. LSM (AppArmor/SELinux) 정책
# AppArmor: time namespace 생성 차단
# deny unshare w, # CLONE_NEWTIME 포함
# SELinux: time namespace 접근 제어
# allow container_t container_t : nstype { time_ns_create };
# 4. audit 로그로 time NS 생성 추적
$ sudo auditctl -a always,exit -F arch=b64 \
-S unshare -F a0=0x80 -k timens_create
# 0x80 = CLONE_NEWTIME
# 5. 시간 NS 현황 모니터링
$ sudo lsns -t time
NS TYPE NPROCS PID USER COMMAND
4026531834 time 100 1 root /sbin/init
4026532001 time 5 1234 user /usr/bin/app
보안 모범 사례
- 모니터링은 호스트 NS에서 실행: MONOTONIC 기반 지표의 오프셋 영향 방지
- 보안 만료 판정은 신뢰된 MONOTONIC/BOOTTIME 사용: wall clock 점프와 container offset을 동시에 피하기 쉬움
- 로그 타임스탬프 정책: 컨테이너 로그에 호스트 시간도 함께 기록하거나, 중앙 로깅 서비스 사용
- namespace 수 제한:
user.max_time_namespacessysctl로 자원 소모 공격 방지 - 오프셋 검증: CRIU 복원 시 오프셋이 합리적인 범위인지 검증 (예: 10년 이내)
- CAP_SYS_TIME 제한: 컨테이너에 불필요한 CAP_SYS_TIME capability 부여하지 않기
실전 활용
컨테이너 라이브 마이그레이션
# 1. Source Host에서 checkpoint
$ sudo criu dump -t $CONTAINER_PID -D /tmp/checkpoint \
--shell-job --tcp-established --ext-unix-sk
# 2. Destination Host로 checkpoint 이미지 전송
$ rsync -avz /tmp/checkpoint/ dest:/tmp/checkpoint/
# 3. Destination Host에서 restore (time namespace 자동 설정)
$ sudo criu restore -D /tmp/checkpoint \
--shell-job --tcp-established --ext-unix-sk
시간 의존 애플리케이션 테스트
#!/bin/bash
# 미래 시간 시뮬레이션 (uptime +30일)
OFFSET=$((30 * 86400)) # 2,592,000초
sudo unshare --time --fork /bin/bash -c "
echo 'monotonic ${OFFSET} 0' > /proc/self/timens_offsets
echo 'boottime ${OFFSET} 0' >> /proc/self/timens_offsets
exec /bin/bash -c '
echo \"Simulated uptime: $(cat /proc/uptime)\"
./myapp --test-long-running
'
"
Kubernetes/Docker 통합
# Docker: time namespace 사용 (runc 지원 시)
$ docker run --security-opt=time-namespace=host alpine cat /proc/uptime
# 현재 Docker/containerd는 time NS를 기본 활성화하지 않음
# CRIU를 통한 checkpoint/restore에서 자동 설정
# Kubernetes: CRIU 기반 체크포인트 (실험적)
$ kubectl checkpoint pod/my-pod --container=my-container \
--export=/tmp/checkpoint
분산 시스템 시간 불일치 시뮬레이션
Time namespace를 활용하여 분산 시스템에서 발생할 수 있는 시간 스큐(skew)를 안전하게 시뮬레이션할 수 있습니다.
#!/bin/bash
# clock-skew-test.sh -- 분산 시스템 clock skew 시뮬레이션
# 3개 노드가 각각 다른 시간 오프셋을 갖는 환경 구성
set -euo pipefail
# 노드별 시간 스큐 (초 단위)
NODE_OFFSETS=(
0 # 노드 1: 기준 시간
5 # 노드 2: +5초 앞서감
-3 # 노드 3: -3초 뒤처짐
)
echo "=== Clock Skew Simulation ==="
echo "Testing Raft consensus with clock offsets: ${NODE_OFFSETS[*]}"
for i in 0 1 2; do
offset=${NODE_OFFSETS[$i]}
port=$((8000 + i))
sudo unshare --time --fork /bin/bash -c "
if [ '${offset}' != '0' ]; then
echo 'monotonic ${offset} 0' > /proc/self/timens_offsets
echo 'boottime ${offset} 0' >> /proc/self/timens_offsets
fi
exec ./raft-node --id=${i} --port=${port} --peers=localhost:8000,localhost:8001,localhost:8002
" &
echo "Node ${i}: port=${port}, offset=${offset}s, pid=$!"
done
echo "Waiting for consensus test..."
sleep 30
# 결과 수집
echo "=== Test Results ==="
curl -s localhost:8000/status
curl -s localhost:8001/status
curl -s localhost:8002/status
재현 가능한 버그 디버깅
시간 의존 버그를 재현하기 위해 특정 uptime을 시뮬레이션하는 패턴입니다. 예를 들어 "49.7일 후 발생하는 jiffy 래핑 버그"를 테스트할 수 있습니다.
#!/bin/bash
# reproduce-time-bug.sh -- 특정 uptime에서 발생하는 버그 재현
# 32비트 jiffy 래핑 시점 (49.7일 = 4,294,967 초)
TARGET_UPTIME=4294960 # 래핑 7초 전
# 현재 uptime 확인
CURRENT=$(awk '{print int($1)}' /proc/uptime)
OFFSET=$((TARGET_UPTIME - CURRENT))
echo "Current uptime: ${CURRENT}s"
echo "Target uptime: ${TARGET_UPTIME}s"
echo "Offset needed: +${OFFSET}s"
sudo unshare --time --fork /bin/bash -c "
echo 'monotonic ${OFFSET} 0' > /proc/self/timens_offsets
echo 'boottime ${OFFSET} 0' >> /proc/self/timens_offsets
exec /bin/bash -c '
echo \"Simulated uptime: \$(cat /proc/uptime)\"
echo \"Monitoring for jiffy wrap bug...\"
./buggy-driver-test --watch-jiffies --duration=15
'
"
모니터링 대시보드 통합
#!/bin/bash
# timens-monitor.sh -- 시스템 내 모든 time namespace 모니터링
echo "=== Time Namespace Monitor ==="
printf "%-8s %-20s %-15s %-12s %-12s\n" \
"PID" "COMMAND" "NS_ID" "MONO_OFFSET" "BOOT_OFFSET"
echo "-------------------------------------------------------------------"
for pid in $(ls /proc/ | grep -E '^[0-9]+$'); do
[ -r /proc/$pid/ns/time ] || continue
ns_id=$(readlink /proc/$pid/ns/time 2>/dev/null) || continue
comm=$(cat /proc/$pid/comm 2>/dev/null) || continue
offsets=$(cat /proc/$pid/timens_offsets 2>/dev/null) || continue
# init NS는 건너뜀
init_ns=$(readlink /proc/1/ns/time 2>/dev/null)
[ "$ns_id" = "$init_ns" ] && continue
mono=$(echo "$offsets" | awk '/monotonic/ {printf "%d.%09d", $2, $3}')
boot=$(echo "$offsets" | awk '/boottime/ {printf "%d.%09d", $2, $3}')
printf "%-8s %-20s %-15s %-12s %-12s\n" \
"$pid" "$comm" "$ns_id" "$mono" "$boot"
done
echo "-------------------------------------------------------------------"
echo "Total time namespaces: $(sudo lsns -t time --no-headings | wc -l)"
timens_offsets 구조 심화
timens_offsets 구조체는 Time Namespace의 핵심 데이터입니다.
이 구조체가 커널 내부에서 어떻게 초기화되고, 검증되며, 적용되는지 세부적으로 분석합니다.
오프셋 초기화 경로
/* kernel/time/namespace.c -- clone_time_ns() */
static struct time_namespace *clone_time_ns(
struct user_namespace *user_ns,
struct time_namespace *old_ns)
{
struct time_namespace *ns;
ns = kmalloc(sizeof(*ns), GFP_KERNEL_ACCOUNT);
if (!ns)
return ERR_PTR(-ENOMEM);
refcount_set(&ns->ns.count, 1);
ns->vvar_page = NULL;
ns->user_ns = get_user_ns(user_ns);
ns->ucounts = inc_time_namespaces(user_ns);
if (!ns->ucounts) {
kfree(ns);
return ERR_PTR(-ENOSPC);
}
/* 오프셋을 0으로 초기화 -- 이 시점에서는 frozen=false */
ns->offsets.monotonic = (struct timespec64){0, 0};
ns->offsets.boottime = (struct timespec64){0, 0};
ns->frozen_offsets = false;
return ns;
}
오프셋 값 검증 상세
오프셋 쓰기 시 커널은 여러 단계의 검증을 수행합니다.
/* proc_timens_offsets_write() 내부 검증 흐름 */
static ssize_t timens_offsets_write(struct file *file,
const char __user *buf, size_t count, loff_t *ppos)
{
struct time_namespace *ns;
char *kbuf;
struct proc_timens_offset offsets[2];
int noffsets;
/* 검증 1: frozen 상태 확인 */
ns = current->nsproxy->time_ns_for_children;
if (ns->frozen_offsets)
return -EACCES;
/* 검증 2: user namespace 내 CAP_SYS_TIME 확인 */
if (!ns_capable(ns->user_ns, CAP_SYS_TIME))
return -EPERM;
/* 검증 3: 입력 파싱 ("monotonic sec nsec" 형식) */
kbuf = memdup_user_nul(buf, count);
if (IS_ERR(kbuf))
return PTR_ERR(kbuf);
noffsets = parse_offsets(kbuf, offsets);
/* 검증 4: nsec 범위 (0 ~ 999,999,999) */
for (int i = 0; i < noffsets; i++) {
if (offsets[i].val.tv_nsec < 0 ||
offsets[i].val.tv_nsec >= NSEC_PER_SEC)
return -EINVAL;
}
/* 검증 5: clock 이름이 "monotonic" 또는 "boottime"인지 */
/* 다른 clock 이름은 -EINVAL */
/* 적용 */
ns->offsets.monotonic = offsets[0].val;
...
}
오프셋 경계 조건
| 시나리오 | monotonic 오프셋 | 결과 | 위험도 |
|---|---|---|---|
| 양수 오프셋 | +1000초 | NS 내 시간이 호스트보다 1000초 앞 | 안전 |
| 음수 오프셋 (호스트 > |오프셋|) | -500초 (호스트 1000s) | NS 내 시간 = 500초 | 안전 |
| 음수 오프셋 (호스트 < |오프셋|) | -2000초 (호스트 1000s) | 결과 음수, 0으로 클램프 | 위험 |
| 매우 큰 양수 오프셋 | +2^62초 | overflow 가능 | 위험 |
| 나노초 부분만 설정 | 0초 500000000ns | 0.5초 오프셋 | 안전 |
| 초와 나노초 혼합 | 100초 750000000ns | 100.75초 오프셋 | 안전 |
tv_sec = LLONG_MAX)을 설정하면 timespec64_add()에서 overflow가 발생할 수 있습니다.
운영 환경에서는 항상 합리적인 범위(수십 년 이내) 내에서 오프셋을 설정하세요.
/proc/PID/timens_offsets 파일 형식
/* 커널의 timens_offsets 출력 포맷팅 */
static int proc_timens_offsets_show(struct seq_file *m, void *v)
{
struct time_namespace *ns;
struct timens_offsets *offsets;
ns = get_time_ns(current->nsproxy->time_ns_for_children);
offsets = &ns->offsets;
/* 형식: "clock_name seconds nanoseconds" */
seq_printf(m, "monotonic %10lld %9lu\n",
offsets->monotonic.tv_sec,
offsets->monotonic.tv_nsec);
seq_printf(m, "boottime %10lld %9lu\n",
offsets->boottime.tv_sec,
offsets->boottime.tv_nsec);
put_time_ns(ns);
return 0;
}
/proc/self/timens_offsets는 현재 프로세스의 time namespace가 아니라
자식 프로세스가 진입할 time namespace의 오프셋을 보여줍니다.
unshare(CLONE_NEWTIME) 전에는 init NS의 오프셋(0, 0)이 표시됩니다.
vDSO Fast Path 심화
Time Namespace에서 vDSO가 시스템 콜 없이 올바른 시간을 반환하는 메커니즘을 아키텍처별로 상세히 분석합니다.
아키텍처별 vDSO 구현
/* lib/vdso/gettimeofday.c -- 아키텍처 공통 vDSO 코드 */
static __always_inline const struct vdso_data *
__arch_get_timens_vdso_data(const struct vdso_data *vd)
{
/* VVAR 페이지 영역에서 timens용 데이터 위치 계산
* init NS: NULL 반환 -- 글로벌 vdso_data 사용
* custom NS: NS별 VVAR 페이지의 vdso_data 포인터 반환
*
* x86에서는 VVAR 페이지가 다음과 같이 배치됨:
* [VVAR_PAGE] -- 글로벌 vdso_data (init NS)
* [VVAR_TIMENS_PAGE] -- time NS용 vdso_data */
return (const struct vdso_data *)
((char *)vd + VVAR_TIMENS_PAGE_OFFSET * PAGE_SIZE);
}
/* x86 아키텍처: arch/x86/include/asm/vdso/gettimeofday.h */
static __always_inline const struct vdso_data *
__x86_get_timens_vdso_data(const struct vdso_data *vd)
{
if (likely(!vd->clock_mode))
return NULL; /* init NS -- 추가 페이지 불필요 */
return (const struct vdso_data *)
((char *)vd + PAGE_SIZE);
}
VVAR 페이지 매핑 상세
/* arch/x86/entry/vdso/vma.c -- VVAR 페이지 fault 처리 */
static vm_fault_t vvar_fault(
const struct vm_special_mapping *sm,
struct vm_area_struct *vma,
struct vm_fault *vmf)
{
if (vmf->pgoff == VVAR_DATA_PAGE_OFFSET) {
/* 글로벌 vdso_data 매핑 */
return vmf_insert_pfn(vma, vmf->address,
__pa_symbol(_vdso_data) >> PAGE_SHIFT);
}
if (vmf->pgoff == VVAR_TIMENS_PAGE_OFFSET) {
struct time_namespace *ns = current->nsproxy->time_ns;
/* init NS이면 fault -- 이 페이지가 필요하지 않음 */
if (ns == &init_time_ns)
return VM_FAULT_SIGBUS;
/* NS별 VVAR 페이지 매핑 */
return vmf_insert_pfn(vma, vmf->address,
page_to_pfn(ns->vvar_page));
}
return VM_FAULT_SIGBUS;
}
CRIU 라이브 마이그레이션 심화
CRIU의 Time Namespace 통합은 단순한 오프셋 설정을 넘어, checkpoint 시점의 시간 정보 수집, 이미지 포맷 내 시간 메타데이터 저장, restore 시 정밀한 오프셋 계산을 포함하는 복잡한 워크플로우입니다.
CRIU 이미지 내 시간 메타데이터
/* CRIU 복원 코드 (pie/restorer.c 기반 개념 코드) */
static int restore_time_namespace(
int64_t saved_mono_sec, int64_t saved_mono_nsec,
int64_t saved_boot_sec, int64_t saved_boot_nsec)
{
struct timespec host_mono, host_boot;
int fd;
char buf[256];
/* 현재 호스트 시간 측정 */
clock_gettime(CLOCK_MONOTONIC, &host_mono);
clock_gettime(CLOCK_BOOTTIME, &host_boot);
/* 오프셋 계산: saved_time - current_host_time */
int64_t mono_off_sec = saved_mono_sec - host_mono.tv_sec;
int64_t mono_off_nsec = saved_mono_nsec - host_mono.tv_nsec;
/* 나노초 정규화 */
if (mono_off_nsec < 0) {
mono_off_sec--;
mono_off_nsec += 1000000000LL;
}
/* timens_offsets 쓰기 */
fd = open("/proc/self/timens_offsets", O_WRONLY);
snprintf(buf, sizeof(buf),
"monotonic %lld %lld\nboottime %lld %lld\n",
mono_off_sec, mono_off_nsec,
saved_boot_sec - host_boot.tv_sec,
saved_boot_nsec - host_boot.tv_nsec);
write(fd, buf, strlen(buf));
close(fd);
return 0;
}
다중 컨테이너 마이그레이션
| 시나리오 | 오프셋 전략 | 주의사항 |
|---|---|---|
| 단일 컨테이너 이동 | 개별 오프셋 계산 | 단순, 일반적 |
| 같은 호스트 내 다중 컨테이너 | 컨테이너별 독립 오프셋 | 각 NS가 독립적 |
| 컨테이너 그룹 일괄 이동 | 동일 시점 checkpoint 필요 | IPC 의존 시 시간 동기화 중요 |
| 반복 마이그레이션 (A->B->C) | 원본 기준 오프셋 재계산 | 정밀도 저하 주의 |
futex 동작 심화
futex(Fast Userspace muTEX)는 glibc의 pthread_mutex, pthread_cond, sem_wait 등이 내부적으로 사용하는 핵심 동기화 원시 연산입니다.
Time Namespace는 futex의 절대 시간 기반 타임아웃에 영향을 미칩니다.
futex clock 선택과 오프셋
/* kernel/futex/futex.h -- futex 타임아웃 처리 */
static int futex_setup_timer(ktime_t *time,
struct hrtimer_sleeper *timeout,
int flags, u64 range_ns)
{
if (!time)
return 0;
if (flags & FLAGS_CLOCKRT) {
/* CLOCK_REALTIME: time NS 무관 */
hrtimer_init_sleeper_on_stack(timeout,
CLOCK_REALTIME, HRTIMER_MODE_ABS);
} else {
/* CLOCK_MONOTONIC: time NS 오프셋 역변환 필요 */
hrtimer_init_sleeper_on_stack(timeout,
CLOCK_MONOTONIC, HRTIMER_MODE_ABS);
}
/* 절대 시간을 호스트 시간으로 역변환 */
timeout->timer.node.expires =
timens_ktime_to_host(flags & FLAGS_CLOCKRT ?
CLOCK_REALTIME : CLOCK_MONOTONIC, *time);
return 0;
}
futex 시나리오별 동작
| 호출 방식 | clock | 시간 유형 | timens 영향 | 커널 처리 |
|---|---|---|---|---|
FUTEX_WAIT | MONOTONIC | 상대 | 없음 | 직접 사용 |
FUTEX_WAIT_BITSET | MONOTONIC | 절대 | 오프셋 적용 | timens_ktime_to_host() |
FUTEX_WAIT_BITSET | CLOCK_RT | REALTIME | 절대 | 없음 | 직접 사용 |
FUTEX_LOCK_PI | REALTIME | 절대 | 없음 | 직접 사용 |
pthread 라이브러리 영향
/* pthread_cond_timedwait은 내부적으로 futex 사용
*
* 기본 clock: CLOCK_REALTIME -- timens 영향 없음
* CLOCK_MONOTONIC으로 설정 시: timens 오프셋 적용
*
* 시나리오: time NS (offset +5000s) 내에서 */
struct timespec abs;
clock_gettime(CLOCK_MONOTONIC, &abs); /* NS 시간: 6000s */
abs.tv_sec += 10; /* 6010s에 타임아웃 */
pthread_cond_timedwait(&cond, &mutex, &abs);
/* 커널: timens_ktime_to_host(MONOTONIC, 6010s)
* 6010 - 5000 = 1010s (호스트 시간)
* hrtimer: 호스트 1010s에 만료
* 호스트 현재 1000s -- 10초 후 만료 (정확) */
timerfd 동작 심화
timerfd는 파일 디스크립터 기반 타이머로, epoll/select/poll 이벤트 루프에서 핵심적으로 사용됩니다.
Time Namespace에서 timerfd의 정확한 동작을 이해하는 것은 컨테이너 내 이벤트 루프 기반 애플리케이션의 안정성에 직결됩니다.
timerfd 커널 내부 경로
/* fs/timerfd.c -- timerfd_settime 내부 */
static int do_timerfd_settime(int ufd, int flags,
const struct itimerspec64 *new,
struct itimerspec64 *old)
{
struct timerfd_ctx *ctx = file->private_data;
ktime_t expires;
if (flags & TFD_TIMER_ABSTIME) {
/* 절대 시간: namespace 시간 -- 호스트 시간 변환 */
expires = timespec64_to_ktime(new->it_value);
expires = timens_ktime_to_host(ctx->clockid, expires);
} else {
/* 상대 시간: 변환 불필요 */
expires = timespec64_to_ktime(new->it_value);
}
hrtimer_start(&ctx->t.tmr, expires,
(flags & TFD_TIMER_ABSTIME) ?
HRTIMER_MODE_ABS : HRTIMER_MODE_REL);
return 0;
}
timerfd clock 유형별 영향
| timerfd clock | 절대 시간 (ABSTIME) | 상대 시간 | timens 영향 |
|---|---|---|---|
CLOCK_MONOTONIC | 오프셋 역변환 | 영향 없음 | mono 오프셋 |
CLOCK_BOOTTIME | 오프셋 역변환 | 영향 없음 | boot 오프셋 |
CLOCK_REALTIME | 영향 없음 | 영향 없음 | 없음 |
CLOCK_BOOTTIME_ALARM | 오프셋 역변환 | 영향 없음 | boot 오프셋 |
timer_list과 hrtimer 내부
커널 내부의 타이머 서브시스템이 time namespace 오프셋을 어떻게 처리하는지 분석합니다.
hrtimer 오프셋 변환 함수
/* kernel/time/namespace.c -- 핵심 변환 함수 */
ktime_t timens_ktime_to_host(clockid_t clockid, ktime_t tim)
{
struct time_namespace *ns = current->nsproxy->time_ns;
ktime_t offset;
if (ns == &init_time_ns)
return tim; /* init NS: 변환 불필요 */
switch (clockid) {
case CLOCK_MONOTONIC:
case CLOCK_MONOTONIC_RAW:
case CLOCK_MONOTONIC_COARSE:
offset = timespec64_to_ktime(ns->offsets.monotonic);
break;
case CLOCK_BOOTTIME:
case CLOCK_BOOTTIME_ALARM:
offset = timespec64_to_ktime(ns->offsets.boottime);
break;
default:
return tim;
}
/* host_time = ns_time - offset */
return ktime_sub(tim, offset);
}
커널 모듈 타이머 호환성
| 커널 타이머 API | 시간 기준 | timens 영향 |
|---|---|---|
hrtimer_start(ABS) | 호스트 절대 시간 | 커널 내부, 영향 없음 |
hrtimer_start(REL) | 상대 시간 | 영향 없음 |
mod_timer() | jiffies 기반 | 영향 없음 |
schedule_timeout() | jiffies 기반 | 영향 없음 |
userspace timer_settime(ABS) | NS 절대 시간 | 오프셋 역변환 |
다른 네임스페이스와의 상호작용
Time Namespace는 독립적으로 동작하지만, 다른 namespace와 결합될 때 중요한 상호작용이 존재합니다.
PID Namespace와의 상호작용
/proc/PID/timens_offsets 접근 시 PID namespace가 PID 번역을 담당합니다.
중첩 PID NS에서 외부 PID(예: 12345)와 내부 PID(예: 1)가 같은 프로세스를 가리킵니다.
Mount Namespace와 /proc
# 일반적인 namespace 조합 (mount NS 포함)
$ unshare --pid --time --mount --fork /bin/bash
# 새 /proc 마운트 (mount NS 내에서)
$ mount -t proc proc /proc
# 이제 /proc/uptime은 time NS 오프셋 반영
# mount NS 없이 time NS만 사용하면 /proc 정보 불일치 가능
User Namespace와 권한
| 작업 | 필요 권한 | User NS 의미 |
|---|---|---|
unshare(CLONE_NEWTIME) | CAP_SYS_ADMIN | 현재/상위 user NS에서 보유 |
timens_offsets 쓰기 | CAP_SYS_TIME | 소유 user NS에서 보유 |
| 비특권 time NS | 없음 | user NS 내에서 자동 허용 |
# 비특권 사용자도 user NS 내에서 time NS 생성 가능
$ unshare --user --time --map-root-user --fork /bin/bash
# user NS 내에서 root로 매핑됨
$ id
uid=0(root) gid=0(root)
# timens_offsets 쓰기 가능
$ echo "monotonic 1000 0" > /proc/self/timens_offsets
tcp_timestamps)는 jiffies 기반이므로
time namespace 영향을 받지 않습니다. SO_TIMESTAMP 패킷 타임스탬프도 호스트 기준입니다.
보안 고려사항 심화
CAP_SYS_TIME과 Time Namespace
/* CAP_SYS_TIME의 범위 */
/* 1. settimeofday() -- 전역 CLOCK_REALTIME 변경
* CAP_SYS_TIME (init user NS에서만 유효)
* time NS와 무관 */
/* 2. timens_offsets 쓰기
* CAP_SYS_TIME (해당 time NS의 소유 user NS에서)
* user NS 내에서도 가능 */
/* 3. adjtimex() -- NTP 조정
* CAP_SYS_TIME (init user NS)
* time NS와 무관 */
보안 강화 권장사항
# 1. seccomp로 time NS 생성 차단
# CLONE_NEWTIME = 0x80
# Docker seccomp 프로필에서 unshare 제한 가능
# 2. sysctl로 비특권 NS 생성 제한
$ sysctl kernel.unprivileged_userns_clone=0
# 3. time NS 최대 개수 제한
$ cat /proc/sys/user/max_time_namespaces
31924
$ echo 100 > /proc/sys/user/max_time_namespaces
# 4. audit 로그로 time NS 생성 추적
$ sudo auditctl -a always,exit -F arch=b64 \
-S unshare -F a0=0x80 -k timens_create
CLOCK_MONOTONIC 기반 만료 메커니즘(토큰, 캐시)이 우회될 수 있습니다.
보안 결정이 호스트 밖에서 공유되어야 한다면, 컨테이너 내부 clock 대신 신뢰된 외부 시간원을 기준으로 판정해야 합니다.
테스트 및 디버깅 심화
커널 셀프테스트
# 커널 소스의 Time Namespace 셀프테스트
$ cd /usr/src/linux
$ make -C tools/testing/selftests/timens run_tests
# 개별 테스트
$ cd tools/testing/selftests/timens
$ ./timens # 기본 오프셋
$ ./timerfd # timerfd 오프셋
$ ./timer # POSIX 타이머
$ ./clock_nanosleep # nanosleep
$ ./procfs # /proc 인터페이스
$ ./exec_after_fork # fork+exec 후 오프셋 유지
고급 추적 기법
# bpftrace로 timens 오프셋 적용 모니터링
$ sudo bpftrace -e '
kprobe:timens_ktime_to_host {
printf("pid=%d comm=%s clock=%d\n", pid, comm, arg0);
}
kretprobe:timens_ktime_to_host {
printf(" result=%lld\n", retval);
}'
# /proc/PID/maps에서 VVAR 매핑 확인
$ grep vvar /proc/$$/maps
7ffce1a00000-7ffce1a02000 r--p 00000000 00:00 0 [vvar]
# 2페이지 = init NS + timens 페이지
# perf로 vDSO fallback 감지
$ sudo perf record -e 'syscalls:sys_enter_clock_gettime' -p $PID
# vDSO 정상이면 이벤트 거의 없음. 많으면 fallback 발생
자주 발생하는 문제
| 문제 | 원인 | 해결 |
|---|---|---|
CLONE_NEWTIME not supported | 커널 5.6 미만 또는 CONFIG_TIME_NS=n | 커널 업그레이드 |
| 오프셋 후 자식에서 변화 없음 | time_ns_for_children에 미설정 | unshare 후 확인 |
Java System.nanoTime() 불일치 | JVM이 CLOCK_MONOTONIC 사용 | 정상 (오프셋 적용됨) |
Go time.Now() 이상 | monotonic clock 병행 사용 | 정상 동작 |
다른 시간 가상화 접근법 비교
Linux Time Namespace 외에도 시간 가상화를 달성하는 여러 방법이 있습니다.
접근법 비교 표
| 접근법 | 격리 수준 | 대상 clock | 성능 영향 | 커널 요구 | 주요 사용처 |
|---|---|---|---|---|---|
| Time Namespace | 커널 수준 | MONOTONIC, BOOTTIME | 거의 없음 | Linux 5.6+ | 컨테이너, CRIU |
| libfaketime | 라이브러리 | REALTIME, MONOTONIC | LD_PRELOAD | 없음 | 테스트, 개발 |
| KVM/QEMU | 하이퍼바이저 | 모든 clock | 가상화 비용 | KVM | VM 격리 |
| seccomp+ptrace | 시스템 콜 | 선택적 | ptrace 비용 | 없음 | 샌드박스 |
| gVisor | 인터셉트 | 모든 clock | 시스템 콜 비용 | 없음 | 보안 컨테이너 |
libfaketime과의 비교
# libfaketime: LD_PRELOAD로 시간 함수 후킹
$ LD_PRELOAD=/usr/lib/libfaketime.so.1 FAKETIME="+1y" date
# 출력: 내년 날짜 (CLOCK_REALTIME도 변경)
# libfaketime 한계 (vs Time Namespace):
# 1. vDSO 경로 우회 불가 -- clock_gettime() 미후킹 가능
# 2. 멀티프로세스 일관성 보장 어려움
# 3. setuid 바이너리에서 LD_PRELOAD 무시
# 4. 커널 내부 타이머에 영향 불가
# 5. Go/Rust non-glibc 런타임에서 불완전
- 컨테이너 CRIU 마이그레이션: Time Namespace (유일한 올바른 선택)
- 테스트에서 REALTIME 변경: libfaketime
- wall clock까지 포함한 강한 시간 격리: KVM/QEMU
- Go/Rust 바이너리: Time Namespace (libfaketime 불완전)
참고자료
- time_namespaces(7) man page
- LWN: Time namespaces
- LWN: Time namespaces in 5.6
- CRIU Time Namespace Guide
- Time namespace patchset (Dmitry Safonov)
- unshare(2) man page
- setns(2) man page
- vdso(7) man page
kernel/time/namespace.c— Time namespace 핵심 구현 (~500줄)include/linux/time_namespace.h— 헤더 파일, timens_offsets 정의kernel/time/posix-timers.c— POSIX 타이머 오프셋 적용kernel/nsproxy.c— namespace 프록시, timens 전환 로직arch/x86/entry/vdso/— x86 vDSO 구현arch/x86/entry/vdso/vma.c— VVAR 페이지 매핑, vvar_fault()fs/proc/namespaces.c— /proc/[pid]/ns/time 구현- Kernel unshare Documentation
- OCI Runtime Specification - Linux Container
- Namespaces — 전체 namespace 개요
- Linux Containers — 컨테이너 격리
- Timers & Timekeeping — 커널 시간 관리
- ktime/Clock — 커널 시계 내부
- vDSO — 가상 동적 공유 객체
- cgroups — 리소스 제어