Ceph 受墙上时间调整影响分析

我们知道,Ceph 集群在部署时要求各节点通过 NTP 统一各自的系统时间,而且 MON 还会检测 MON 集群各节点间的时间差异,当超过一定阈值(mon_clock_drift_allowed)时产生相应的告警,但实际上在 Ceph 集群对集群各节点(甚至客户端节点)的时间有非常严格的要求(主要包括保持各节点时间同步、禁止时间跳变),本文将对 Ceph 涉及的时间各方面进行一个较详细的分析解读。

需要注意的是,本文引用的代码都基于 Ceph Luminous 版本,不同 Luminous 小版本之间代码可能有细微的差异,但不影响这里的分析。新版本 Ceph 在时间处理上有一些改变,但显然还远未做到对时间不敏感。

此外,不仅仅是 Ceph 自身代码需要处理系统时间调整的问题,本文涉及的墙上时间/单调时间选择、条件变量超时等待、定时器在 Ceph 依赖的其它组件同样会遇到,比如为了规避系统时间调整的问题,dmClock 的代码就有一处将 CLOCK_REALTIME 时间改成 CLOCK_MONOTONIC 的改动,当然这不是本文的重点。

C/C++ 时间

虽然 Ceph 当前已全面支持 C++11/14/17,但是由于历史原因,不可避免的会存在 C 时间 和 C++ 时间混用的情况,因此有必要先了解这两种语言或者说库涉及的时间。

C 时间

C 语言或者说 glibc 中重要的与时间相关的数据结构包括:

typedef __time_t time_t;

__time_t 在 x64 系统上实际上就是一个 64bit 的 long int

struct timespec
{
  __time_t tv_sec;		/* Seconds.  */
#if __WORDSIZE == 64 \
  || (defined __SYSCALL_WORDSIZE && __SYSCALL_WORDSIZE == 64) \
  || __TIMESIZE == 32
  __syscall_slong_t tv_nsec;	/* Nanoseconds.  */
#else
# if __BYTE_ORDER == __BIG_ENDIAN
  int: 32;           /* Padding.  */
  long int tv_nsec;  /* Nanoseconds.  */
# else
  long int tv_nsec;  /* Nanoseconds.  */
  int: 32;           /* Padding.  */
# endif
#endif
};
struct timeval
{
  __time_t tv_sec;		/* Seconds.  */
  __suseconds_t tv_usec;	/* Microseconds.  */
};

显然 struct timespecstruct timeval 并没有什么本质的差异,只是前者的精度是纳秒(ns),后者的精度是微秒(us),两个结构体的两个字段在 x64 系统上也都是 long int 类型。

在 Ceph 代码中,实际上这些类型用的并不多,主要用于与外部接口交互的场景,在 Ceph 内部更多的是使用 utime_t 自定义类型(见下一节分析)。

C++ 时间

C++11 标准库引入了 std::chrono 时间处理相关的基础设施,与其它语言(如 Golang)标准库直接以纳秒作为最小精度单位不同,C++ 的时间概念更多,操作更灵活,当然也更晦涩难懂。

  • duration
template<typename _Rep, typename _Period = ratio<1>>
struct duration;

其中 _Rep 是一个整数类型,如 int64_t, uint64_t 等,用来表示存储时间长度的数据类型;_Period 表示时间单位。

duration 实例用来表示“时间段”,或者说“时间跨度”、“时间长度”,实际上就是小学数学里 数量 * 时间单位 表示时长的 C++ 描述,而 duration 实例的 count() 方法返回 数量 * 时间单位 中的 数量 部分。

举几个例子可能更容易理解:

1 hours, 2 minutes, 3 seconds, 4 milliseconds, 5 microseconds, 6 nanoseconds

如果将这些例子统一成以单位 second 表示,则可以换一种表述方式如下:

1 * 3600 seconds, 2 * 60 seconds, 3 seconds, 4 * 1/1000 seconds, 5 * 1/1000 000 seconds, 6 * 1/1000 000 000 seconds

换成 C++ chrono 的表示方式如下:

// 1 hours:         1 * 3600 seconds
std::chrono::duration<int64_t, std::ratio<3600, 1>>(1);
// 2 minutes:       2 * 60 seconds
std::chrono::duration<int64_t, std::ratio<60, 1>>(2);
// 3 seconds:       3 seconds
std::chrono::duration<int64_t, std::ratio<1, 1>>(3);
// 4 milliseconds:  4 * 1/1000 seconds
std::chrono::duration<int64_t, std::ratio<1, 1000>>(4);
// 5 microseconds:  5 * 1/1000 000 seconds
std::chrono::duration<int64_t, std::ratio<1, 1000 000>>(5);
// 6 nanoseconds:   6 * 1/1000 000 000 seconds
std::chrono::duration<int64_t, std::ratio<1, 1000 000 000>>(6);

std::ratio 是一个缩放比例,以秒(s, second)为基准单位结合 std::ratio 定义的缩放比例,std::chrono::duration 就可以定义各种不同精度的时间单位。

显然,如果仅仅是 1 - 6 这几个数字,_Rep 可以是 (u)int8_t, (u)int16_t, (u)int64_t, (u)int64_t 等任何整数类型,而时间单位 _Period 分别是 3600s, 60s, 1s, 1/1000s, 1/1000 0000s, 1/1000 000 000s。

通常,在 C++ 中我们不会使用上面的方式来表示,取而代之的是:

// 1 hours:         1 * 3600 seconds
std::chrono::hours(1);
// 2 minutes:       2 * 60 seconds
std::chrono::minutes(2);
// 3 seconds:       3 seconds
std::chrono::seconds(3);
// 4 milliseconds:  4 * 1/1000 seconds
std::chrono::milliseconds(4);
// 5 microseconds:  5 * 1/1000 000 seconds
std::chrono::microseconds(5);
// 6 nanoseconds:   6 * 1/1000 000 000 seconds
std::chrono::nanoseconds(6);

