2019/11/23

Circular Queue와 Iterator


Circular Queue(Buffer)를 c++로 구현해 보았다. Ring Buffer로 오래 전부터 네트워크 프로그램에서 많이 사용하던 놈이다. 컨테이너 Class 들은 c++을 배우고, 또 적응하는데 매우 도움이 된다. 단지 공부하려고 만든 것은 아니고 실시간 차트 데이터를 저장하는데 적용해 보려고 만든 것이다.

그 간에는 std::deque을 사용했었는데 뒤에서 채우고 앞에서 지우는 식으로 고정크기를 유지했는데, 메모리 관리를 내 맘대로 못하는게 문제였고 성능 문제가 생길 수 밖에 없었다.  아예 첨부터 고정크기 메모리를 할당해서 사용하는게 효율적이고, 실시간 데이터는 시간 단위로 저장하면 되기 때문에 필요한 메모리 크기를 사전에 예측할 수 있다. 결론은 피부로 느낄만큼 성능향상 효과가 있더라.

성능을 높이는 김에 메모리 할당 외에 fast modulo 함수를 사용했다. Circular Buffer의 특성상 메모리 상에서 현재 데이터의 위치를 빠르게 알아내야 하는데 나머지(%) 연산을 해야만 한다. 다행히도 나누는 수가 양수이고 2의 거듭제곱(power of 2)이라면 bit 연산으로 나머지를 빠르게 계산할 수 있다. 여기서 나누는 수는 고정크기 용량 또는 최대 저장 크기이다. 그리고, 사용자가 용량을 적당히 지정하더라도 무조건 가까운 크기의 2의 거듭제곱으로 용량을 설정하도록 하였다. 이렇게 하지 않으면 나머지 연산 결과가 엉뚱하게 나오기 때문이다.

Circular Queue

// Circular Queue(Buffer) and Circular Iterator C++ Implementation
//
// This program is copyright (c) 2019 by Umundu @ https://zapary.blogspot.com .
// It is distributed under the terms of the GNU LGPL version 3, as detailed in
// https://opensource.org/licenses/lgpl-3.0.html .

#include <memory>

template<typename TC>
class CircularIterator;

template<typename TD>
class CircularQueue
{
public:
  using value_type      = TD;
  using reference       = TD&;
  using const_reference = TD const&;
  using pointer         = TD*;
  using const_pointer   = TD const*;
  using difference_type = std::ptrdiff_t;
  using size_type       = std::size_t;
  using iterator        = CircularIterator<CircularQueue>;
  using const_iterator  = CircularIterator<const CircularQueue>;
  using riterator       = std::reverse_iterator<iterator>;
  using const_riterator = std::reverse_iterator<const_iterator>;

public:
  CircularQueue() = default;
  CircularQueue(size_type capacity)
    : m_capacity(toPow2(capacity)), m_data(new TD[m_capacity]{}) {}
  template<typename TI>
  CircularQueue(TI b, TI e)
    : m_capacity(toPow2(std::distance(b, e))), m_data(new TD[m_capacity]{})
  { for(auto it = b; it != e; ++it) push_back(*it); }
  CircularQueue(std::initializer_list<TD> const& il)
    : CircularQueue(std::begin(il), std::end(il)) {}
  ~CircularQueue() { destroy(); }

  CircularQueue(const CircularQueue& cq)
    : m_capacity(cq.m_capacity), m_nDequeue(cq.m_nDequeue), m_nOverflow(cq.m_nOverflow),
      m_nUnderflow(cq.m_nUnderflow), m_nRemoved(cq.m_nRemoved), m_data(new TD[m_capacity]{})
  {
    try {
      for(size_type i = 0; i < cq.m_size; ++i) push_back(cq[i]);
      // Note: m_capacity and m_size are already set.
      //       m_head and m_tail should be reset automatically in updateSize().
      m_nEnqueue = cq.m_nEnqueue;
    }
    catch(...) { destroy(); throw; }
  }
  CircularQueue& operator=(const CircularQueue& cq)
  {
    if(this != &cq) { CircularQueue<TD> tmp(cq); tmp.swap(*this); }
    return *this;
  }
  CircularQueue(CircularQueue&& cq) noexcept { cq.swap(*this); }
  CircularQueue& operator=(CircularQueue&& cq) noexcept { cq.swap(*this); return *this; }

  bool            empty() const    { return !m_size; }
  bool            full() const     { return m_size == m_capacity - 1; }
  size_type       size() const     { return m_size; }
  size_type       capacity() const { return m_capacity - 1; } // for iterators
  size_type       removed() const  { return m_nRemoved; }

  reference       at(size_type idx)
                  { validate(idx); return *(m_data + modCapacity(m_head + idx)); }
  const_reference at(size_type idx) const
                  { validate(idx); return *(m_data + modCapacity(m_head + idx)); }
  reference       operator[](size_type idx) { return *(m_data + modCapacity(m_head + idx)); }
  const_reference operator[](size_type idx) const
                  { return *(m_data + modCapacity(m_head + idx)); }
  reference       front()          { return *(m_data + m_head); }
  const_reference front() const    { return *(m_data + m_head); }
  reference       back()           { return *(m_data + (m_tail ? m_tail - 1 : m_capacity - 1)); }
  const_reference back() const     { return *(m_data + (m_tail ? m_tail - 1 : m_capacity - 1)); }

  iterator        begin()          { return iterator(this, m_data + m_head); }
  riterator       rbegin()         { return riterator(end()); }
  const_iterator  begin() const    { return const_iterator(this, m_data + m_head); }
  const_riterator rbegin() const   { return const_riterator(end()); }
  iterator        end()            { return iterator(this, m_data + m_tail); }
  riterator       rend()           { return riterator(begin()); }
  const_iterator  end() const      { return const_iterator(this, m_data + m_tail); }
  const_riterator rend() const     { return const_riterator(begin()); }
  const_iterator  cbegin() const   { return begin(); }
  const_riterator crbegin() const  { return rbegin(); }
  const_iterator  cend() const     { return end(); }
  const_riterator crend() const    { return rend(); }

  void enqueue(const value_type& item) { push_back(item); }
  void enqueue(value_type&& item) noexcept { move_back(std::move(item)); }
  template<typename... TAs>
  void enqueue(TAs&&... args) noexcept { emplace_back(std::move(args)...); }

  TD dequeue() noexcept
  {
    if(!m_size) {
      ++m_nUnderflow;
      return TD{};
    }

    TD item = std::move(*(m_data + m_head));
    m_head = modCapacity(++m_head);
    --m_size;
    ++m_nDequeue;
    ++m_nRemoved;
    return item; // Respect RVO for local objects.
  }

  void reserve(size_type capacity)
  {
    if(m_capacity >= capacity) return;
    capacity = toPow2(capacity);
    reserveData(capacity);
  }

private:
  // Return a number with a power of 2 that is larger but the most adjacent to a given value.
  size_type toPow2(size_type value) const
  {
    int hbit = 0;
    for(; value != 1; ++hbit) value >>= 1;
    return (size_type(1 << hbit) == value) ? value : 1 << (hbit + 1);
  }
  // Return a fast modulo. m_capacity is assumed a power of 2 and positive number.
  size_type modCapacity(size_type num) const { return num & (m_capacity - 1); }

  void validate(size_type idx) const
  { if(idx >= m_size || !m_capacity) throw std::out_of_range("Error: index out of range."); }

  void updateSize()
  {
    ++m_nEnqueue;
    m_tail = modCapacity(++m_tail);

    // If Queue is full(or empty) head and tail are same. This makes iterators useless.
    if(m_size == m_capacity - 1) {
      ++m_nRemoved;
      ++m_nOverflow;
      m_head = modCapacity(m_tail + 1); // Make head != tail for iterators.
    }
    else ++m_size;
  }

  void push_back(const TD& item) { *(m_data + m_tail) = item; updateSize(); }
  void move_back(TD&& item) noexcept { *(m_data + m_tail) = std::move(item); updateSize(); }
  template<typename... TAs>
  void emplace_back(TAs&&... args) noexcept
  { *(m_data + m_tail) = TD(std::move(args)...); updateSize(); }

  void reserveData(size_type capacity)
  {
    CircularQueue<TD> tmp(capacity);
    restoreData(tmp);
    tmp.swap(*this);
  }

  void restoreData(CircularQueue<TD>& cq)
  {
    if(!m_size) return;
    for(size_type i = 0; i < m_size; ++i) cq.move_back(std::move((*this)[i]));
    // Note: m_capacity and m_size are already set.
    //       m_head and m_tail should be reset automatically in updateSize().
    cq.m_nEnqueue   = m_nEnqueue;
    cq.m_nDequeue   = m_nDequeue;
    cq.m_nOverflow  = m_nOverflow;
    cq.m_nUnderflow = m_nUnderflow;
    cq.m_nRemoved   = m_nRemoved;
  }

  void swap(CircularQueue& cq) noexcept
  {
    std::swap(m_capacity,   cq.m_capacity);
    std::swap(m_size,       cq.m_size);
    std::swap(m_head,       cq.m_head);
    std::swap(m_tail,       cq.m_tail);
    std::swap(m_nEnqueue,   cq.m_nEnqueue);
    std::swap(m_nDequeue,   cq.m_nDequeue);
    std::swap(m_nOverflow,  cq.m_nOverflow);
    std::swap(m_nUnderflow, cq.m_nUnderflow);
    std::swap(m_nRemoved,   cq.m_nRemoved);
    std::swap(m_data,       cq.m_data);
  }

  void destroy() { std::unique_ptr<TD, Deleter> deleter(m_data, Deleter()); }

private:
  struct Deleter { void operator()(TD* data) const { delete[] data; } };

  size_type m_capacity{0};   // queue capacity
  size_type m_size{0};       // current data size
  size_type m_head{0};       // head index
  size_type m_tail{0};       // tail index
  size_type m_nEnqueue{0};   // enqueued data size(m_size + m_nRemoved)
  size_type m_nDequeue{0};   // dequeued data size
  size_type m_nOverflow{0};  // overflowed data size
  size_type m_nUnderflow{0}; // underflowed data size
  size_type m_nRemoved{0};   // removed(m_nOverflow + m_nDequeue) data size
  TD* m_data{nullptr};       // data storage

  friend iterator;
  friend const_iterator;
};

Circular Iterator

만드는 김에 iterator까지 만들어 보았다. 로직이 간단하지는 않아서 애를 좀 먹었다. STL 표준 iterator들은 컨테이너의 begin()과 end() 함수만으로 동작하는데, Circular Queue의 경우 두 함수가 동일한 메모리 주소를 갖는 경우가 생기기 때문에 구현하기 어렵다. 즉, Queue가 비어 있거나 꽉찼을 때 head와 tail 위치가 같아진다. loop를 아예 돌릴 수 없거나 무한 loop를 돌게 되는 상황에 빠진다.

나의 해결 방법은 head와 tail이 같아지지 않도록 하여 정확히 1 cycle의 loop이 돌게 하였다. 대신 최대 데이터 크기는 고정 용량 크기 보다 1개 줄어든다.

// Circular Queue(Buffer) and Circular Iterator C++ Implementation
//
// This program is copyright (c) 2019 by Umundu @ https://zapary.blogspot.com .
// It is distributed under the terms of the GNU LGPL version 3, as detailed in
// https://opensource.org/licenses/lgpl-3.0.html .

#include "CircularQueue.h"

template<typename TC>
class CircularIterator
{
public: // Should be public!
  using iterator_category = std::random_access_iterator_tag;
  using value_type        = typename TC::value_type;
  using pointer           = typename TC::pointer;
  using difference_type   = typename TC::difference_type;
  using size_type         = typename TC::size_type;
  using reference         = typename TC::reference;

public:
  CircularIterator() = default;
  CircularIterator(const CircularIterator& it) : m_cque{it.m_cque}, m_it{it.m_it} {}
  CircularIterator(TC* co, const pointer po) : m_cque{co}, m_it{po} {}

  CircularIterator& operator=(const CircularIterator& it)
  {
    if(this == &it) return *this;
    m_cque = it.m_cque;
    m_it = it.m_it;
    return *this;
  }

  reference operator*() const { return *m_it; }
  pointer operator->() const { return &(operator*()); }

  CircularIterator& operator++() {
    if(++m_it == m_cque->m_data + m_cque->m_capacity) m_it = m_cque->m_data;
    return *this;
  }
  CircularIterator operator++(int) {
    CircularIterator tmp = *this;
    ++*this;
    return tmp;
  }
  CircularIterator& operator--() {
    if(m_it == m_cque->m_data) m_it = m_cque->m_data + m_cque->m_capacity;
    --m_it; // note!
    return *this;
  }
  CircularIterator operator--(int) {
    CircularIterator tmp = *this;
    --*this;
    return tmp;
  }

  CircularIterator& operator+=(difference_type n) {
    if(n > 0) m_it = m_cque->m_data + m_cque->modCapacity(m_it - m_cque->m_data + n);
    else if(n < 0) *this -= -n;
    return *this;
  }
  CircularIterator& operator-=(difference_type n) {
    if(n > 0) {
      difference_type idx = m_it - m_cque->m_data;
      m_it = m_cque->m_data +
             (n > idx ? m_cque->m_capacity - m_cque->modCapacity(n - idx) : idx - n);
    }
    else if(n < 0) *this += -n;
    return *this;
  }
  CircularIterator operator+(difference_type n) const { return CircularIterator(*this) += n; }
  CircularIterator operator-(difference_type n) const { return CircularIterator(*this) -= n; }
  difference_type operator+(CircularIterator& it) const
  { return m_cque->modCapacity(index(m_it) + index(it.m_it)); }
  difference_type operator-(CircularIterator& it) const
  { return index(m_it) - index(it.m_it); }

  reference operator[](difference_type n) const { return *(*this + n); }

  bool operator!() const { return !m_it; }
  bool operator==(const CircularIterator& it) const { return m_it == it.m_it; }
  bool operator!=(const CircularIterator& it) const { return !operator==(it); }

  bool operator<(const CircularIterator<TC>& it) const
  { return (index(m_it) < index(it.m_it)); }
  bool operator>(const CircularIterator<TC>& it) const
  { return (index(m_it) > index(it.m_it)); }
  bool operator<=(const CircularIterator& it) const { return !(operator>(it)); }
  bool operator>=(const CircularIterator& it) const { return !(operator<(it)); }

private:
  difference_type index(const pointer& it) const
  {
    difference_type idx = it - m_cque->m_data - m_cque->m_head;
    return idx < 0 ? m_cque->m_capacity + idx : idx;
  }

  const TC* m_cque{nullptr}; // CircularQueue
  pointer   m_it{nullptr};   // iterator
};


Test 결과

테스트 결과만 아래에 보였다. 잘 돌아간다~!!!
===============[[  Queue: 0x7ffcd5d379a0 Status  ]]===============
=== Queue capacity(the maximum data size)        : 15
=== Current(queue keeping) data size             : 15
=== Enqueued(= Current + Removed) size           : 46
=== Dequeued size                                : 15
*** Overflowed(starving capacity) size           : 16
*** Underflowed(starving enque data) size        : 5
*** Removed(= Dequeued + Overflowed) size        : 31
=== Head/Front(next dequeue point) index (value) : 3 (8)
=== Back(the last data point) index (value)      : 1 (-48)
=== Tail(next enqueue point) index               : 2
-------------< Storage Data in memory address order >-------------
-49, -48, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, -50, 
-------------<    Current Data in enqueued order    >-------------
8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, -50, -49, -48, 
==================================================================

*** for-loop test on a Circular Queue
8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, -50, -49, -48, index loop
8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, -50, -49, -48, auto iterator
8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, -50, -49, -48, iterator
8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, -50, -49, -48, const iterator
-48, -49, -50, 19, 18, 17, 16, 15, 14, 13, 12, 11, 10, 9, 8, reverse iterator
-48, -49, -50, 19, 18, 17, 16, 15, 14, 13, 12, 11, 10, 9, 8, c-r iterator
-50, -49, -48, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, std::sort()

Tip: 함수 성능 측정을 위한 Simple Timer

가끔 함수 성능 측정을 해야 하는데 아래와 같은 Simple Timer 하나 장만해 두면 편하다.

#include <chrono>
#include <thread>

using namespace std::chrono_literals;
// Simple Timer
class Timer {
  using TClock = std::chrono::high_resolution_clock;
  using TTime = decltype(TClock::now());

public:
  Timer() : m_start(TClock::now()) {}
  operator long()
  {
    auto interval = std::chrono::duration_cast<std::chrono::microseconds>
                    (TClock::now() - m_start).count();
    reset();
    return interval;
  }
  void reset() { m_start = TClock::now(); }

private:
  TTime m_start;
};

아래와 같이 간단하게 사용할 수 있다.

void main()
{
  Timer now;
  for(size_t i = 0; i < 1000; ++i) [] { std::this_thread::sleep_for(2ms); };
  std::cout << "f1: " << now << '\n';

  for(size_t i = 0; i < 1000; ++i) [] { std::this_thread::sleep_for(3ms);};
  std::cout << "f2: " << now << '\n';
}

2019/10/28

btrfs 파일시스템 이해 및 활용


btrfs 파일시스템을 본격적으로 쓰기로 했기 때문에 까먹기 전에 사용법에 대해 다시 정리해 두는게 좋겠다. 예전 글에서 btrfs local snapshot으로 시스템 복원하는 방법에 대해 다루었는데, 몇 년 사이에 btrfs도 많이 개선됐기에, 복습도 되고 새로 btrfs 파일시스템을 접하는 이들에게도 도움이 될 것이다. 특히나 btrfs는 기존의 ext4 파일시스템과 많이 다르기 때문에 개념을 이해하지 않으면 제대로 활용하기 어렵다. 사실, btrfs wiki를 보면 대부분의 btrfs 사용법을 익힐 수 있지만, btrfs를 잘 모르는 사용자들은 적응하기가 쉽지 않다.

Copy-on-Write(CoW) 개념

c++ 고급 사용자라면 한 번쯤 접해 봤을 것이다. Qt Framework에서는 container 들을 포함한 여러 class 객체 들이 copy-on-write(또는 implicit sharing) 방식으로 동작한다. 공유 객체가 만들어질 때 공유 data를 생성하고 reference counter 를 1로 설정했다가 참조하는 객체가 늘어날 때마다 counter를 증가시키고 줄어들면 감소시킨다. 0이 되면 공유 data를 삭제하고 공유 객체는 소멸된다. 참조 객체들은 처음에는 공유 객체 주소 정보만 복사해서(shallow copy) 사용하다가 변경 사항이 생길 때 reference counter가 1보다 크면(여러 객체가 data를 공유하고 있으면) 공유 data를 모두 복사하고(deep copy) 나서 변경사항을 적용한다.

변경이 필요할 때(on-write) 공유 data를 복사하면(copy) 된다는 개념인데 btrfs도 이런 식으로 동작한다. 즉, 어떤 파일이 변경되지 않는 한 공유 data 참조 정보만 저장하면 된다. 파일이 변경될 때는 원본은 그대로 두고 빈 공간에 새 data를 만들어서 meta 정보로 새 data를 참조한다. 원본 공유 data를 참조하는 놈들이 없으면 원본을 지운다.

여기서 복사(copy)와 복제(clone) 개념을 구분할 필요가 생긴다. 복사는 원본 데이터와 똑같은 데이터를 갖는 새로운 놈을 만드는 것이다. 복제는 새로운 놈을 만들 때 처음엔 원본에 대한 참조 정보만 복사하고 있다가 변경이 필요할 때 원본 데이터를 복사하고 나서 변경시킨다. 이후로는 복제본도 복사본처럼 더이상 원본을 참조할 필요가 없다.

$ cp /etc/fstab ~/test.copy
$ cp --reflink=always ~/test.copy ~/test.clone

위의 첫째 cp는 copy를 수행한 것이고 두번째 cp는 clone을 수행한 것이다. test.clone은 test.copy를 참조하고 있으므로 파일 데이터 공간을 차지하지 않는다. 파일시스템 내부적으로 처리하기 때문에 사용자가 차이를 알기 어려운데, VM 이미지같이 큰 파일을 사용해 보면 복제가 당연히 빠르다고 느낄 수 있다. 하지만, 복제된 VM으로 최초 부팅시엔 VM 파일에 변경이 필요하고 원본 데이터를 복사해야하므로 VM이 느려질 수 밖에 없다. 한번 복사가 이루어진 후에는 VM 복사본이나 복제본이나 별 속도 차이가 없다. VM 파일이나 DBMS도 내부적으로 CoW 방식을 사용하는 놈들도 있다.

Copy-on-Wrtie의 기본 발상은,  대부분의 경우엔 원본을 참조하는 복제본만 있으면 되고, 변경이 필요할 때만 복사하면 된다는 것이다. 즉, 변경이 일어나지 않는 한 매우 빠르고 효율적이지만 변경시에는 느려지고  비효율적이다.