背后的实现细节仅仅是 typedef 而已:

typedef ratio<1,       1000000000000000000> atto;
typedef ratio<1,          1000000000000000> femto;
typedef ratio<1,             1000000000000> pico;
typedef ratio<1,                1000000000> nano;
typedef ratio<1,                   1000000> micro;
typedef ratio<1,                      1000> milli;
typedef ratio<1,                       100> centi;
typedef ratio<1,                        10> deci;
typedef ratio<                       10, 1> deca;
typedef ratio<                      100, 1> hecto;
typedef ratio<                     1000, 1> kilo;
typedef ratio<                  1000000, 1> mega;
typedef ratio<               1000000000, 1> giga;
typedef ratio<            1000000000000, 1> tera;
typedef ratio<         1000000000000000, 1> peta;
typedef ratio<      1000000000000000000, 1> exa;

typedef duration<_GLIBCXX_CHRONO_INT64_T, nano>         nanoseconds;
typedef duration<_GLIBCXX_CHRONO_INT64_T, micro>        microseconds;
typedef duration<_GLIBCXX_CHRONO_INT64_T, milli>        milliseconds;
typedef duration<_GLIBCXX_CHRONO_INT64_T>               seconds;
typedef duration<_GLIBCXX_CHRONO_INT64_T, ratio<60>>    minutes;
typedef duration<_GLIBCXX_CHRONO_INT64_T, ratio<3600>>  hours;

当然,如果编译器支持 c++14 标准,则还有更简化的版本[2]:

using namespace std::literals::chrono_literals;

// 1 hours:         1 * 3600 seconds
1h;
// 2 minutes:       2 * 60 seconds
2min;
// 3 seconds:       3 seconds
3s;
// 4 milliseconds:  4 * 1/1000 seconds
4ms;
// 5 microseconds:  5 * 1/1000 000 seconds
5us;
// 6 nanoseconds:   6 * 1/1000 000 000 seconds
6ns;
  • time_point
template<typename _Clock, typename _Dur = typename _Clock::duration>
struct time_point;

其中 _Clock 表示时钟类型,_Dur 表示时间单位,实际上这两个模板参数唯一的作用就是用来定义 time_point 的时间精度。

如果说 duration 是“时间段”,那么 time_point 就是“时间点”,严格来说,应该是时间线上的一个时间点,显然时间线总是有一个时间起点(epoch),这个起点对应不同的 _Clock 有不同的定义。

显然,time_point +/- 一个 duraion 就得到了时间线上的另一个 time_point,而通过 time_since_epoch 接口又能得到从起点以来的 duration

  • clock

clock 是一个用户自定义时钟类型,除了前面提到的定义时间进度,它最重要的接口就是定义 now() 接口以得到(相对起点以来)当前的时间点。

标准库内置了两个预定义的时钟类型: struct system_clock, struct steady_clock,两者的时间精度都是 std::chrono::nanoseconds,两者的差异在于前者表示墙上时间,受 NTP 的控制,其时间起点是 我们常见的 1970/01/01 00:00:00 UTC 时间,因此,可以和 C 中的 time_t 相互转换,而后者是一个机器相关的单调递增时钟,其时间定义不具有普适性的意义。

在 chrono 中有两个比较重要的转换函数需要重点关注一下:

template<typename _ToDur, typename _Rep, typename _Period>
  constexpr __enable_if_is_duration<_ToDur>
duration_cast(const duration<_Rep, _Period>& __d);

template<typename _ToDur, typename _Clock, typename _Dur>
  constexpr typename enable_if<__is_duration<_ToDur>::value,
    time_point<_Clock, _ToDur>>::type
time_point_cast(const time_point<_Clock, _Dur>& __t);

分别对应时长(duration)和时间点(time_point)在不同精度时间单位之间的相互转换,注意针对 time_point 的转换操作都是在同一个时钟(clock)约束下的转换,不可能存在操作将 system_clock 定义的时间点转成 steady_clock 时间点。

此外需要注意的是,上一节提到的 C 中的时间都是 64bit 类型,因此不存在所谓的 2038 年/2106 年问题[1],但讽刺的是,Ceph 自定义了一个时间相关的数据结构 utime_t(主要用在 ceph_clock_now() 相关的场合),并且将两个字段都定义成了 32bit 无符号整型,显然这样就人为的制造了 2106 年问题。

class utime_t {
public:
  struct {
    __u32 tv_sec, tv_nsec;
  } tv;
  ...
};

Ceph 在 Jewel 版本通过 common/ceph_time.h 引入 std::chrono 试图统一时间相关的处理,当然,真正的统一将是一个漫长的过程。

Ceph 定时器

Ceph 中定义了三种定时器,一种是 src/msg/async/Event.h 中定义的 EventCenter::TimeEvent 定时器事件,由 msgr worker 线程作为定时器线程驱动定时器事件执行;一种是 src/common/Timer.h 中定义的 SafeTimer,每个定时器由独立的定时器线程驱动;一种是 src/common/ceph_timer.h 中定义的 ceph::timer,与 SafeTimer 类似,每个定时器由独立的定时器线程驱动。

三种定时器实现背后的运行逻辑并没有本质的差异,简单来说都是定时器线程死循环检测定时器事件定义的时间与当前时间进行比较,一旦定时器事件超时,则调用应用定义的回调函数。

SafeTimer 定时器线程处理逻辑如下:

std::multimap<utime_t, Context*> timer_events;