btrfs의 snapshot도 subvolume의 복제본이다. 특정 subvolume에 대한 snapshot을 만들면, 최초엔 snapshot이 subvolume을 참조만 하기 때문에 데이터 공간을 차지하지 않는다. 두 놈이 같은 참조 정보를 저장하고 있다가 subvolume 내의 파일이 변경되면 subvolume은 변경된 데이터를 빈 공간에 새로 저장하고, 그 파일에 대한 meta 정보를 갱신한다. snapshot은 계속 원본을 참조한다. snapshot에는 불필요한 정보가 남아 있는 것이므로 원본 subvolume이 많이 변경될수록 snapshot이 차지하는 공간이 점점 늘어나게 된다. subvolume 내의 어떤 파일을 삭제하면 snapshot으로 인해 원본 파일 데이터 전체를 보존해야 하므로 불필요한 공간이 크게 늘어난다. 뭐 snapshot의 존재 이유가 저장 공간을 희생해서 시스템 복구를 쉽게하자는 것이다.

만약, 이 snapshot을 다른 컴퓨터나 USB 외장 디스크에 backup 한다면 외부 파일시스템에는 참조 정보가 없기 때문에 최초 복사 시에는 모든 원본을 복사해야 한다. 최초의 backup subvolume이 만들어지면 이후로는 이를 참조하여 변경된 정보만으로 incremental backup을 할 수 있게 된다.

Subvolume vs. folder(directory)

btrfs wiki에 따르면, subvolume은 자신의 독립적인 파일/디렉터리 계층(hierarchy)을 갖는 파일시스템의 일부이고, 파일 extents를 공유할 수 있다. snapshot은 최초에 원본 subvolume의 내용을 갖고 있는 subvolume이다.  btrfs 파일시스템은 block-level의 LVM 논리 volume과는 달리 file-extent 기반이다......

SysadminGuide에서, btrfs subvolume은 독립적으로 mount 할 수 있는 POSIX filetree이지만 block device는 아니다.  대부분의 다른 POSIX 파일시스템은 마운트할 수 있는 root가 단 1개인데 비해, btrfs는 top level subvolume을 포함한 각 subvolume이 독립적으로 마운트 할 수 있는 root를 갖는다. btrfs subvolume은 POSIX file namespace라 생각할 수 있다. LVM 논리 volume과 ZFS subvolume과도 다르다......

폴더가 파일들을 담을 수 있는 컨테이너 정도로 이해하는 사람들에게 subvolume이 뭔지 설명하기란 힘들다. 사용자 관점에서 subvolume과 폴더는 사용법도 비슷하고, @를 subvolume 명 앞에 사용하지 않으면  ls 명령으로 아예 구분할 수도 없다. 폴더와 subvolume 모두 하위에 자식 폴더나 자식 subvolume을 만들 수 있고 다른 폴더에 마운트도 할 수 있다.

흠, subvolume은 CoW를 사용하는 특별한 폴더라고 이해하는 게 편할 듯하다. 하지만, CoW 개념도 사실 일반 사용자들이 이해하기 어렵다. 자신을 복제할 수 있는 특별한 폴더 ???

여기서, 특별한 폴더라고 하는 이유는 subvolume을 복사해야 하는 상황에서는 subvolume이 폴더로 바뀌기 때문이다. 가령, subvolume을 cp -R 로 복사하면 복사본은 폴더로 바뀐다. 또한, mv를 사용할 때도 특정 subvolume 내의 자식 subvolume들은 부모 subvolume 내에서는 이동이 되지만, 부모 subvolume 밖의 폴더나 subvolume으로 mv하면 폴더 복사본을 만들고 나서(파일 참조 불가) 자신을 삭제한다. 또한, 부모 subvolume의 snapshot 생성 시에도 자식 subvolume은 snapshot에 빈 폴더로 남게 된다(이 경우도 파일 참조 불가 문제인듯. 다만, snapshot에는 복사본을 사용하는게 의미 없기 때문에 아예 빈 폴더로 두는 듯하다).

subvolume을 폴더 대신 사용 하는 이유는,
  • 특정 subvolume의 backup 및 복구: snapshot을 만들거나, snapshot을 send/receive로 incremental backup 후 문제 발생시 복구
  • 반대로, 특정 subvolume을 backup에서 제외:
    • 가령, ~/.cache 폴더를 subvolume으로 바꾸면 @home의 snapshot 생성시 제외됨
  • 독립적인 애플리케이션 관리: 예를 들어 /var/www 또는 /var/lib/postgresql 등
  • 특별한 속성의 파일들을 분리하거나 mount 시 특별한 속성 부여:
    • chattr +c (compress: 파일 압축) 또는 chattr +C(nodatacow: 파일 생성시 CoW 비적용) 
    • 가령, VM(가상 머신) 파일 저장소나 DB 파일 들은 CoW를 사용하지 않도록 설정
    • 단, mount로 특별한 속성을 부여하려면 반드시 subvolume이 별개의 btrfs device(또는 파티션)에 있어야 함(향후 btrfs 버전에서 개선 예정???)
    • 즉, 동일 btrfs 파일시스템 내의 subvolume은 mount 옵션을 개별적으로 설정해도 첫번째 mount한 subvolume의 옵션만 적용됨(/etc/fstab 사용시에도 동일함)
btrfs 기본 사용법

우분투에서 예전의 btrfs-tools 패키지는 btrfs-progs 패키지로 바뀌었다. btrfs 파일 시스템을 사용하면 자동으로 설치되지만, ext4 등 다른 파일시스템에서 btrfs 파일시스템의 파일들을 사용하려면 따로 설치해야 한다. 이 놈이 있어야 btrfs partition 생성, format, mount 등을 비롯한 btrfs 파일시스템에 대한 모든 것을 처리할 수 있다.

$ sudo apt install btrfs-progs

일부 btrfs 명령을 일반 사용자가 사용할 수는 있지만 매우 제한적이고 대부분은 root 권한으로 사용해야 한다. 또, btrfs 명령들은 중복되지 않는 한 줄여서 사용할 수 있다.

하나 이상의 전체 btrfs 파일시스템(또는 디바이스)을 보고 싶다면 아래 명령을 사용한다(fi = filesystem). 이를 제외한 나머지 명령들은 btrfs 파일시스템 1개에 대한 명령임에 유의해야 한다.

$ sudo btrfs fi show

특정 btrfs 파일시스템의 subvolume 목록을 조회하려면 다음 명령과 같이 맨 끝에 마운트 위치를 알려 주어야 한다.

$ sudo btrfs subvolume list /

만약, 여러 개의 btrfs 파일시스템을 사용하고 있다면(예를 들어, /dev/sda3가 별개의 btrfs 파티션이고 /opt에 마운트돼 있다면),

$ sudo btrfs sub l /opt

와 같이 해야만 /dev/sda3의 subvolume 목록을 알 수 있다. 즉, 한번에 여러 btrfs 파일시스템 전체의 subvolume 목록을 조회할 수는 없고 각각의 btrfs 파일시스템에 대해 1개씩 조회해야 한다.

디스크 공간 관리

$ sudo btrfs fi usage /

이 명령으로 확인한 Free 공간은 df -h 명령으로 확인한 available 공간과 일치한다(Unallocated 용량이 합산됨). 하지만 CoW 파일시스템 특성상 Free (estimated) 공간이 실제 사용할 수 있는 공간에 더 가까울 수 있다. Unallocated 용량은 btrfs 파일시스템으로 파티션을 format 하더라도 전체 파티션을 모두 할당해서 사용하지는 않고 있다가 저장 공간이 부족하면 자동으로 할당한다. 또한, btrfs 파티션은 online으로(시스템 운영 중에) resize할 수 있는데 Unallocated 용량이 10GiB라면 바람직하진 않지만 아래와 같이 파티션 크기를 최대한 줄일 수 있다. gparted 앱을 사용해도 online resize가 된다.

$ sudo btrfs fi resize -10G /
$ sudo btrfs fi usage /

다시 최대 크기로 파티션을 늘리려면,

$ sudo btrfs fi resize max /

이외에도 아래의 명령들을 사용할 수 있다.

$ sudo btrfs fi df -H /

$ sudo mount /dev/sda2 /mnt
$ sudo btrfs fi du -s --iec /mnt/@home

defragment -r 명령은 하위 폴더를 포함해서(recursively) defragment를 수행하는데 subvolume과 mount point는 건너 띄도록 바뀐듯 하다. snapshot이나 reflink 파일에 적용하면 복제본 파일들이 복사본으로 바뀌어서 디스크 용량이 늘어나는 문제가 생긴다. 성능 감소가 대단하지 않기 때문에 defragment를 굳이 사용할 필요없다는 이들도 있다.

$ sudo btrfs fi defagment -r /no-cow/VM

subvolume 관리

subvolume의 생성(create), 복제(snapshot), 조회(list/show), 삭제(delete)를 위해 아래와 같이 btrfs subvolume 명령을 사용한다. 참고로, subvolume을 cp -R 명령으로 복사하면 앞서 설명했듯이 폴더로 바뀐다. 즉, btrfs 파일시스템이 아닌 다른 파일시스템을 어떤 폴더에 mount 해서 subvolume을 복사하려면 일반 폴더처럼 cp -R 명령을 사용하면 된다.

$ cd ~/
$ btrfs subvolume create @test
$ cp /etc/fstab @test/
$ ls @test/
$ btrfs sub snap @test @test-snapshot
$ sudo btrfs sub l /
$ sudo btrfs sub show @test
$ sudo btrfs sub sh @test-snapshot
$ sudo btrfs sub delete -c @test
$ mv @test-snapshot @test
$ sudo btrfs sub l /
$ sudo btrfs sub del -c @test

위에서 del -c 옵션은 subvolume을 지우고 즉시 commit을 수행하라는 것이다.

다른 btrfs 파일시스템으로 subvolume을 복제하려면 나중에 설명하겠지만 btrfs send/receive를 사용해야 한다. 가령, /dev/sda2가 root btrfs 파티션이고, /dev/sda3가 별개의 btrfs 파티션으로 /opt에 마운트 되어 있다면 아래와 같이 snapshot을 생성할 수는 없다.

$ cd /opt
$ sudo btrfs sub snap /mnt/@home ./@home-snapshot

USB 외장 디스크의 btrfs 파티션을 마운트해서 사용하는 경우를 생각해 볼 수 있다.

btrfs Incremental 백업 및 복구

하나의 btrfs 파일시스템 내에서는 snapshot 만으로도 local backup이 되고, snapshot은 원본 subvolume이 많이 변경되지 않는 한 디스크 용량도 많이 차지하지는 않는다. 백업해야 할 사용자 데이터가 많다면 외장 디스크나 다른 컴퓨터의 디스크에 백업해야 하는데 btrfs incremental 백업은 btrfs 파일시스템 간에만 가능하다. 즉, 백업 받는 쪽의 디스크도 btrfs 파일시스템이어야 한다.

여기서는 btrfs root 시스템이 /dev/sda2에 설치되어 있을 때 외장 디스크의 btrfs 파일시스템이 /dev/sdb3 파티션이라고 가정하고, @home subvolume의 snapshot을 백업했다가 복구하는 예를 들었다. 외장 디스크가 아니고 원격 컴퓨터의 btrfs 파일시스템을 사용하려면 ssh를 사용하면 된다.

1. 원본 subvolume 마운트

$ sudo mount /dev/sda2 /mnt
$ ls -CF /mnt
@/  @home/

2. 외장 디스크 btrfs 파티션 마운트

$ sudo mkdir /back
$ sudo mount /dev/sdb3 /back
$ sudo mkdir /back/backups

3. 최초 snapshot 생성 및 백업

$ sudo btrfs sub snap -r @home @home20190101
$ sudo sync

여기서 주의할 점은, btrfs send/recive 명령을 사용하기 위해 snapshot 생성시 read-only(-r) 옵션을 사용해야 한다. 그리고, 이 -r 옵션이 recursively라고 착각하는 이들도 더러 있는데, ZFS와는 달리 btrfs snapshot은 원본 subvolume에 자식 subvolume이 있으면 빈 폴더로 바꿔서 snapshot이 생성된다. 앞서 설명했듯이 btrfs에서 자식 subvolume을 만드는 이유는 ~/.cache 폴더와 같이 불필요한 특정 폴더를 subvolume으로 대체함으로써 snapshot이나 백업에서 제외하기 위해서이다.

아래와 같이 writable snapshot을 생성했을 경우 btrfs property 명령으로 read-only snapshot으로 만들 수도 있다.

$ sudo btrfs sub snap @home @home20190101
$ sudo btrfs property set -ts /mnt/@home20190101 ro true
$ sudo sync

위에서 true대신 false 옵션을 사용하면 read-only snapshot을 다시 writable snapshot으로 설정한다.

$ sudo btrfs send /mnt/@home20190101 | sudo btrfs receive /back/backups

위의 명령과 같이 최초 백업시에는 외장 디스크에 참조할 데이터가 없기 때문에 btrfs send시 아무 옵션이 없다. 외장디스크의 /backups 폴더에 @home20190101 subvolume이 생성된다.

4. Increametal 백업

이제 다음날 @home 파일들이 변경돼서 백업을 해야 한다면, 아래와 같이 새로운 snapshot을 만들어서 incremental backup을 할 수 있게 된다.

$ sudo btrfs sub snap -r @home @home20190102
$ sudo sync
$ ls -CF /mnt
@/  @home/  @home20190101/  @home20190102/

$ sudo btrfs send -p /mnt/@home20190101 /mnt/@home20190102 | sudo btrfs receive /back/backups

위의 -p <parent subvolume> 옵션은 incremental 백업시 참조할 부모 subvolume이다. -p 옵션을 사용하면  receive(백업) 쪽에서 부모 subvolume의 snapshot을 만들고 나서 변경된 부분을 백업 @home20190102 subvolume에 반영한다. 대부분의 경우에 -p 옵션만으로 충분하다.

참고로 -c <clone source subvolume>을 여러 개 사용할 수도 있는데 원본 subvolume을 여러개 참조해서 백업 subvolume을 생성하라는 의미이다. -c 옵션은 snapshot을 생성하지 않고 빈 백업 subvolume을 먼저 만들고 나서 백업 쪽에 clone source subvolume에 참조할 데이터가 있을 경우 그 들을 참조해서 백업 subvolume이 만들어지도록 한다.

$ ls -CF /back/backups
@home20190101/  @home20190102/

5. 외장 디스크의 백업 snapshot에서 root btrfs 파일시스템의 @home 복구

최신 데이터 백업이 됐기 때문에 아래와 같이 불필요한 snapshot들을 제거해도 된다.

$ sudo btrfs sub del -c /mnt/@home20190101
$ sudo btrfs sub del -c /mnt/@home20190102
$ sudo btrfs sub del -c /back/backups/@home20190101

$ ls -CF /mnt
@/  @home/

$ ls -CF /back/backups
@home20190102/

이제는 복구할 것이므로 백업 쪽의 snapshot을 send 한다. @home을 대체할 것이므로 writable snapshot으로 만들어야 한다.

$ sudo btrfs send /back/backups/@home20190102 | sudo btrfs receive /mnt
$ sudo btrfs property set -ts /mnt/@home20190102 ro false

$ ls -CF /mnt
@/  @home/  @home20190102/

이제는 local snapshot을 복구하는 것과 똑같이 하면 된다.

$ sudo mv @home @home-problem
$ sudo mv @home20190102 @home
$ sudo reboot

안전한 Checksum 검사 및 복구

btrfs scrub은 디스크의 모든 data를 읽어서 checksum을 다시 계산해서 기존 값과 비교함으로써 meta data를 포함한 data(file 단위는 아님)가 깨졌는지 알려주고 가능한 경우 복구한다. Background 프로세스로 돌기 때문에 상태를 확인하려면 아래와 같이 하면 된다. RAID를 구성한 경우에는 최소한 한달에 한번 주기적으로 scrub을 돌리는 게 좋단다.

$ sudo btrfs scrub start /
$ sudo btrfs scrub status

아예 root btrfs 파일시스템을 마운트 할 수 없을 경우에는 우분투 설치 iso로 부팅해서,

$ sudo btrfs scrub /dev/sda2

scrub으로 복구가 안될 경우엔 아래 명령으로 B-tree를 복구해 본다. 

$ sudo mount -o usebackuproot /dev/sda2 /mnt

위의 과정은 모두 안전한 복구 방법인데, 디스크 문제로 파일이 깨졌을 때 사용할 수 있는 방법은 아니다. 이것으로 복구가 안되면 안전하지 않은 btrfs check/restore/rescue 등의 명령을 사용해야 한단다.

2019/10/25

btrfs 파일시스템에 우분투 19.10 clean 설치


우분투 19.10이 지난 주말에 출시 됐는데 native root ZFS 설치가 가능한 점이 가장 눈에 띈다. 우분투 설치시에 NVIDIA 드라이버를 설치할 수 있는 점도 좋은 점이다. 우분투 19.10의 새로운 점들은 Release Note를 참고하는 게 좋고, omg! Ubuntu!의 포스트에도 정리가 잘 되어 있다.

btrfs도 잘 활용하지 못하는 판에 캐노니컬은 btrfs 대신 ZFS를 밀어주고 있다. 궁금해서 VirtualBox에서 ZFS로 설치해 보기로 했다. 처음부터 삽질을 하게 만들어 불길한 예감이 들었는데 우분투 19.10을 본격적으로 설치하면서 꽤나 삽질을 해야했다.

VirtualBox(v6.0.14)에서 새로운 VM을 만들어 우분투 설치 iso로 부팅하고 나서 설치를 진행하면 VM이 먹통이 돼버린다. 우분투 19.04 host에서 top을 띄워 보면 VirtualBox cpu 점유율이 100%가 넘는다. 우분투 19.10을 설치하려면 최소 2GB 메모리가 필요하다는데 VM에 2GB를 할당해 놓았었다. 포기하려다 VM에서 gparted로 파티션을 수동분할 해서 설치를 진행하니까 설치가 잘 된다. 우연인가 싶어 먹통 만들기 테스트를 몇가지 했는데 우분투 설치 iso로 부팅이 되자 마자 gparted를 띄우고 View > Disk Info만 확인해도 host의 top에서 VirtualBox cpu 점유율이 떨어진다. VirtualBox나 우분투의 버그이겠지만 아무튼 쓸모 있는 해결책이다. 혹시나 해서 VM의 메모리를 4GB로 늘렸더니 cpu 점유율이 정상으로 돌아온다. 메모리가 넉넉하면 이 방법이 더 좋겠다.

흠, 아무튼 ZFS 설치는 잘 된다. 단, VM 생성시 시스템 설정에서 EFI를 사용하도록 해야 ZFS 설치가 가능하다. 이유는 우분투 ZFS가 gpt 멀티 파티션을 사용하기 때문인데 bios 환경에서는 설정이 복잡해서 아예 설치를 지원하지 않는다. 아직은 Experimental 딱지가 붙어 있으니 실제 시스템에 적용하려면 삽질 정신이 필수일 게다. 더구나, 전체 디스크를 지우고 파티션을 자동 할당하기 때문에 실제 시스템에는 사전 준비없이 ZFS로 설치하지 않는게 좋다. 캐노니컬이 ZFS에 꽤 심혈을 기울이는 모양이다. ZFS 관리를 위한 zsys를 열심히 만들고 있단다. ZFS가 나온지는 꽤 됐는데 서버 파일시스템으로써는 최고라는 수식어가 붙고 있다. 리눅스에서는 License 충돌 문제때문에 ZoL(ZFS on Linux) 프로젝트가 별개로 진행되었고 커널 모듈로만 사용할 수 있는 단점이 있다. ZFS를 배우려는 이들에게 우분투 19.10 VM이 큰 도움이 될 것이다. 기능적으로는 ZFS가 btrfs보다 우월해 보이는데 Desktop 사용자들에게는 btrfs도 꽤나 쓸만하다. SSD가 범용화되면서 애플의 apfs와 더불어 서버건 데스크탑이건 모바일이건 간에 바야흐로 copy-on-write 파일시스템이 대세가 되고 있다. 아무튼 우분투의 ZFS 지원이 사용자들에게 배움의 단맛과 삽질의 쓴맛을 동시에 안겨 줄 것이다.

하지만, 이 글 제목에서와 같이 나는 당분간 btrfs에 좀 더 적응하기로 했다. 몇년 내 리눅스에서 ZFS가 대세가 되면 갈아 타겠지... 지금 당장 ZFS로 우분투 root 파일시스템으로 갈아 타려면 아래의 제약 사항들을 극복해야 한다.
  • grub이 아직 ZFS를 완벽하게 지원하지 못한다.
  • ZFS 커널 모듈로 부팅해야 하므로 root ZFS 설치 절차가 복잡하고 문제 발생시 시스템 복구 등 관리 상의 복잡함이 배가된다.
  • ZFS를 제대로 사용하려면 dataset(btrfs의 subvolume) 설정을 미세하게 할 필요가 있는데 수작업으로 하기엔 좀 복잡하다.
  • ZFS는 btrfs 보다 기능이 많기 때문에 배워야 할 것도 많다.