while (!stopping) {
  utime_t now = ceph_clock_now();

  while (!timer_events.empty()) {
    auto ev = timer_events.begin();

    // is the future now?
    if (ev->first > now)
      break;

    timer_events.erase(ev);

    Context *callback = ev->second;
    callback->complete(0);
  }

  if (timer_events.empty())
    cond.Wait(lock);
  else
    cond.WaitUntil(lock, timer_events.begin()->first);
}

ceph::timer 定时器线程与 SafeTimer 的逻辑几无二致:

while (!suspended) {
  typename TC::time_point now = TC::now();

  while (!timer_events.empty()) {
    auto p = timer_events.begin();
    // Should we wait for the future?
    if (p->t > now)
      break;

    event& e = *p;
    timer_events.erase(e);

    e.f();
    delete &e;
  }

  if (timer_events.empty())
    cond.wait(l);
  else
    cond.wait_until(l, timer_events.begin()->t);
}

EventCenter 定时器线程处理逻辑如下:

std::multimap<ceph::coarse_mono_clock::time_point, TimeEvent> time_events;

while (!w->done) {
  int timeo = 0;
  auto it = time_events.begin();
  auto now = ceph::coarse_mono_clock::now();
  ceph::coarse_mono_clock::time_point shortest = now + std::chrono::microseconds(30000000); 
  if (it != time_events.end() && shortest >= it->first) {
    shortest = it->first;

    if (shortest > now) {
      timeo = std::chrono::duration_cast<std::chrono::microseconds>(shortest - now).count();
    } else {
      timeo = 0;
    }
  }

  struct timeval tv;
  tv.tv_sec = timeo / 1000000;
  tv.tv_usec = timeo % 1000000;

  vector<FiredFileEvent> fired_events;
  numevents = driver->event_wait(fired_events, &tv);

  ceph::coarse_mono_clock::time_point now = ceph::coarse_mono_clock::now();
  while (!time_events.empty()) {
    auto it = time_events.begin();
    if (now >= it->first) {
      time_events.erase(it);

      TimeEvent &e = it->second;
      EventCallbackRef cb = e.time_cb;
      cb->do_request(id);
    } else {
      break;
    }
  }
}

三种定时器实现的差异主要有两点:

  1. 时间定义

SafeTimer 定时器使用 utime_t,也就是使用的是墙上时间,墙上时间受系统时间调整影响;

ceph::timer 定时器支持指定自定义时钟类型(墙上时钟或单调时钟),如果使用的是墙上时钟,墙上时间受系统时间调整影响;

EventCenter 定时器使用的 ceph::coarse_mono_clock::time_pointstd::chrono::steady_clock 类似,不受系统时间调整影响。

  1. 线程超时等待

SafeTimer 定时器使用的超时等待功能本质上是一个 pthread_cond_t 条件变量的超时等待接口 pthread_cond_timedwait,除非设置 pthread_cond_t 使用 CLOCK_MONOTONIC 时钟,否则超时等待功能受系统时间调整影响[3];

ceph::timer 定时器的超时等待功能使用 std::condition_variable 提供的超时等待接口,需要注意的是 std::condition_variableCond 类似,实际上也只是 pthread_cond_t 的一个简单封装,因此同样受系统时间调整的影响[4];

EventCenter 定时器的超时等待功能在 Linux 平台复用了 epoll 的超时等待功能,底层的超时逻辑由内核的高精度定时器实现,不受系统时间调整影响。

除了直接使用上面提到的定时器,在 Ceph 代码里可能也会直接用到条件变量的超时等待功能,在代码里 Ceph 封装 pthread_cond_t 的自定义类型 Cond 和 C++ 标准库的 std::condition_variable 出现了混用的情况,这两种类型的条件变量都受系统时间调整的影响。

2019年8月发布的 glibc 2.30 版本新增了 pthread_cond_clockwait 接口[5],稍后 GCC C++ 标准库的 std::condition_variable 实现了对 pthread_cond_clockwait 接口的支持,std::condition_variable 的超时等待功能直至此时才不受系统时间调整的影响。

从下表可以看到,glibc 2.30+ 的普遍支持可能还需要好些年以后:

系统版本 glibc 版本
Ubuntu 18.04 2.27
Ubuntu 20.04 2.31
CentOS 7 2.17
CentOS 8 2.28
openSUSE 42.3 2.22
openSUSE 15.2 2.26
openSUSE 15.3 2.31

MON 时间

主 MON 有在更新 Paxos 租期(lease)时使用的是墙上时间(ceph_clock_now()),这个时间会通过 MMonPaxos 消息广播给所有从 MON:

void Paxos::extend_lease()
{
  lease_expire = ceph_clock_now();
  lease_expire += g_conf->mon_lease;

  // bcast
  for (set<int>::const_iterator p = mon->get_quorum().begin();
      p != mon->get_quorum().end(); ++p) {

    if (*p == mon->rank)
      continue;

    MMonPaxos *lease = new MMonPaxos(mon->get_epoch(), MMonPaxos::OP_LEASE, ceph_clock_now());
    lease->lease_timestamp = lease_expire;

    mon->messenger->send_message(lease, mon->monmap->get_inst(*p));
  }
}

从 MON 收到 MMonPaxos 消息后会使用主 MON 的 lease_expire 更新自身的 lease_expire

void Paxos::handle_lease(MonOpRequestRef op)
{
  MMonPaxos *lease = static_cast<MMonPaxos*>(op->get_req());

  // extend lease
  if (lease_expire < lease->lease_timestamp) {
    lease_expire = lease->lease_timestamp;
  }

  // ack
  MMonPaxos *ack = new MMonPaxos(mon->get_epoch(), MMonPaxos::OP_LEASE_ACK,
         ceph_clock_now());

  lease->get_connection()->send_message(ack);
}

lease_expire 的应用场景是判断 Paxos 是否可读写:

bool Paxos::is_lease_valid()
{
  return 
    (mon->get_quorum().size() == 1) ||
    (ceph_clock_now() < lease_expire);
}

bool Paxos::is_readable(version_t v)
{
  return 
    (mon->is_peon() || mon->is_leader()) &&
    (is_active() || is_updating() || is_writing()) &&
    last_committed > 0 &&
    is_lease_valid();
}

bool Paxos::is_writeable()
{
  return
    mon->is_leader() &&
    is_active() &&
    is_lease_valid();
}

显然,如果 MON 之间的时间不一致(严格来说,从 MON 相比主 MON 时间超前超过允许的租期),会导致从 MON Paxos::is_readable 的判断出错,从而影响 PaxosService::is_readable 的判断,最终影响 MMonGetVersion 等各种消息的处理:

bool PaxosService::is_readable(version_t ver = 0)
{
  if (ver > get_last_committed() ||
      !paxos->is_readable(0) ||
      get_last_committed() == 0)
    return false;
  return true;
}

void Monitor::handle_get_version(MonOpRequestRef op)
{
  MMonGetVersion *m = static_cast<MMonGetVersion*>(op->get_req());

  PaxosService *svc = NULL;
  if (m->what == "mdsmap") {
    svc = mdsmon();
  } else if (m->what == "fsmap") {
    svc = mdsmon();
  } else if (m->what == "osdmap") {
    svc = osdmon();
  } else if (m->what == "monmap") {
    svc = monmon();
  }

  if (!svc->is_readable()) {
    svc->wait_for_readable(op, new C_RetryMessage(this, op));
    return;
  }

  ...
}

bool PaxosService::dispatch(MonOpRequestRef op)
{
  PaxosServiceMessage *m = static_cast<PaxosServiceMessage*>(op->get_req());

  // make sure our map is readable and up to date
  if (!is_readable(m->version)) {
    wait_for_readable(op, new C_RetryMessage(this, op), m->version);
    return true;
  }

  ...
}

cephx 时间

在 Nautilus 版本之前,cephx 提供的认证和授权功能是一个很鸡肋的东西,对于 Ceph 这种内网应用没有太大的意义,但是从 Nautilus 的 msgr v2 开始,cephx 开始用于端到端的消息加密[7],因此开始具有一定的实际应用价值。

cephx 的工作流程大概如下图所示:

cephx 与墙上时间强关联的地方主要是 rotating keys 的处理[8],AuthMonitor 定时更新 rotating keys:

AuthMonitor::tick
  KeyServer::updated_rotating
    KeyServer::_rotate_secret

int KeyServer::_rotate_secret(uint32_t service_id)
{
  RotatingSecrets& r = data.rotating_secrets[service_id];
  int added = 0;
  utime_t now = ceph_clock_now();
  double ttl = service_id == CEPH_ENTITY_TYPE_AUTH ? cct->_conf->auth_mon_ticket_ttl : cct->_conf->auth_service_ticket_ttl;

  while (r.need_new_secrets(now)) {
    ExpiringCryptoKey ek;
    generate_secret(ek.key);
    if (r.empty()) {
      ek.expiration = now;
    } else {
      utime_t next_ttl = now;
      next_ttl += ttl;
      ek.expiration = MAX(next_ttl, r.next().expiration);
    }
    ek.expiration += ttl;
    uint64_t secret_id = r.add(ek);
    added++;
  }
  return added;
}

可以看到,ExpiringCryptoKey 的超时时间由主 MON 的当前时间决定。

OSD, MDS, MGR 中的 MonClient 定时更新 rotating keys:

static inline bool KeyStore::auth_principal_needs_rotating_keys(EntityName& name)
{
  uint32_t ty(name.get_type());
  return ((ty == CEPH_ENTITY_TYPE_OSD)
      || (ty == CEPH_ENTITY_TYPE_MDS)
      || (ty == CEPH_ENTITY_TYPE_MGR));
}

MonClient::tick
  MonClient::_check_auth_tickets
    CephxClientHandler::need_tickets
    MonClient::_check_auth_rotating
      if (!KeyStore::auth_principal_needs_rotating_keys())
	    return;
      RotatingKeyRing::need_new_secrets
	    RotatingSecrets::need_new_secrets
	  MAuth *m = new MAuth;
      auth->build_rotating_request
	  _send_mon_message(m);

const ExpiringCryptoKey& RotatingSecrets::current() const {
  map<uint64_t, ExpiringCryptoKey>::const_iterator p = secrets.begin();
  ++p;
  return p->second;
}

bool RotatingSecrets::need_new_secrets(utime_t now) const {
  return secrets.size() < KEY_ROTATE_NUM || current().expiration <= now;
}

如果 OSD, MDS, MGR 所在的节点的时间相对 MON 节点的时间落后,将导致 OSD, MDS, MGR 的 rotating keys 得不到更新,如果相对 MON 节点时间超前,将导致发起频繁的 rotating keys 更新,虽然已经拿到的是 MON 能够提供的最新的 rotating keys 了。

OSD, MDS, MGR 分别在如下位置检测通信对端携带的 ticket 以进行授权检测:

OSD::ms_verify_authorizer
  RotatingKeyRing *keys = monc->rotating_secrets.get();
  CephxAuthorizeHandler::verify_authorizer

MDSDaemon::ms_verify_authorizer
  RotatingKeyRing *keys = monc->rotating_secrets.get();
  CephxAuthorizeHandler::verify_authorizer

DaemonServer::ms_verify_authorizer
  RotatingKeyRing *keys = monc->rotating_secrets.get();
  CephxAuthorizeHandler::verify_authorizer