copy-on-write 파일시스템의 장점은 time-machine 류의 snapshot과 변분(incremental/differential) backup이 가능하고 대형 파일 복제가 매우 빠르며, 파일시스템 문제 발생시 쉽게 복구할 수 있다는 점이다. 파일 압축과 암호화를 지원하는 점도 장점이다. 실제로 btrfs 파일시스템을 사용하고 있는 우분투 19.04에서 우분투 19.10으로 upgrade를 진행했는데 중간에 gdm이 새로 뜨더니 로그인이 안돼서 설치를 제대로 마무리 할 수 없게 됐었다. 다행히 설치 전에 snapshot을 떠 놓아서 다시 19.04로 쉽게 돌아 갈 수 있었다. 할 수 없이 우분투 19.10 clean install을 진행하기로 했고 지금은 snapshot 덕분에 grub에서 19.04로도 부팅할 수 있고 19.10으로도 부팅할 수 있다. 서버라면 RAID 구성도 가능하겠지만 데스크탑에서는 USB 외장하드에 백업이 되는 것 만으로도 매우 유용하다. 필요할 때만 백업해 주면 되는데 시간을 꽤나 절약할 수 있기 때문이다. 뭐, 단점이라면 ext4 파일시스템에 비해 약간의 성능 저하가 있을 수 있는데 피부로 느낄 만큼 크지는 않다. subvolume에 lzo 압축을 적용해서 사용하는데 느리다고 느껴 본 적은 없다. 대신 저장 공간은 크게 절약된다(단, 동영상이나 음악 파일과 같이 자체 압축된 파일들이 많지 않다면...).

btrfs 파일시스템 파티션 조정

디스크 파티션을 조정해야 하므로 중요한 데이터를 별도의 디스크에 백업해 둬야 한다. UEFI 시스템에 btrfs 설치시 필요한 파티션은 EFI System(200MiB)과 btrfs(나머지 용량) 2개이다. 그 동안 우분투 전용 파티션만 4~5개 사용했었는데 이제 1개로 모두 합치게 되었다. EFI System 파티션은 모든 OS가 공용으로 사용하기 때문에 기존에 사용하고 있었다면 새로 추가할 필요는 없다. 리눅스 커널 5.0부터(우분투 19.04부터) btrfs 파일시스템 내의 Swap 파일을 Swap 파티션 대신 사용할 수 있다. 참고로 우분투 19.10의 ZFS는 EFI System, Swap, grub, boot pool, root pool 등 5개의 파티션을 사용하더라. 아마 boot와 root 파티션을 분리한 이유는 encryption이 가능하게 하려는 것인듯 하다. btrfs와 zfs는 비슷한 점이 많기 때문에 boot와 root 파티션을 나누는 게 좋을 수도 있지만 암호화를 사용하지 않으면 굳이 나눌 필요는 없어 보인다. ZFS의 Swap 파티션도 약간의 문제는 있지만 zvol dataset을 사용하면 굳이 필요하지는 않다. 그리 보면 ZFS엔 grub 파티션이 하나 더 있는 셈인데 아직 grub이 ZFS를 완벽하게 지원하지 못해서 분리한 듯하다(굳이 분리할 필요는 없어 보인다).

한마디로 btrfs든 zfs이든 ext4처럼 사용자 파티션 들을 더이상 구분할 필요가 없다. 가능한 파티션을 쪼개지 않는게 더 좋다. 이는 나중에 btrfs의 subvolume 또는 zfs의 dataset을 사용함으로써 구분할 수 있기 때문이다. 궁극적으로 데스크탑 사용자에겐 EFI 시스템 파티션을 제외하면 1개의 파티션으로 충분하다. subvolume 간에는 Quota를 따로 설정하지 않는 한 저장 공간을 공유하기 때문에 파티션을 여러 개 사용하면서 디스크 용량을 얼마씩 할당할지 더이상 고민할 필요가 없다.

참고로, 디스크 파티션을 분리해서 각각 btrfs 파일시스템으로 format 하면 각각의 파티션 수만큼 독립적인 btrfs 파일시스템을 만들게 되고, 각 파티션이 물리적인 Device 역할을 하게 된다. 대개는 여러 개의 디스크를 가지고 RAID를 구성하지만 분리된 btrfs 파티션들을 사용해서 RAID를 구성할 수도 있다. 주의할 점은 단일 btrfs 파일시스템 내에서 subvolume 들을 mount 할 때 다양한 옵션을 사용할 수 없다는 점이다. root subvolume의 mount 옵션을 따를 수 밖에 없다. 즉, root subvolume이 압축을 사용하고 있다면, 이후의 모든 subvolume은 mount 시 nocompress 옵션을 주더라도 압축을 사용하게 된다. 다만, 나중에 다루겠지만 우회적인 해결책은 있다. ZFS에서는 subvolume(dataset)이 파일시스템 단위가 되기 때문에 미세하고도 유연하게 subvolume 설정을 다르게 할 수 있다.

우분투 설치 iso로 부팅하여 설치시 참고 사항

우분투 installer(Ubiquity)를 이용하여 btrfs 파티션에 우분투 설치를 할 때 subvolume 압축을 사용할 수 있다. 압축을 사용하는 이유는 저장 공간을 줄이는 의미도 있지만 btrfs 파일시스템의 성능을 높이는 효과도 있다. 그냥 설치 후 나중에 /etc/fstab에서 subvolume 압축 옵션을 주고 mount 할 수도 있는데, btrfs에서는 기존의 파일들은 압축하지 않고 새로 생성된 파일들만 압축하기 때문에, 설치 전에 빈 subvolume을 압축 옵션을 주고 mount 해서 설치를 진행하는 것이다. 물론, 설치를 마치고 나서 defrag 명령에 압축 옵션을 사용할 수도 있기는 하지만, 사용 중인 파일들은 압축할 수 없기 때문에 바람직 하지는 않다.

Install Ubuntu 아이콘을 double click 하여 설치 진행시, [Installation type] 설정에서 [something else]를 선택하여 파티션 용도에 맞게 EFI 시스템 파티션을 [change]하고 btrfs 파티션은 /에 마운트 지정한다. 설정을 마친 후, [Where are you?] 설정(Time zone) 화면에서 멈추고, <Ctrl>+<Alt>+<t> 키 조합으로 터미널을 띄운다.

$ df -h
$ sudo btrfs subvolume list /target

명령으로 확인해 보면 btrfs 파티션(/dev/sda2로 가정)의 두 개의 subvolume 중에 @는 /target, @home은 /target/home, EFI 파티션은 /target/boot/efi에 마운트 되어 있음을 알 수 있다. 이 단계에서는 /target/swapfile과 /target/etc/fstab만 생성되어 있다.

먼저, btrfs용 swapfile은 root subvolume(@)에 snapshot을 사용할 수 있도록 하기위해 @swap subvolume에 다시 만들어야 한다. 그리고, @ subvolume을 포함한 모든 subvolume은 /etc/fstab에서 압축 옵션을 주고 부팅시 자동 mount 될 것이므로, swapfile이 압축되지 않도록 해야 하고 /target/etc/fstab 파일도 이에 맞게 아래와 같이 수정해야 한다.

$ sudo nano /target/etc/fstab

UUID=12345678-1234567890ab / btrfs defaults,noatime,compress=lzo,subvol=@ 0 1
UUID=1234-1234  /boot/efi vfat  umask=0077 0 1
UUID=12345678-1234567890ab /home btrfs defaults,noatime,compress=lzo,subvol=@home 0 1
UUID=12345678-1234567890ab /swap btrfs defaults,noatime,nodatacow,subvol=@swap 0 0
/swap/swapfile none  swap  sw 0 0

기존의 fstab 파일 내용과 다른 점은 @, @home 부분에 noatime,compress=lzo 부분이 추가됐고, @swap subvolume을 /swap에 mount하도록 한 줄이 추가됐다. 또, /swapfile 대신 /swap/swapfile을 사용한다. /swap mount 옵션에 compress 대신 nodatacow를 주긴했지만 /etc/fstab의 첫줄에 의해 mount 옵션이 동일하게 적용되기 때문에 옵션이 적용되지는 않는다. 다만, 실제로 압축을 사용하지 않을 것이므로 구분해 둘 필요는 있다. 이 단계에서 사용자 subvolume을 fstab에 더 추가할 수도 있겠지만 설치 후에 작업하는게 더 편해 보인다.

@swap/swapfile은 아래와 같이 만든다.

$ sudo -i
$ swapoff -a
$ rm /target/swapfile
$ mount /dev/sda2 /mnt
$ cd /mnt && ls
$ btrfs subvolume create @swap
$ chattr +C @swap
$ lsattr /mnt
$ cd @swap
$ touch swapfile
$ fallocate --length 1000MiB swapfile
$ chmod 600 swapfile
$ mkswap swapfile

$ swapon swapfile
$ cat /proc/swaps
$ swapoff -a
$ cd /
$ umount /mnt

@swap을 mount 할 swap 폴더를 아래와 같이 생성한다.

$ mkdir /target/swap
$ chattr +C /target/swap

참고로, btrfs 파일시스템에서 chattr +C 옵션은 파일이나 폴더/subvolume에 no-cow(no copy-on-write) 속성을 설정하는데,  subvolume 마운트시 nodatacow 옵션을 준 것과 같은 효과가 있고 압축을 사용하지 않는다. no-cow 속성이 설정되어 있더라도 subvolume의 snapshot 생성에는 아무 문제가 없다. no-cow 폴더 내의 파일이나 하위 폴더 들은 size가 0이거나 새로 생성될 때만 no-cow 속성이 계승된다. cp로 cow 파일을 no-cow 폴더에 복사하면 파일을 새로 만들기 때문에 no-cow 속성이 부여된다. no-cow 파일을 cow 폴더에 복사할 때도 cow 속성으로 바뀐다. mv로 기존의 파일을 옮길 때는 파일 속성을 그대로 유지한다.

이제 fstab과 같은 옵션으로 아래와 같이 remount 한다. lzo 압축 방식은 압축율은 좀 안좋지만 성능은 빠르다(ZFS의 lz4와는 다른 방식임). noatime 옵션은 파일시스템에서 access time을 사용하지 않도록 하는 것인데 성능을 높이기 위한 것이다.

$ sudo mount -o remount,defaults,noatime,compress=lzo /target
$ sudo mount -o remount,defaults,noatime,compress=lzo /target/home
$ grep btrfs /proc/mounts

이미 생성된 파일들은 몇개 안되지만 자동 압축되지 않았으므로 아래와 같이 수동으로 압축한다(/target/home은 비어 있음).

$ sudo btrfs fi defrag -r -clzo /target

다시 Ubiquity로 돌아와 [Where are you?] 설정을 마치고 우분투 설치를 진행하여 완료 후 디스크의 btrfs 파티션으로 재 부팅하면 우분투 clean 설치가 마무리된다.

참고로, ZFS는 이런 식으로 설치 할 수 없다. 파티셔닝부터 모든 게 자동화 되어 있으니까... 그리고, 우분투 19.10부터 설치시 3rd Party 비디오 드라이버와 WIFI 드라이버를 선택하면 자동으로 설치해 준다(설치 iso에 드라이버 탑재). Intel H/W PC에서 NVIDIA의 경우 부팅시 화면이 지글거리던 문제도 좀 나아졌다.

btrfs 사용자 subvolume 생성

btrfs 명령의 기본 사용법에 대해서는 이전 글을 참조하는 게 좋다. 이제 우분투 19.10으로 부팅해서 터미널에서 사용자 subvolume을 생성한다. @opt를 예로 든다.

$ sudo -i
$ mount /dev/sda2 /mnt
$ cd /mnt

$ btrfs sub create @opt
$ mkdir /opt
$ mount /dev/sda2 -o subvol=@opt /opt

위의 명령들을 사용해서 @opt subvolume 생성 후 /opt 폴더에 마운트해서 subvolume을 사용할 수 있게 된다. /etc/fstab에 등록해 주면 부팅시 자동으로 마운트된다.

VM 파일, DBMS storage 등에는 copy-on-write를 사용하면 성능 문제가 발생할 수 있으므로, Swap 파티션과 마찬가지로 no-cow 속성을 사용하는게 좋다. 아래와 같이 @nocow subvolume을 만들어 사용하면 된다. 어차피 압축을 사용하지 않기 때문에 동영상이나 음악 파일과 같이 자체 압축을 사용하는 파일들도 @nocow subvolume에 담아 두는게 좋아 보인다.

$ btrfs sub create @nocow
$ chattr +C @nocow
$ lsattr ./
$ mkdir /nocow
$ chattr +C /nocow
$ mount /dev/sda2 -o nodatacow,defaults,subvol=@nocow /nocow
$ grep btrfs /proc/mounts

앞서 설명했듯이 마운트 옵션을 다르게 주어도 root subvolume의 옵션을 따르기 때문에 의미는 없다. chattr +C가 이미 그 일을 해 주고 있다. 결국은 간단하게 모든 subvolume에 대해 아래와 같은 방식으로 마운트하면 된다.

$ mount /dev/sda2 -o subvol=@nocow /nocow

우분투 19.10 설치 후 발생한 문제들

우선, grub-efi 패키지가 제대로 설치되어 있지 않았다.

$ sudo apt install grub-efi --reinstall

그리고, grub에서 HDD의 iso 파일로 부팅이 안된다. 우분투 19.04에서는 잘 됐었기 때문에 EFI System 파티션의 /EFI/ubuntu 폴더를 19.04 버전으로 되돌려서 사용하고 있다. 이것이 가능했던 것은 우분투 19.04의 snapshot 덕분이다. 우분투 19.04 부팅 메뉴를 grub에 추가하되 linux와 initrd 부분에 @root-19.04 subvolume을 참조하도록 해야 한다.

linux   /@root-19.04/boot/vmlinuz-5.0.0-32-generic root=UUID=...... ro rootflags=subvol=@root-19.04
initrd  /@root-19.04/boot/initrd.img-5.0.0-32-generic

그리고, 부팅시 @root-19.04와 @home-19.04 subvolume이 mount 되어야 하므로 @root-19.04/etc/fstab 파일에서 @는 @root-19.04로, @home은 @home-19.04로 각각 수정해 주어야 한다. writable snapshot은 보통의 subvolume과 같이 파일을 수정할 수 있다. 이 경우에도 원본 subvolume은 안전하다.

이런 식으로 특정일의 @와 @home snapshot을 grub 메뉴에 등록해서 과거로 부팅할 수 있게 된다. 참고로, ZSF에서는 과거 특정 시점의 snapshot으로 rollback하면 그 과거 특정 시점 이후 생성한 snapshot 들은 삭제된다. 이에 반해 btrfs에서는 rollback이라는 것이 특정 시점의 snapshot을 사용하는 것이므로 중간 시점의 snapshot 들을 삭제할 필요가 없다. 즉, 과거에서 현재까지의 모든 snapshot 들의 상태로 들락날락 할수 있는 장점이 있다.

그 다음 문제는 VirtualBox 사이트에서 제공하는 6.0.14 버전이 설치가 안된다. 다행히 우분투 19.10 패키지에 6.0.14 버전이 탑재돼 있었다.

$ sudo apt install virtualbox virtualbox-ext-pack

설치시 modprobe 오류가 발생해서 모듈들이 로딩되지 않는 문제가 생기는데 수동으로 다시 올리면 되고, 재부팅해도 잘 된다. 또한, 이렇게 설치한 virtualbox는 guest additions iso 파일을 제대로 다운로드하지 못해서 별개로 다운로드 받아서 설치해야 했다.

btrfs 파일시스템에서 우분투 Clean Install시 사용자 설정 복원

Clean Install의 장점은 최신 버전의 앱들을 다시 설치함과 동시에 앱들이 구동되는 환경을 최신으로 바꿔 줄 수 있다는 점이다. 그러면 사용자가 과거에 설정했던 부분들을 어떻게 조화시킬 수 있을까? 이것이 가장 큰 문제이다.

현재 사용하고 있는 방법은 원하는 앱들을 모두 설치하고 나서 아래와 같이 하는 것이다.

$ sudo mount /dev/sda2 /mnt
$ cd /mnt
$ sudo btrfs sub snap @home-19.04 @home-new
$ cd /mnt/@home-new/aaa
$ rm -rf ./.*
$ cp -R /mnt/@home/aaa/.* .
$ cp /mnt/@home-19.04/aaa/.bashrc .
$ cp -R /mnt/@home-19.04/aaa/.config/VirtualBox .

$ sudo mv @home @home-19.10
$ sudo mv @home-new @home
$ sudo reboot

2019/04/24

Grub rEFInd chainloading


Mac PC를 사용하고 있지만 정작 macOS는 늘 방치해 왔는데 iPhone 파일들을 정리하려다 보니 macOS만 한게 없기 때문에 다시 macOS를 돌보기로 했다. 중간에 Recovery 파티션을 삭제했었는데 그 후로 OS를 Upgrade해도 다시는 Recovery 파티션을 생성해 주지 않더라. Recovery Partiton이 없는 상태에서 macOS에 문제가 생겨도 인터넷으로 macOS를 쉽게 설치할 수 있는 것은 Mac의 큰 장점 중 하나이다. 예전에 갖고 놀다가 날려서 어쩔 수 없이 High Sierra를 인터넷으로 설치한 채 방치해왔다. iMessage 동기화가 잘 안돼서 OS Upgrade를 했는데 10.13.4까지만 되고 10.13.6 버전은 설치하다가 파티션을 건드리면 Windows로 부팅할 수 없다는 메시지를 뿌리고는 Upgrade 할 수 없는 상황이 돼 버렸다. 기왕 이렇게 된거 Recovery Partiton도 만들겸 따끈 따끈한 Mojave(10.14.4)로 clean install을 하기로 했다.

모하비(Mojave) Clean 설치 후 ......

새로 설치하려면 8GB 이상의 USB가 필요한데 USB 외장하드를 사용해도 된다. 참고로 외장하드에 macOS의 DiskUtility로 GPT 파티션을 생성하면 200MiB의 EFI System Partition(ESP)을 기본으로 생성하더라. 아무튼, Mac OS 확장(저널링) 파티션(hfs+)을 추가해서 설치 이미지를 복사하면 재 부팅 후 <Option>(= <Alt>) 키를 사용해서 외장하드로 부팅해서 모하비를 설치할 수 있다. 그런데 High Sierra 이후에 macOS 기본 파일시스템이 hfs+에서 apfs로 바뀐 것이 생각나서 인터넷을 뒤져보니 High Sierra에서는 SSD일 때만 hfs+를 apfs(Apple File System)로 자동 변환했는데 모하비 버전부터는 HDD도 무조건 apfs가 기본이 되었단다. 그래서 설치 전에 기존의 hfs+ 파티션을 삭제하고 apfs 파티션을 새로 생성해서 모하비를 설치했다. 모하비 설치 방법은 인터넷에서 쉽게 찾을 수 있기 때문에 따로 정리할 필요는 없어 보인다. 참고로, 나처럼 Mac/iPhone에서 backup 파일이 5GB가 안될 경우에는 iCloud를 사용하면 엄청 편하다. 모든 것을 iCloud에 올려 놓고 icloud.com에서 Backup이 잘됐는지 확인하고 나서 macOS를 설치하면 설치 후에 모든 것이 예전처럼 복원된다. 물론 5GB이상인 경우에도 애플에 추가 비용을 지불하면 된다.

설치 후 재 부팅하면 macOS로 기본 부팅이 되기 때문에 우분투로 부팅하려면 부팅시에 Option키를 눌러서 EFI 아이콘을 선택해야 한다. 그런데, Recovery Partition으로 부팅할 수 있는 icon이 안보인다. 설치가 안됐는지 확인하기 위해 모하비로 부팅 후에 터미널에서,

$ diskutil list

해 보니 APFS container 밑에 분명히 Recovery Partition이 보인다. 혹시나 해서 부팅 시에 <Command>(= <Super> = <Windows>) + <R> 키를 사용했더니 Recovery Partition으로 부팅이 잘 된다.

흠, 이번에는 우분투로 부팅해서 NVRAM 부팅 설정이 비뀐게 있는지 확인하기 위해,

$ efibootmgr -v

로 확인해 보니 부팅 순서를 포함해서 바뀐 것이 없었다. 이게 약간 의아스러운 점인데 기존에 Grub으로 먼저 부팅하도록 설정된 상태이기 때문이다. 아마 macOS를 upgrade 하면서 firmware 설정도 바뀌었기 때문으로 추정된다. 우분투로 먼저 부팅하도록 기존의 순서대로 다시 아래와 같이 해 주고 재 부팅해 보았다.

$ sudo efibootmgr -o 0000,0001,0080

우분투 Grub으로 부팅이 잘 된다. 이번에는 우분투 Grub에 추가했던 macOS 메뉴 엔트리로 부팅해 보았다. 부팅이 안될 것으로 예상했는데 역시나 안된다. apfs 파일 시스템이 나온지 얼마 안돼서 Grub에 apfs module이 없기 때문이다. 리눅스에서 apfs 파일 시스템 지원 부분은 좀 시간이 걸리겠지만 조만간에 문제가 해결되리라 예상한다. 다행히, apfs 파일시스템은 apfs-fuse를 사용하면 리눅스에서 Read-only로 mount해서 사용할 수 있더라. 다만, Grub에서 macOS로 부팅할 수 있는 수단은 추가해야 한다. rEFInd를 Grub에서 chainloading 해 주면 문제가 해결된다.