CephxAuthorizeHandler::verify_authorizer
  cephx_verify_authorizer
    if (ticket.secret_id == (uint64_t)-1) { // MON, refer to Monitor::ms_get_authorizer
      EntityName name;
      name.set_type(service_id);
      if (!keys->get_secret(name, service_secret)) {
        return false;
      }
    } else {
      if (!keys->get_service_secret(service_id, ticket.secret_id, service_secret)) {
        return false;
      }
    }

如果 OSD, MDS, MGR 拿到 rotating keys 与 MON AuthMonitor 生成 rotating keys 的节奏不一致(OSD, MDS, MGR 时间落后,迟迟拿不到新的 rotating keys),keys->get_service_secret 会出现找不到 ticket 携带的 secret id(实际上是 rotating keys 的递增版本号)的现象,从而出现授权检测失败。

此外,需要注意 MonClient 在设置或判断 ticket 超期的时候也会用到墙上时间:

bool CephXTicketHandler::verify_service_ticket_reply(CryptoKey& secret,
                 bufferlist::iterator& indata)
{
  CephXServiceTicket msg_a;
  decode_decrypt(cct, msg_a, secret, indata, error);
  
  bufferlist service_ticket_bl;
  ::decode(service_ticket_bl, indata);
  bufferlist::iterator iter = service_ticket_bl.begin();
  ::decode(ticket, iter);

  session_key = msg_a.session_key;
  if (!msg_a.validity.is_zero()) {
    expires = ceph_clock_now();
    expires += msg_a.validity;
    renew_after = expires;
    renew_after -= ((double)msg_a.validity.sec() / 4);
  }
  
  have_key_flag = true;
  return true;
}

MonClient::tick
  MonClient::_check_auth_tickets
    CephxClientHandler::need_tickets
	  CephXTicketManager::validate_tickets
	    CephXTicketManager::set_have_need_key
		  CephXTicketHandler::need_key
		  CephXTicketHandler::have_key

bool CephXTicketHandler::need_key() const
{
  if (have_key_flag) {
    return (!expires.is_zero()) && (ceph_clock_now() >= renew_after);
  }

  return true;
}

bool CephXTicketHandler::have_key()
{
  if (have_key_flag) {
    have_key_flag = ceph_clock_now() < expires;
  }

  return have_key_flag;
}

因此墙上时间的调整会影响 ticket 的更新。

夏令时

在我父母年轻时候工作的那个年代,确实存在夏令时一说,但我们这一代就基本上没有接触过了,想不到我们的产品还会部署到使用夏令时的那些国家或地区。

Ceph 实际上并不受夏令时的影响(因为前面提到的 C/C++ 与时间相关的接口都不受夏令时影响),只是有些运维人员并不懂夏令时,出现了手动调整 NTP 以适应夏令时的错误操作。

只要理解了下面这几条,应该就理解了夏令时:

  1. 夏令时的英文是 Daylight Saving Time(DST);
  2. 夏令时是一个政府政策,而不是一个客观的物理规律;
  3. NTP 永远使用 UTC 时间,不存在时区以及夏令时的概念;
  4. 在 Linux 系统上,根据时区的设置,当需要进入夏令时的时间到了,系统会自动进行本地显示时间的调整(相应的 c 接口提供的 localtime 等 API 返回的结果会自动调整),具体哪个时区存在夏令时,以及什么时候进行时间的调整,这是由系统的 tzdata 这个安装包里面的数据决定的,完全不需要管理员进行手工调整,更不应该直接修改 NTP 时钟源(实际上如果这样做应该会存在冲突);
  5. 参考第 2 条,tzdata 是根据各国政府的政策动态更新的;
  6. 参考第 2 条,客户如果需要使用自定义的夏令时方案(即与 tzdata 中定义的,实际上也就是与国家政策不一致),则需要定制 tzdata 数据;
  7. Ceph 不受 tzdata 规则所引起的本地时间变化的影响;

显然,只要制作正确的 tzdata 数据,就解决了夏令时要求的时间跳变问题。

解决方案

前面分析了这么多,简单总结下来,Ceph 有如下一些地方受时间(如2106 年问题、时间调整、各节点时间不一致)影响:

  1. 全系统的 2106 年问题;
  2. 条件变量超时等待;
  3. 定时器;
  4. Paxos 租期过期处理;
  5. cephx tickets/rotaing keys 过期处理;

显然,2106 年问题涉及消息兼容性的处理,是一个很大的问题,而且未来还有几十年的时间来考虑处理方案,因此这里不准备讨论这个问题,下面就后面的四个问题进行逐一分析。

条件变量超时等待

SafeTimerceph::timer 的定时器实现都依赖于 pthread_cond_t 条件变量的超时等待,因此先分析如何消除时间调整对条件变量的影响。这里不考虑使用新接口 pthread_cond_clockwait 这种情况,毕竟 glibc 新版本不是那么容易升级的。

需要注意的是,条件变量的超时等待有两种类型的接口,一种使用相对时间,如 Cond 提供的 WaitInterval 接口,condition_variable 提供的 wait_for 接口;一种使用绝对时间,如 Cond 提供的 WaitUntil 接口,condition_variable 提供的 wait_until 接口。

一旦 Condcondition_variable 对底层 pthread_cond_t 条件变量设置了 CLOCK_MONOTONIC 属性,那么在使用绝对时间接口(WaitUntil, wait_until)进行超时处理时如果传递的参数是墙上时间,则需要转换成单调时间,为了避免墙上时间到单调时间的转换(以及避免在转换过程中系统时间可能发生的跳变),应该尽量避免绝对时间接口的使用。

  • Cond

Ceph 自己封装的条件变量,如果超时等待要做到不受系统时间调整影响,只需要在条件变量初始化时设置 CLOCK_MONOTONIC 属性即可[9]:

// src/common/CondVar.h

   Cond() : waiter_mutex(NULL) {
-    int r = pthread_cond_init(&_c,NULL);
+    int r = 0;
+#if !defined(__APPLE__)
+    pthread_condattr_t attr;
+    pthread_condattr_init(&attr);
+    pthread_condattr_setclock(&attr, CLOCK_MONOTONIC);
+    r = pthread_cond_init(&_c, &attr);
+    pthread_condattr_destroy(&attr);
+#else
+    r = pthread_cond_init(&_c,NULL);
+#endif
     assert(r == 0);
   }

对入参墙上时间到单调时间的转换有几种思路,一是在 Cond::WaitUntil 内部进行转换,一是在调用者那一层进行转换。下一节将分析因为 SafeTimer 定时器的原因必须选择后一种转换方法,也就是说 Cond::WaitUntil 不需要做任何修改,但需要注意的是两个 Cond::WaitInterval 接口的实现在获取当前时间时都使用了墙上时钟,需要修改成使用单调时钟。

  • std::condition_variable

C++ 标准库的条件变量稍微有点麻烦,但可以自己重新封装 condition_variable 的实现[11][12][13][14],并与 Cond 一样,在初始化底层 pthread_cond_t 变量时设置 CLOCK_MONOTONIC 属性即可:

// <condition_variable> -*- C++ -*-

// Copyright (C) 2008-2020 Free Software Foundation, Inc.
//
// This file is part of the GNU ISO C++ Library.  This library is free
// software; you can redistribute it and/or modify it under the
// terms of the GNU General Public License as published by the
// Free Software Foundation; either version 3, or (at your option)
// any later version.

// This library is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
// GNU General Public License for more details.

// Under Section 7 of GPL version 3, you are granted additional
// permissions described in the GCC Runtime Library Exception, version
// 3.1, as published by the Free Software Foundation.

// You should have received a copy of the GNU General Public License and
// a copy of the GCC Runtime Library Exception along with this program;
// see the files COPYING3 and COPYING.RUNTIME respectively.  If not, see
// <http://www.gnu.org/licenses/>.

#pragma once

#include <chrono>
#include <mutex>
#include <condition_variable>

#include "include/assert.h"

namespace ceph {
namespace gcc {

using namespace std::chrono;

#if __cplusplus >= 201703L

template<typename _ToDur, typename _Rep, typename _Period>
constexpr __enable_if_is_duration<_ToDur>
ceil(const duration<_Rep, _Period>& __d)
{
  auto __to = duration_cast<_ToDur>(__d);
  if (__to < __d)
    return __to + _ToDur{1};
  return __to;
}

#else // ! C++17

template<typename _Tp, typename _Up>
constexpr _Tp
__ceil_impl(const _Tp& __t, const _Up& __u)
{
  return (__t < __u) ? (__t + _Tp{1}) : __t;
}

// C++11-friendly version of std::chrono::ceil<D> for internal use.
template<typename _ToDur, typename _Rep, typename _Period>
constexpr _ToDur
ceil(const duration<_Rep, _Period>& __d)
{
  return __ceil_impl(duration_cast<_ToDur>(__d), __d);
}

#endif // C++17

} // gcc
} // ceph

namespace ceph {
namespace gcc {

namespace chrono = std::chrono;

using std::mutex;
using std::unique_lock;
using std::cv_status;

class condition_variable
{
  using steady_clock = chrono::steady_clock;
  using system_clock = chrono::system_clock;
  using __clock_t = steady_clock;
  typedef pthread_cond_t __native_type;

  __native_type _M_cond;

public:
  typedef __native_type* native_handle_type;

  condition_variable() noexcept {
    int r = 0;
#if !defined(__APPLE__)
    pthread_condattr_t attr;
    pthread_condattr_init(&attr);
    pthread_condattr_setclock(&attr, CLOCK_MONOTONIC);
    r = pthread_cond_init(&_M_cond, &attr);
    pthread_condattr_destroy(&attr);
#else
    r = pthread_cond_init(&_M_cond, nullptr);
#endif
    ceph_assert(r == 0);
  }
  ~condition_variable() noexcept {
    /* int __e = */ pthread_cond_destroy(&_M_cond);
  }

  condition_variable(const condition_variable&) = delete;
  condition_variable& operator=(const condition_variable&) = delete;

  void
  notify_one() noexcept {
    /* int __e = */ pthread_cond_signal(&_M_cond);
  }

  void
  notify_all() noexcept {
    /* int __e = */ pthread_cond_broadcast(&_M_cond);
  }

  void
  wait(unique_lock<mutex>& __lock) noexcept {
    /* int __e = */ pthread_cond_wait(&_M_cond, __lock.mutex()->native_handle());

    /*
    if (__e)
      std::terminate();
    */
  }

  template<typename _Predicate>
    void
    wait(unique_lock<mutex>& __lock, _Predicate __p)
    {
      while (!__p())
        wait(__lock);
    }

  template<typename _Duration>
    cv_status
    wait_until(unique_lock<mutex>& __lock,
               const chrono::time_point<steady_clock, _Duration>& __atime)
    { return __wait_until_impl(__lock, __atime); }

  template<typename _Duration>
    cv_status
    wait_until(unique_lock<mutex>& __lock,
               const chrono::time_point<system_clock, _Duration>& __atime)
    {
      // return __wait_until_impl(__lock, __atime);
      return wait_until<system_clock, _Duration>(__lock, __atime);
    }

  template<typename _Clock, typename _Duration>
    cv_status
    wait_until(unique_lock<mutex>& __lock,
               const chrono::time_point<_Clock, _Duration>& __atime)
    {
#if __cplusplus > 201703L
      static_assert(chrono::is_clock_v<_Clock>);
#endif
      using __s_dur = typename __clock_t::duration;
      const typename _Clock::time_point __c_entry = _Clock::now();
      const __clock_t::time_point __s_entry = __clock_t::now();
      const auto __delta = __atime - __c_entry;
      const auto __s_atime = __s_entry +
        ceph::gcc::ceil<__s_dur>(__delta);

      if (__wait_until_impl(__lock, __s_atime) == cv_status::no_timeout)
        return cv_status::no_timeout;
      // We got a timeout when measured against __clock_t but
      // we need to check against the caller-supplied clock
      // to tell whether we should return a timeout.
      if (_Clock::now() < __atime)
        return cv_status::no_timeout;
      return cv_status::timeout;
    }

  template<typename _Clock, typename _Duration, typename _Predicate>
    bool
    wait_until(unique_lock<mutex>& __lock,
               const chrono::time_point<_Clock, _Duration>& __atime,
               _Predicate __p)
    {
      while (!__p())
        if (wait_until(__lock, __atime) == cv_status::timeout)
          return __p();
      return true;
    }

  template<typename _Rep, typename _Period>
    cv_status
    wait_for(unique_lock<mutex>& __lock,
             const chrono::duration<_Rep, _Period>& __rtime)
    {
      using __dur = typename steady_clock::duration;
      return wait_until(__lock,
                        steady_clock::now() +
                        ceph::gcc::ceil<__dur>(__rtime));
    }

  template<typename _Rep, typename _Period, typename _Predicate>
    bool
    wait_for(unique_lock<mutex>& __lock,
             const chrono::duration<_Rep, _Period>& __rtime,
             _Predicate __p)
    {
      using __dur = typename steady_clock::duration;
      return wait_until(__lock,
                        steady_clock::now() +
                        ceph::gcc::ceil<__dur>(__rtime),
                        std::move(__p));
    }

  native_handle_type
  native_handle()
  { return &_M_cond; }

private:
  template<typename _Dur>
    cv_status
    __wait_until_impl(unique_lock<mutex>& __lock,
                      const chrono::time_point<steady_clock, _Dur>& __atime)
    {
      auto __s = chrono::time_point_cast<chrono::seconds>(__atime);
      auto __ns = chrono::duration_cast<chrono::nanoseconds>(__atime - __s);

      struct timespec __ts =
        {
          static_cast<std::time_t>(__s.time_since_epoch().count()),
          static_cast<long>(__ns.count())
        };

      pthread_cond_timedwait(&_M_cond, __lock.mutex()->native_handle(), &__ts);

      return (steady_clock::now() < __atime
              ? cv_status::no_timeout : cv_status::timeout);
    }
};

} // namespace gcc
} // namespace ceph

所有引用 std::condition_variable 并使用了超时等待的地方都要替换成 ceph::gcc::condition_variable,如:

-      std::condition_variable cond;
+      ceph::gcc::condition_variable cond;

在 Ceph 新版本中,多数地方在使用 std::condition_variable 条件变量的地方都引用的是 ceph::condition_variable,因此会更容易修改。

对于 wait_until 入参墙上时间到单调时间的转换在 ceph::gcc::condition_variable 中已经做了相关的处理,即将所有非 steady_clock(__clock_t) 转成 steady_clock

template<typename _Duration>
  cv_status
  wait_until(unique_lock<mutex>& __lock,
             const chrono::time_point<system_clock, _Duration>& __atime)
  {
    // return __wait_until_impl(__lock, __atime);
    return wait_until<system_clock, _Duration>(__lock, __atime);
  }

template<typename _Clock, typename _Duration>
  cv_status
  wait_until(unique_lock<mutex>& __lock,
             const chrono::time_point<_Clock, _Duration>& __atime)
  {
#if __cplusplus > 201703L
    static_assert(chrono::is_clock_v<_Clock>);
#endif
    using __s_dur = typename __clock_t::duration;
    const typename _Clock::time_point __c_entry = _Clock::now();
    const __clock_t::time_point __s_entry = __clock_t::now();
    const auto __delta = __atime - __c_entry;
    const auto __s_atime = __s_entry +
      ceph::gcc::ceil<__s_dur>(__delta);

    if (__wait_until_impl(__lock, __s_atime) == cv_status::no_timeout)
      return cv_status::no_timeout;
    // We got a timeout when measured against __clock_t but
    // we need to check against the caller-supplied clock
    // to tell whether we should return a timeout.
    if (_Clock::now() < __atime)
      return cv_status::no_timeout;
    return cv_status::timeout;
  }

定时器

SafeTimerceph::timer 的定时器实现分别基于 Condcondition_variable 条件变量的超时等待,上一节已经对为适应系统时间调整条件变量所需的改动进行了分析,在此之上的定时器的改动相对来说就比较简单。

  • SafeTimer

SafeTimer 定时器线程总是拿定时器事件的定时时间与当前时间(now)进行对比:

utime_t now = ceph_clock_now();

while (!timer_events.empty()) {
  auto ev = timer_events.begin();

  // is the future now?
  if (ev->first > now)
    break;

  ...
}

为了避免系统时间调整影响时间对比,所以 now 必须改成单调时钟的当前时间,相应的定时器事件的定时时间也必须是单调时钟的时间,这也就解释了上一节提到的 Cond::WaitUntil 为什么需要在调用者那一层进行时间转换。

SafeTimer 注册定时器事件的接口有两个:SafeTimer::add_event_afterSafeTimer::add_event_at,由于定时器时间的定时时间要求是单调时间,因此这两个接口也要相应的进行时间转换。

  • ceph::timer

当前 Ceph 代码中 ceph::timer 实例使用的都是单调时钟,因此不需要像 SafeTimer 一样进行墙上时间到单调时间的转换,唯一需要处理的就是将 std::condition_variable 替换成 ceph::gcc::condition_variable 以避免系统时间调整对条件变量超时等待带来的影响。