Grub에서 rEFInd chainloading

사실, rEFInd만을 사용해도 macOS, 우분투, Windows를 모두 부팅할 수 있기 때문에 현재 apfs 모듈이 지원이 되지 않는 상황에서는 Grub을 사용하는 것이 최선은 아니다. macOS를 사용하지 않는 환경에서도 rEFInd를 기본으로 사용하는 사용자들도 많다. 하지만, OS 별 Upgrade가 원활히 동작하도록 하기 위해서는 모든 기본 설정이 정상 동작하도록 하는게 좋다. 더구나 boot manager/loader 들이 서로 chainloading이 되도록 해 주면 부팅 환경이 매우 쾌적해지고, 혹시나 어느 놈이 망가져도 다른 놈으로 부팅해서 문제를 해결할 수 있게 된다.

아무튼 최신 rEFInd-bin-0.11.4.zip 파일을 다운로드 받아서 압축해제 후 설치 폴더에서,

$ sudo refind-install

하면 우분투 또는 macOS에서 rEFInd가 ESP 파티션에 설치되고, rEFInd가 default boot loader가 된다. default boot loader라는 의미는 부팅 시에 별 다른 키를 누르지 않으면 rEFInd로 부팅한다는 의미이다. 즉, Grub을 대체해 버린다. 뭐 이렇게 사용하는 것이 더 편할 수도 있지만, 개인적으로 우분투 사용자 관점에서는 Grub이 Default가 되도록 하는 게 낫다고 본다.

Grub이 default boot loader가 되도록 하려면, efibootmgr -v 명령으로 ubuntu(Grub) boot entry를 확인 한 후, efibootmgr -o 명령으로 ubuntu가 맨 앞에 오도록 부팅 순서를 바꾸면 된다. 이 경우에 rEFInd Grub 부팅 메뉴 엔트리를 아래와 같이 만들어서 우분투의 /etc/grub.d/40_custom 파일에 추가해 주고 나서,

menuentry "rEFInd" {
    search --file --no-floppy --set=root /EFI/refind/refind_x64.efi
    chainloader (${root})/EFI/refind/refind_x64.efi
}

$ sudo update-grub

한 후에 재 부팅해서 Grub에서 rEFInd 메뉴를 선택해서 사용할 수 있게 된다.

참고: macOS에서 rEFInd 설치

기존에 rEFInd를 사용해 왔는데 macOS를 upgrade했거나 clean install 했다면 파티션 정보가 바뀌기 때문에 rEFInd를 다시 설치해야 한다. 그리고, 버전마다 버그 개선 사항이 있기 때문에 가능한 최신 버전을 사용하는 것이 좋다. 이는 우분투를 사용하지 않는 일반 macOS 사용자들에게도 일반적으로 적용된다. 또한, macOS에서 apfs 파일 시스템을 채택했기 때문에 rEFInd 최신 버전에서는 기본적으로 ESP 파티션에만 설치된다. 즉, 이후로는 macOS에서 rEFInd를 설치하든 우분투에서 rEFInd를 설치하든 큰 차이가 없다는 뜻이다. 단, 최근의 macOS에서 rEFInd를 설치하려면 반드시 Recovery 파티션으로 부팅해서 터미널에서,

$ csrutil disable

명령으로 SIP 모드 사용해제하고 나서 다시 Recovery 파티션으로 부팅한 후에야 rEFInd를 설치할 수 있다.

$ refind-install
$ csrutil enable

명령으로 SIP 모드를 다시 사용하도록 해준 후 재 부팅하면 된다.

참고: Mac에서 <Option> 키 사용시 rEFInd 사용

우분투를 사용할 경우 이 방법을 권하고 싶지는 않다. Mac PC에서 부팅 시 <Option> 키를 눌렀을 때 macOS Icon과 EFI Boot Icon 두 개가 나타나는데 EFI Boot Icon은 EFI 기본(fallback) boot loader로 부팅하도록 해 준다. 즉, ESP 파티션의 /EFI/Boot/bootx64.efi 파일로 부팅한다. 이 파일을 원하는 boot loader/manager로 교체해도 된다는 뜻이다. /EFI/refind/refind_x64.efi 파일을 /EFI/Boot/bootx64.efi로 복사해서 사용하면 <Option> 키를 사용해서 부팅시 rEFInd를 선택할 수 있다. 다만, 이렇게 하면 text mode의 rEFInd가 뜬다. /EFI/refind 폴더 내의 파일들을 /EFI/Boot 폴더에 복사해 주면 OS 별 Icon들을 사용할 수 있을지도...

참고로, 우분투를 설치하거나 Grub package가 update 되면 /EFI/Boot/bootx64.efi 파일에 /EFI/ubuntu/shimx64.efi 파일을 복사해 놓는다. 언제부터인지 우분투에서 Secure Boot를 사용하지 않는 경우에도 grubx64.efi 파일 대신 shimx64.efi 파일을 사용하고 있더라. 문제는 최신 rEFInd에 버그가 있어서 shimx64.efi로 부팅할 수 있는 방법이 없다. 무조건 우분투는 grubx64.efi 또는 raw kernel만 선택할 수 있다. 문제는 Grub chainloading이 안된다는 것이다.

우분투에서 macOS apfs 파일 시스템 사용

참고로, 이전의 Mac extended 파일 시스템(hfs+)은 hfsprogs 패키지만 우분투에 설치해 주면 mount/umount는 물론이고 gparted에서 format도 할 수 있었다. 앞서 리눅스에서 apfs-fuse를 사용하면 macOS의 apfs 파일 시스템을 사용할 수 있다고 했는데 git 사이트의 설치 방법을 따라가면 된다. 다만, 우분투 19.04에서 제대로 컴파일 하려면 아래 두 개의 패키지를 추가로 설치해 주면 된다.

$ sudo apt install zlib1g-dev libfuse3-dev

컴파일 후에 build 폴더에서 binary 파일만 /usr/local/bin에 복사해 두면 편리하다.

$ sudo fdisk -l /dev/sda

명령으로 macOS apfs 파티션을 확인하고 나서,

$ sudo apfs-fuse -o allow_other /dev/sda2 /mnt

와 같이 mount 하고,

$ sudo umount /mnt

와 같이 unmount 할 수 있다. mount는 read-only로만 할 수 있기 때문에 apfs 파일 시스템을 리눅스에서 망가뜨릴 염려는 안해도 될 것이다.

참고로, apfs는 btrfs와 매우 유사한 파일 시스템이다. Copy on write 방식을 사용하기 때문에 큰 파일 복제시 매우 빠르다. 또한, snapshot, 압축, encryption 등이 지원되는 것도 btrfs와 유사하다. SSD에서는 파일 Block들이 연달아 있지 않아도 되기 때문에 성능이 무척 좋다. HDD인 경우에는 자동 defragmentation 방식을 사용함으로써 성능문제를 해결하고 있다. btrfs는 Oracle과 SUSE 리눅스가 적극적으로 채택하고 있지만 Redhat이 포기한 후로 다른 리눅스 벤더들이 적극적으로 채택하고 있지는 않다. MS가 NTFS를 대체하려던 ReFS도 유사한 파일 시스템인데 그리 성공적이진 않은 모양이다. apfs는 아예 hfs+를 대체하는 것이니까 애플이 심혈을 기울일 수 밖에 없을 것이다. apfs가 잘 정착한다면 리눅스에서 btrfs도 보편화되리라 본다. 반대로, 어쩌면 btrfs가 나름 잘 정착했기 때문에 애플이 apfs를 채택한 것인지도 모른다.

2019/04/19

Ubuntu 19.04 Upgrade


어제 우분투 19.04 Disco Dingo가 공식 출시됐다. 방금 국내 미러 사이트를 이용해서 18.10에서 19.04로 Upgrade 했다. 패키지 다운로드 시간이 1분 정도 걸렸고 전체 Upgrade 시간은 45분 정도 걸렸다. 18.10이후 크게 바뀐 것이 없기에 이 글이 특별히 의미가 있는 것도 아니다. 다만, 18.04 이전 사용자들이라면 18.10 이후 적용된 Yaru 테마 덕분에 우분투가 크게 바뀐 듯이 느껴질 것이다.

우분투 19.04의 새로운 점들

언제나 자세한 사항은 Release Note를 참고하는 것이 좋다.

  • 성능 향상 : 우분투 18.10 이후 Gnome Shell에 대한 메모리 누수 방지와 성능 향상 노력이 19.04버전까지 이어졌다.
  • Gnome Shell 3.32 탑재: 새로운 nautilus를 탑재 하면서 Desktop에 Icon을 끌어다 놓는 기능이 사라졌다. 대신 Desktop Icons Extension을 사용해서 이 기능을 대체했는데 File Drag & Drop은 안된다. Upgrade 후에 바탕화면에 휴지통과 Home Folder icon이 나타나는데 Desktop Icons Extension을 사용한 것이다. 텅빈 Desktop을 선호한다면 Gnome Tweaks Tool을 이용해서 Desktop Icons Extension 설정에서 이 두 개의 icon을 안보이도록 할 수 있다.
    • 이외에도 gnome-terminal 등에 소소한 기능들이 추가됐다.
    • 야간 모드 설정을 미세하게 조정할 수 있다.
    • naultilus에서 Starred(별표)를 이용해 즐겨찾기 파일을 추가할 수 있다.
    • Yaru Icon 들이 새로 추가됐다.
  • Linux Kernel 5.0 탑재: AMD Radeon이나 Rasberry Pi 등 새로운 H/W 지원과 함께 절전 기능과 보안 기능 등이 강화됐다.
  • Toolchain Updates:  gcc 8.3/optional gcc 9, python 3.7.3 등과 함께 cross-compiler 들이 개선됐다.
  • Fractional Scaling 지원: HiDPI 디스플레이의 Wayland 및 Xorg session에서 화면 확대 기능을 지원한다.
  • S/W Updates: Firefox 66, LibreOffice 6.22 등이 탑재됐고, Tracker가 기본 설치되어 최근 사용 파일들을 쉽게 찾을 수 있도록 했다.
  • Grub Safe graphics mode 옵션: Grub 메뉴에 nomodeset이 설정된 메뉴가 추가됐다는데 upgrade해서 그런지 안보이더라.
  • 기타: Network Manager로써 IWD가 wpa supplicant를 대체하기 위해 탑재됨