与条件变量的处理建议类似,为了避免墙上时间到单调时间的转换(以及避免在转换过程中系统时间可能发生的跳变),应该尽量避免绝对时间定时接口的使用。

Paxos 租期过期处理

Paxos::lease_expire 当前使用的是墙上时间,为了避免系统时间调整的影响,需要转成单调时间,但是从 MON 延长租期使用的是主 MON MMonPaxos 消息中携带的 lease_timestamp 时间(即主 MON 自身记录的 lease_expire),而单调时钟一旦离开当前机器就变得毫无意义,因此从 MON 的租期计算不能再使用 lease_timestamp,而必须基于自身的当前时间加上租期:

lease_expire = ceph_mono_now();
lease_expire += g_conf->mon_lease;

当然,Paxos::is_lease_valid() 接口的判断也需要做相应的改变,以使用单调时间的对比。

需要注意的是,不依赖于主 MON 统一的时间来处理租期,在网络故障等情况下,过期的消息可能会错误的延长从 MON 的租期。

cephx tickets/rotaing keys 过期处理

cephx 超期处理涉及两部分:一是 tickets 更新,一是 rotating keys 更新。

对于 tickets 的超时在 CephXServiceTicket 携带的是相对时间:

// CephXTicketHandler::verify_service_ticket_reply

expires = ceph_clock_now();
expires += msg_a.validity;
renew_after = expires;
renew_after -= ((double)msg_a.validity.sec() / 4);

因此转成单调时间的处理比较简单,在生成本地过期时间和判断是否过期的地方使用单调时间即可。

AuthMonitor 生成 rotating keys 时超时使用的是绝对时间:

// KeyServer::_rotate_secret

utime_t now = ceph_clock_now();

while (r.need_new_secrets(now)) {
  if (r.empty()) {
    ek.expiration = now;
  } else {
    utime_t next_ttl = now;
    next_ttl += ttl;
    ek.expiration = MAX(next_ttl, r.next().expiration);
  }
  ek.expiration += ttl;
}

因此,只用将 ExpiringCryptoKey::expiration 转成相对时间,然后超期判断使用本地单调时间就可以了。

理论上完成上述两部分的修改就可以解决各节点时间不一致和系统时间调整带来的问题,但是 cephx 涉及时间的地方比较多,稍不注意就很容易漏掉一些地方,因此,我们也可以换一种思路,将 cephx 禁用,或者将 cephx 的默认超期时间设置成一个非常大的值,综合 2106年问题,以及 rotating keys 总是一次性生成 3 个,在当前这个时间点,设置成 20年是非常安全的数字:

// src/common/options.cc

     Option("auth_mon_ticket_ttl", Option::TYPE_FLOAT, Option::LEVEL_ADVANCED)
-    .set_default(12_hr)
+    .set_default(7300_day)
     .set_description(""),
 
     Option("auth_service_ticket_ttl", Option::TYPE_FLOAT, Option::LEVEL_ADVANCED)
-    .set_default(1_hr)
+    .set_default(7300_day)
     .set_description(""),

这样只要主 MON 节点的时钟不往未来调整超过 60年,那么一切都是安全的。

参考资料

[1] Year 2038 problem
https://en.wikipedia.org/wiki/Year_2038_problem

[2] User-defined literals
https://en.cppreference.com/w/cpp/language/user_literal

[3] CLOCK_MONOTONIC and pthread_mutex_timedlock / pthread_cond_timedwait
https://stackoverflow.com/questions/14248033/clock-monotonic-and-pthread-mutex-timedlock-pthread-cond-timedwait

[4] Bug 41861 (DR887) - [DR 887][C++0x] <condition_variable> does not use monotonic_clock
https://gcc.gnu.org/bugzilla/show_bug.cgi?id=41861

[5] nptl: Add POSIX-proposed pthread_cond_clockwait
https://github.com/bminor/glibc/commit/afe4de7d283ebd88157126c5494ce1796194c16e

[6] PR libstdc++/41861 Add full steady_clock support to condition_variable
https://github.com/gcc-mirror/gcc/commit/ad4d1d21ad5c515ba90355d13b14cbb74262edd2

[7] MESSENGER V2
https://docs.ceph.com/en/latest/rados/configuration/msgr2/

[8] mon: set mon_clock_drift_allowed default to 1.0
https://github.com/ceph/ceph/pull/24825

[9] ConditionVariable
https://chromium.googlesource.com/chromium/src/base/+/master/synchronization/condition_variable_posix.cc

[10] std::condition_variable::wait_for prone to early/late timeout with libstdc++
http://randombitsofuselessinformation.blogspot.com/2018/06/its-about-time-monotonic-time.html

[11] std::condition_variable
https://github.com/gcc-mirror/gcc/blob/releases/gcc-10.3.0/libstdc++-v3/include/std/condition_variable
https://github.com/gcc-mirror/gcc/blob/releases/gcc-10.3.0/libstdc++-v3/src/c++11/condition_variable.cc

[12] PR libstdc++/68519 use native duration to avoid rounding errors
https://github.com/gcc-mirror/gcc/commit/83fd5e73b3c16296e0d7ba54f6c547e01c7eae7b

[13] libstdc++: Avoid rounding errors on custom clocks in condition_variable
https://github.com/gcc-mirror/gcc/commit/e05ff30078e80869f2bf3af6dbdbea134c252158

[14] libstdc++: Fix chrono::__detail::ceil to work with C++11
https://github.com/gcc-mirror/gcc/commit/53ad6b1979f4bd7121e977c4a44151b14d8a0147


最后修改于 2021-06-09