Upgrade 후 발생한 소소한 문제들

일단 Upgrade 후에 Firefox를 띄워서 인터넷에 접속했는데 화면 스크롤이 느려졌다는 느낌이 들었다. 확인해 보니 Nvidia driver가 Loading 되어 있지 않았다. 그런데 nouveau 모듈도 로딩되어 있지 않았다. 이게 정상적인 Upgrade case인지는 확실하지 않다. 또한, Nvidia 드라이버 문제때문에 /etc/gdm3/custom.conf 파일에서 Wayland를 사용하지 않도록 한 상태에서 upgrade했기 때문에 발생한 문제인지는 잘 모르겠다. 혹시나 해서 Wayland를 사용하도록 설정한 후 재부팅했더니 아예 부팅하다가 gdm을 못 띄우고 먹통이 돼 버렸다. 아무튼 이 부분은 우분투의 버그임에 틀림없다. 다시 부팅해도 먹통이 되었기 때문에 복구 모드로 부팅해서 다시 Wayland를 사용하지 않도록 설정한 후 재부팅하니까 부팅이 되더라.

Cannonical에선 당분간 Wayland를 채택할 의향이 없나 보더라. 10년이 됐는데 안정성 문제가 해결이 안돼서 그럴만도 하겠다 싶다. 아무튼, 우분투 19.04에 기본 탑재된 Nvidia 드라이버를 설치해서 문제가 해결됐다. 좀 나아진 것은 Nvidia 드라이버 설치시 좀 버벅댐이 있었지만 재부팅하지 않고도 설치 후 Nvidia 환경에서 정상 동작한다는 점이다.

Gnome Extensions...

애용하는 Extension이 Hide top bar와 Pixel saver인데 이 중에서 Pixel saver가 항상 문제다. Upgrade 후에 Windows Title이 사라지지 않는다. 다행히 좀 찾아 봤더니 대용으로 쓸만한 놈이 있었다. Unite라는 놈인데 Pixel saver보다 훨씬 맘에 든다. Ubuntu Dock 대신 Dash to panel을 사용할 경우에도 잘 동작한다. 참고로, Dash to panel을 사용할 경우엔 Hide top bar 기능이 포함돼 있다.

맺음말

우분투 19.04를 추천한다면 성능 향상/시스템 안정성과 Yaru 테마를 꼽을 수 있을 것이다. 우분투는 이미 충분히 성숙한 OS이기 때문에 버전 Upgrade가 그다지 흥미로운 일도 아닌 것이 되어 버렸다. Windows 10을 같이 사용하기는 하지만 우분투가 훨 낫다는 것이 개인적인 생각이다.

2019/03/05

Windows에서 Qt5 쓰다 알게 된 몇 가지


우분투에 길들여져 있어서 Windows에서 뭘 하는 게 무척 서툴러 졌다. Windows 10 환경에서 Qt5를 갖고 노는데 잘 안되는게 많아서 삽질이다.

우선, Qt 5.12 최신 버전을 설치했는데, 하필이면 Qt Creator 4.8.1에 64bit MinGW / gcc 7.2가 딸려온다. 첨엔 32bit / 64bit 둘 다 만들 수 있으니까 좋겠다 싶었는데 그게 아니었다. 64bit MinGW 버전도 두 가지 형태로 배포되는데 32bit / 64bit 코드를 생성해 주는 배포판이 있고, 그렇지 않은 배포판이 있는데 Qt Creator에 딸려오는 놈은 64bit로 밖에 컴파일이 안된다. 참고로, Qt 5.11까지는 32bit MinGW가 기본이었다. MinGW 64bit가 기본이 되었다는 것은, Qt 5.12에 딸려오는 Qt library들도 모두 64bit 버전이 되었다는 것을 내포한다. 즉, 32bit 개발 환경이 필요하면 거꾸로 32bit MinGW와 32bit Qt library를 별개로 설치해야 한다는 의미이다. Qt Creator는 IDE(통합 개발 환경) Tool이기 때문에 여러 가지 조합을 kit로 만들어서 사용할 수 있도록 해준다. 일찌감치 64bit 환경이 된 우분투에서는 아예 신경도 안쓸 일을, 64bit Windows 10에서는 아직까지도 신경써야 한다는...

참고로, LLVM-7.0.1 64bit Windows 버전을 Qt Creator에서 사용하려면 Visual Studio 버전의 QT Library를 별개로 설치해야 한다. 걍 MinGW로 만족하는게 나을 듯...

아무튼, 삽질하다 보니 또 몇 가지 몰랐던 것들을 알게 되어 까먹기 전에 정리해 둔다.

Virtual Box Guest OS와 Host OS에서 생성한 Binary 성능 차이

사실, 이전엔 가상 머신을 애용했기에, 아무 생각없이 가상 머신 환경에서 만든 binary를 복사해서 Host OS에서 사용하곤 했었다. 그런데 최근에 장난감 chart를 Windows Host에서 돌려 보고는 깜짝놀랐다. 우분투 Host 보다 느려도 너무 느린 것이다. Virtual Box의 Windows에서 돌릴때는 가상 머신이라 느린 것을 당연하게 받아 들였지만, Windows Host에서는 그렇게까지 차이 날 이유가 없다. 그래서 Windows Host에 Qt 5.12를 설치하게 된 것이다. 마침내 새로 컴파일해서 돌려 보니 확실히 10배 가량 빨라졌다. 그런데 Qt가 문제인지 MinGW가 문제인지 Windows가 문제인지 확실하지 않지만, 장난감 chart의 성능은 우분투가 빠르다.

혹시나 해서 이번에는 Windows Host에서 생성한 binary를 복사해서 가상 머신에서 돌려 보았다. 이게 웬걸, 가상머신에서 돌려도 그렇게까지 느리지는 않다. Host의 80~90% 정도의 성능을 보인다. 헐~!!!

아무튼, 가상 머신에서 돌릴지라도 Host에서 생성한 binary로 돌리는 게 빠르다는 사실.

Windows Qt Creator에서 WSL에 설치된 git 사용

Windows의 Qt Creator는 Windows용 프로그램이기 때문에 같이 사용할 Tool들도 모두 Windows용 binary들이라야 한다. 하지만, Batch 파일을 사용하면 WSL(Windows Subsystem Linux)의 우분투에 설치된 git를 사용할 수 있을 것이라 생각해 왔다. 마침, 이번에 64bit MinGW를 설치했으니 다시 git.bat 파일을 사용해 보기로 했다.

chcp 65001
"C:\Windows\System32\wsl.exe" git %*

DOS 시절엔 batch 파일도 잘 만들어 썼는데 안쓰니까 잊혀진다. 구글링으로 복습해서 위와 같이 만들고 Qt Creator의 git에 Path를 넣었는데, wsl 명령을 실행할 수 없다는 메시지...

뭐가 문제인지도 모르겠고 거의 포기하려다 문득, Qt Creator가 32bit가 아닐까 의심하게 되었는데 WSL 우분투의 file 명령을 이용해서 확인해 보니 추측이 맞았다. 당연히 MinGW와 Qt Library들이 64bit 버전이 됐으니까 Qt Creator도 64bit일 것이라고 생각했던 것이다. 헐~!!! 아무튼, 근거없는 추측에 의존하면 안된다.

대부분 알고 있는 사실은 64bit 운영체제에서는 32bit건 64bit건 상관없이 프로그램이 잘 돌아 간다. WSL은 64bit 프로그램이고 Qt Creator는 32bit 프로그램이다. Windows에서 32bit 프로그램이 64bit 프로그램을 호출하는 방법도 혹시 있지 않을까??? 구글링하다 단서를 못찾아 거의 포기하려던 순간에 %windir%\Sysnative를 사용해 보라는게 눈에 띄었는데 Windows 디렉터리 밑에 Sysnative는 아무리 찾아봐도 없다. 밑져야 본전이니 함 해 보자...

chcp 65001
"C:\Windows\Sysnative\wsl.exe" git %*

git.bat를 위와 같이 바꾸고 Qt Creator에서 실행했는데 git 명령이 먹힌다. 헐~!!! 세상엔 숨겨진 진실이 수없이 많구나.

구글링하다 보니 WSL을 Windows 환경에서 적극적으로 활용하려는 노력들이 참 가상하다. wslgit 도 그 중의 하나이다. Qt Creator에서 WSL의 git를 제대로 사용하려면 wslgit를 사용하는게 좋을 듯... Windows는 DOS 태생시절 부터 Unix와 다르다는 점을 내세우고 싶어 했는데 대표적인게 Path 사용 방식이다. 때문에 이게 근본적인 골칫거리 중의 하나가 되었다. 아무튼 wslgit는 git.bat와 함께 우분투 쪽에도 wslgit script를 사용하여 여러 문제를 해결하고 있다. 단, git.bat 파일을 Qt Creator에서 사용할 시에는 위의 Sysnative라는 가상 Path를 반드시 사용해야 한다.

MinGW에서 Windows System Call 사용

위의 git.bat 파일에서 chcp 65001은 CMD 창(shell)의 code page를 UTF-8로 바꾸라는 명령이다. 참고로, CMD 창의 기본 code page는 949(ks_c_5601-1987/EUC-KR)이다. WSL의 우분투도 CMD 창에서 실행되기 때문에 우분투 명령들은 UTF-8 환경에서 실행되지만, Windows 명령들은 EUC-KR 환경에서 실행되게 된다. 이를 UTF-8로 일치시키도록 하는 것이 chcp 65001의 역할이다. 가령, SQLite DB를 CMD 창에서 사용할 때 한글 문제가 발생하지 않도록 하려면 UTF-8 환경에서 사용해야 한다.  기본적으로 SQLite의 encoding 방식은 UTF-8이기 때문이다.

프로그램 개발 시에는 이런 명령을 프로그램에서 제어하는 것이 바람직한데 MinGW에서는 <windows.h> 헤더를 사용함으로써 Windows System Call을 사용할 수 있다. 이것은 Qt Library와는 관련이 없다.

void setConsoleCodePage()
{
#ifdef _WIN32
  SetConsoleOutputCP(65001); // CodePage: UTF-8
  std::cout << "Setting console CodePage to UTF-8 on default locale(C): 한글 테스트\n\n";
#endif
}

즉, SetConsoleOutputCP(65001) 시스템 함수를 MinGW 함수에서 사용함으로써 chcp 65001 명령을 대체할 수 있게 된다. 이외에도 Screen Saver의 실행을 막거나 절전기능을 막기 위한 용도 등으로 Windows System 함수들을 사용할 수 있다.