Apache Ignite로 분산 캐시 만들어보고 싶어요 (1탄)

Overview

안녕하세요. 벌써 일년이 넘는 시간동안 포스팅을 하지 않았네요.

그동안 업무적으로 많은 변화도 있던 격동의 시기였던지라, 제 삶의 가장 중요한 요소 중 하나라 생각하던 공유의 가치를 그만 간과하고 말았네요. 물론 포스팅을 멈춘 시간동안 놀지 않고 많은 경험을 쌓아보았습니다.

오늘은 그중 하나, 인메모리 데이터베이스인 Apache Ignite에 대해 이야기를 해보고자 합니다.

Apache Ignite?

이미 많은 분들이 Ignite 를 사용해보셨을 수도 있겠습니다만, 저는 DBA로 일을 하면서도 늘 제대로된 캐시 용도로 써보고 싶었던 분산 인메모리 데이터베이스가 Apache Ignite입니다.

Server 기반 클러스터링 구성도 가능하고, Application 기반 클러스터링도 가능합니다. 참고로, 이 포스팅에서 이야기할 내용은 Server 기반 클러스터링 구성이고, 인메모리가 아닌 파일 기반 분산 캐시 형태로 사용합니다. SSD로 인해 디스크 Random I/O가 과거 대비 압도적으로 향상된만큼, 캐시 데이터 또한 디스크에 저장하지 않을 이유는 없습니다.

저는 개인적으로 하단 세가지 특성을 Ignite의 가장 큰 강점이라 생각합니다.

  • Persistence Mode
  • SQL(JDBC) 지원
  • 분산 캐시
  • 스케일 인/아웃

Quick Start

도커 기반으로 로컬에서 기능적으로 간단하게 테스트해볼 수 있는 환경을 구성해봅니다.

## 도커에서 사용한 데이터 영역 마운트를 위해 디렉토리 생성
$ mkdir -p $HOME/ignite/storage

## persistenceEnabled만 들어간 설정을 생성하기
$ echo '<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
       xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
       xsi:schemaLocation="http://www.springframework.org/schema/beans
        http://www.springframework.org/schema/beans/spring-beans.xsd">
    <bean id="ignite.cfg" class="org.apache.ignite.configuration.IgniteConfiguration">
        <property name="dataStorageConfiguration">
            <bean class="org.apache.ignite.configuration.DataStorageConfiguration">
                <property name="defaultDataRegionConfiguration">
                    <bean class="org.apache.ignite.configuration.DataRegionConfiguration">
                        <property name="persistenceEnabled" value="true"/>
                    </bean>
                </property>
            </bean>
        </property>
    </bean>
</beans>' > $HOME/ignite/storage/config.xml

## 도커 시작
$ docker run -d                        \
  -p "10800:10800"                   \
  -v $HOME/ignite/storage:/storage   \
  -e IGNITE_WORK_DIR=/storage        \
  -e CONFIG_URI=/storage/config.xml  \
  --name ignite                      \
  apacheignite/ignite

## 클러스터를 ACTIVE로 전환
$ docker exec ignite bash -c 'yes | /opt/ignite/apache-ignite/bin/control.sh --set-state ACTIVE'
Warning: the command will change state of cluster with name "fc4636efa81-50c9c9bd-a798-46a0-992b-c821b80f623f" to ACTIVE.
Press 'y' to continue . . . Control utility [ver. 2.15.0#20230425-sha1:f98f7f35]
2023 Copyright(C) Apache Software Foundation
User: root
Time: 2023-10-05T05:50:44.866
Command [SET-STATE] started
Arguments: --set-state ACTIVE
--------------------------------------------------------------------------------
Cluster state changed to ACTIVE.
Command [SET-STATE] finished with code: 0
Control utility has completed execution at: 2023-10-05T05:50:45.445
Execution time: 579 ms

## 클러스터 상태 확인
$ docker exec ignite bash -c 'yes | /opt/ignite/apache-ignite/bin/control.sh --state'
Control utility [ver. 2.15.0#20230425-sha1:f98f7f35]
2023 Copyright(C) Apache Software Foundation
User: root
Time: 2023-10-05T05:51:54.598
Command [STATE] started
Arguments: --state
--------------------------------------------------------------------------------
Cluster  ID: e6f53c11-62a4-4fec-8259-ae2a43ce29d5
Cluster tag: busy_burnell
--------------------------------------------------------------------------------
Cluster is active
Command [STATE] finished with code: 0
Control utility has completed execution at: 2023-10-05T05:51:54.902
Execution time: 304 ms

이제, 간단한 기능테스트를 해볼 수 있는 환경을 구성하였습니다.

How to use?

JAVA thin client로 캐시로 사용하는 방법과, Ignite JDBC를 통해 SQL데이터를 처리할 수 있는 방안 두 가지를 간단하게 살펴보겠습니다. maven 기준 하단 의존성이 필요합니다.

<dependencies>
    <dependency>
        <groupId>org.apache.ignite</groupId>
        <artifactId>ignite-core</artifactId>
        <version>2.15.0</version>
    </dependency>
</dependencies>

1. Using with java-thin-client

단순하게 캐시를 하나 만들고, put/get/count 정도를 해주는 로직입니다. 하단은 단순한 데이터 처리처럼 보이지만, 실제 운영 환경에서 초당 수만건 처리도 별다른 CPU 리소스 사용 없이도 너끈히 가능합니다. 개인적인 선호도에 따라, 오퍼레이션 실패 시 Retry 기능도 적절하게 구현해서 첨가한다면 더욱 강력한 프로그램이 되겠죠? ^^

import org.apache.ignite.Ignition;
import org.apache.ignite.cache.CachePeekMode;
import org.apache.ignite.client.ClientCache;
import org.apache.ignite.client.ClientCacheConfiguration;
import org.apache.ignite.client.IgniteClient;
import org.apache.ignite.configuration.ClientConfiguration;

import java.util.HashMap;
import java.util.HashSet;

import static org.apache.ignite.cache.CacheWriteSynchronizationMode.FULL_SYNC;

public class IgniteThinClientTest {

  public static void main(String[] args) {
    final ClientConfiguration igniteConfiguration = new ClientConfiguration()
        .setAddresses("127.0.0.1:10800")
        .setPartitionAwarenessEnabled(true)
        .setTimeout(5000);
    final IgniteClient igniteClient = Ignition.startClient(igniteConfiguration);

    final String cacheName = "ignite:cache";
    ClientCacheConfiguration igniteCacheConfiguration = new ClientCacheConfiguration();
    igniteCacheConfiguration.setWriteSynchronizationMode(FULL_SYNC);
    igniteCacheConfiguration.setBackups(1);
    igniteCacheConfiguration.setName(cacheName);
    igniteCacheConfiguration.setStatisticsEnabled(true);
    igniteClient.getOrCreateCache(igniteCacheConfiguration);

    ClientCache<String, String> cache = igniteClient.cache(cacheName);
    cache.put("key1", "abcde1");
    System.out.println("key1=>"+cache.get("key1"));
    System.out.println("Size=>"+cache.size(CachePeekMode.ALL));

    cache.putAll(new HashMap<String, String>() {{
      put("key2", "abcde2");
      put("key3", "abcde3");
      put("key4", "abcde4");
    }});
    System.out.println("GetAll=>"+
        cache.getAll(new HashSet<String>() {{
          add("key2");
          add("key3");
          add("key4");
        }})
    );
    System.out.println("Size=>"+cache.size(CachePeekMode.ALL));

    cache.remove("key1");
    System.out.println("Size=>"+cache.size(CachePeekMode.ALL));

    cache.clear();
    System.out.println("Size=>"+cache.size(CachePeekMode.ALL));

    igniteClient.close();
  }
}

<결과>

key1=>abcde1
Size=>1
GetAll=>{key2=abcde2, key3=abcde3, key4=abcde4}
Size=>4
Size=>3
Size=>0

추가로 하단을 읽어보시면, 나름 트랜잭션과 같은 고급 기능도 있으니, 참고하시면 좋겠습니다. (빙산의 일각만 소개함)
참고) https://ignite.apache.org/docs/latest/thin-clients/java-thin-client

2. Using with JDBC

SQL기반으로 데이터 처리하는 영역은 갈수록 광범위해지고 있습니다. 특히나, JAVA에서는 JDBC라는 강력한 인터페이스를 제공하며, 이를 잘 구현해놓으면 다양한 데이터처리를 SQL로 쉽게 접근하여 처리 가능합니다. Ignite에서도 JDBC 드라이버를 제공하고, 이를 기반으로 SQL로 데이터 질의가 가능합니다.

구현 자체는 기존 SQL 서버들과 붙는 것과 크게 다르지 않습니다. 단지, UPSERT 구문 및 각 데이터베이스별로 제공하는 일부 특이한 쿼리 패턴을 제외하고는.. ^^

import java.sql.*;

public class IgniteJDBCTest {

  public static void main(String[] args) throws ClassNotFoundException, SQLException {

    Class.forName("org.apache.ignite.IgniteJdbcThinDriver");
    Connection conn = DriverManager.getConnection("jdbc:ignite:thin://127.0.0.1:10800");
    Statement stmt = conn.createStatement();
    stmt.execute("create table if not exists person (\n"
                     + "  name varchar, \n"
                     + "  details varchar, \n"
                     + "  primary key(name)\n"
                     + ") with \"backups=1\"");
    stmt.execute("MERGE INTO person (name, details) VALUES ('key1', 'abcde1')");
    stmt.execute("MERGE INTO person (name, details) VALUES ('key2', 'abcde2')");
    stmt.execute("MERGE INTO person (name, details) VALUES ('key3', 'abcde3')");
    stmt.execute("MERGE INTO person (name, details) VALUES ('key4', 'abcde4')");
    {
      ResultSet rs = stmt.executeQuery("select * from person");
      while (rs.next()) {
        System.out.println(rs.getString(1) + "=>" + rs.getString(2));
      }
      rs.close();
    }
    {
      ResultSet rs = stmt.executeQuery("select count(*) from person");
      while (rs.next()) {
        System.out.println("Total=>" + rs.getString(1));
      }
      rs.close();
    }
    stmt.close();
    conn.close();
  }
}

<결과>

key1=>abcde1
key2=>abcde2
key3=>abcde3
key4=>abcde4
Total=>4

참고) https://ignite.apache.org/docs/latest/SQL/JDBC/jdbc-driver

Conclusion

오늘 이 포스팅에서는 Apache Ignite에 대한 간단한 기술 맛보기 정도만 소개를 했습니다. 물론, Ignite 혹은 Gridgain(상용버전)을 서비스에서 잘 사용하고 있는 분들도 분명 많을 것입니다. 일부는 Ignite에 대해 효율성을 공감하실 분도 계실 것이고, 일부는 아직은 아리송한 생각을 가지고 계실 수도 있습니다.

앞서 이야기를 했었지만, 저는 개인적으로 Apache Ignite에 대해서 하단 정도를 가장 큰 강점으로 뽑고 싶습니다. 물론 Ignite가 인메모리데이터이고, 다양한 기능을 제공해주기는 하지만, (개인적인 생각으로는) 결과적으로는 캐시서버일 분, 그 이상의 의미를 부여할 필요는 없다고 봅니다.

  • Persistence Mode
  • SQL(JDBC) 지원
  • 분산 캐시
  • 스케일 인/아웃

다음 포스팅에서는 위에서 소개를 했던 두가지 내용에 이어서, 분산 캐시 구성 방법에 추가로 모니터링 구성 방안을 이야기해보도록 하겠습니다. 아, 추가로, Thin Client 와 JDBC의 두 가지 퍼포먼스를 간단하게 로컬에서 테스트를 해서 공유를 하는 것도 좋겠네요.

감사합니다.

[MySQL] Online Alter에도 헛점은 있더구나 – gdb, mysqld-debug 활용 사례

Overview

MySQL에서도 5.6부터는 온라인 Alter 기능이 상당부분 제공되기 시작했습니다. 인덱스과 칼럼 추가/삭제 뿐만 아니라, varchar 경우에는 부분적으로 칼럼 확장이 서비스 중단없이 가능한 것이죠. 물론 오라클 유저들에게는 당연한 오퍼레이션들이, MySQL에서는 두손들고 운동장 20바퀴 돌 정도로 기뻐할만한 기능들입니다. 물론, 대부분의 DDL을 테이블 잠금을 걸고 수행하던 5.5 시절에도 online alter를 위해 트리거 기반의 pt-online-schema-change 툴을 많이들 사용했었기에.. 서비스 중단이 반드시 필요하지는 않았지만요. (https://gywn.net/2017/08/small-talk-pt-osc/)

아무튼 이렇게 online alter가 대거 지원하는 상황 속에서, MySQL의 메뉴얼과는 다르게 잘못 동작하는 부분이 있었는데, 이 원인을 찾아내기 위해서는 MySQL 내부적으로 어떻게 동작을 하는지 알아내기 위해 며칠 우물을 신나게 파보았습니다.

오늘 블로그에서는 지난 일주일간의 삽질에 대해서 공유를 해보고자 합니다.

VARCHAR size extension

MySQL에서 데이터의 타입이 변경되는 경우에는 테이블 잠금을 잡은 상태에서 데이터 리빌딩을 하기 마련이지만.. VARCHAR 타입의 경우에는 조금 다르게 동작을 합니다. VARCHAR 칼럼에는 데이터의 사이즈(바이트)를 알려주는 Padding 값이 포함되어 있는데, 매뉴얼 상으로는 패딩의 바이트 수가 동일한 경우에는 테이블 메타 정보만 수정함으로써 Online Alter 가 진행되는 것이죠.

The number of length bytes required by a VARCHAR column must remain the same. For VARCHAR columns of 0 to 255 bytes in size, one length byte is required to encode the value. For VARCHAR columns of 256 bytes in size or more, two length bytes are required. As a result, in-place ALTER TABLE only supports increasing VARCHAR column size from 0 to 255 bytes, or from 256 bytes to a greater size. In-place ALTER TABLE does not support increasing the size of a VARCHAR column from less than 256 bytes to a size equal to or greater than 256 bytes. In this case, the number of required length bytes changes from 1 to 2, which is only supported by a table copy (ALGORITHM=COPY)

출처 : https://dev.mysql.com/doc/refman/5.7/en/innodb-online-ddl-operations.html

바이트 단위로 계산이 되기 때문에, 글자수로 기록이 되는 VARCHAR 타입 입장에서는 캐릭터셋에 따라 글자 수 구간이 달라집니다. 예를 들어 2바이트를 차지하는 euckr인 경우, 1~127글자 구간과 128이상 구간으로 나누어볼 수 있을 것이고, 4바이트 utf8 캐릭터셋인 utf8mb4 경우에는 1~63글자 구간 그리고 64이상 구간으로 그룹을 지어볼 수 있겠습니다.

간단하게 예를 들어보겠습니다. 우선 하단과 같이 테이블을 만들어보고..

CREATE TABLE `test` (
  `a` varchar(8) DEFAULT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

알고리즘을 명시적으로 줘서 테스트를 해보면.. 우선 VARCHAR(8) -> VARCHAR(16) 사이즈 확장은 큰 문제 없이 잘 동작을 합니다. (둘다 바이트 수가 255 이하이므로, 동일 패딩 1바이트입니다.)

mysql> alter table test modify a varchar(16), algorithm=inplace;
Query OK, 0 rows affected (0.04 sec)

그러나 63글자를 넘어서 255사이즈로 inplace 알고리즘(online alter)으로 칼럼을 확장하고자 하면, 아래와 같이 에러를 뱉습니다. 에러 메시지를 존중하여, 알고리즘을 COPY로 지정을 해야 “테이블 잠금”을 품고 “데이터 재구성”하는 과정을 거쳐 비로서 정상적으로 칼럼 확장이 마무리되죠. (둘다 바이트 수가 256 이상이므로, 동일 패딩 2바이트입니다.)

mysql> alter table test modify a varchar(255), algorithm=inplace;
ERROR 1846 (0A000): ALGORITHM=INPLACE is not supported. Reason: Cannot change column type INPLACE. Try ALGORITHM=COPY.

mysql> alter table test modify a varchar(255), algorithm=copy;
Query OK, 1 row affected (0.03 sec)

매뉴얼 내용은 제대로 각인되었을 것으로 간주하고.. 이제 오늘 이야기하고자 하는 주제로 넘어가겠습니다.

Indexed Column Extension

그렇습니다. 이야기대로라면, Padding 조건에 맞는 칼럼이 경우에는 무중단, 리빌딩 없이 순식간에 칼럼 사이즈가 확장되어야 하는 것이 정석이건만.. 인덱스를 품은 VARCHAR칼럼인 경우에는 그렇지가 않았습니다. 위에서 테스트로 간단하게 생성을 했었던 테이블 기준으로 테스트 케이스를 만들어서 확인해보겠습니다.

CREATE TABLE `test` (
  `a` varchar(8) DEFAULT NULL,
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

mysql> insert into test select left(uuid(),8);

## 20회 수행
mysql> insert into test select left(uuid(),8) from test;

mysql> select count(*) from test;
+----------+
| count(*) |
+----------+
|  1048576 |
+----------+

확실한 차이를 보이기 위해서, 우선 인덱스가 없는 현재 상황에서 VARCHAR(8) -> VARCHAR(16)으로 사이즈를 확장해서 0.02초에 백만건 넘는 데이터의 칼럼 사이즈가 바로 확장이 되는 것을 보이겠습니다.

mysql> alter table test modify a varchar(16), algorithm=inplace;
Query OK, 0 rows affected (0.02 sec)
Records: 0  Duplicates: 0  Warnings: 0

이 상태에서 인덱스를 하나 추가하고 앞과 비슷하게 VARCHAR(16) -> VARCHAR(17)로 확장을 시도해보았는데.. 바로 완료가 될 것이라는 기대와는 다르게.. 초단위로 DDL이 수행되었습니다.

mysql> alter table test add key ix_a_1(a);
Query OK, 0 rows affected (1.55 sec)
Records: 0  Duplicates: 0  Warnings: 0

mysql> alter table test modify a varchar(17), algorithm=inplace;
Query OK, 0 rows affected (1.64 sec)
Records: 0  Duplicates: 0  Warnings: 0

여기에 추가로 인덱스를 하나 더 생성을 해서 VARCHAR(17) -> VARCHAR(18)로 칼럼을 확장을 해보니 인덱스 한개 대비 대략 두배 정도의 시간이 더 소요된 것을 확인할 수 있겠죠.

mysql> alter table test add key ix_a_2(a);
Query OK, 0 rows affected, 1 warning (4.43 sec)
Records: 0  Duplicates: 0  Warnings: 1

mysql> alter table test modify a varchar(18), algorithm=inplace;
Query OK, 0 rows affected (3.24 sec)
Records: 0  Duplicates: 0  Warnings: 0

참고로, 이 alter 구문의 프로파일링은 하단과 같으며.. “altering table” 단계에서 병목이 걸려있습니다. 무언가 내부적으로 이상한 짓을 하고 있는 것이죠. ㅎㅎ 이 당시 상황에서 performance_schema.metadata_locks을 확인해볼 수 있다면 약간의 정보를 더 얻을 수 있겠지만.. 대세에 큰 영향은 없습니다. ㅠㅠ 정보가 너무 부족합니다.

+--------------------------------+----------+
| Status                         | Duration |
+--------------------------------+----------+
| starting                       | 0.000069 |
| checking permissions           | 0.000007 |
| checking permissions           | 0.000005 |
| init                           | 0.000006 |
| Opening tables                 | 0.000297 |
| setup                          | 0.000019 |
| creating table                 | 0.009754 |
| After create                   | 0.000255 |
| System lock                    | 0.000012 |
| preparing for alter table      | 0.000275 |
| altering table                 | 2.993340 |
| committing alter table to stor | 0.014045 |
| end                            | 0.000056 |
| query end                      | 0.000336 |
| closing tables                 | 0.000024 |
| freeing items                  | 0.000041 |
| cleaning up                    | 0.000062 |
+--------------------------------+----------+

참고로 이 상황에서 서비스 쿼리가 중단되거나 하지는 않아요. 그냥 내부적으로 InnoDB Buffer Pool I/O가 늘어났을 뿐 서비스 쿼리는 큰 문제가 없습니다. 문제는 DDL 구문이 오래걸리는 상황이라면, 자연스럽게 “복제 지연”으로 이어질 수 있다는 점인데요. ALTER구문은 데이터를 담고 있는 “그릇”이 변경되는 의미하는 것이기 때문에.. 데이터를 복제하는 슬레이브 입장에서는 DDL 구문 이후에 변경된 그릇 기준으로 생성되는 DML 쿼리를 처리할 수 있습니다.

생각을 해보세요. 1000만건 테이블의 특정 VARCHAR칼럼에 5개 이상의 인덱스가 관여하고 있다면..?? 생각보다 슬레이브 지연 영향도는 심각할 수 있겠죠.

즉, 순식간에 종료될 것이라 생각했던 Online Alter 구문이 슬레이브 복제 지연에 영향을 줄 수 있다는 점이죠.

Replication-Delay

만약 슬레이브 서버들이 어떤 형태로든 서비스에 관여를 하고 있다면.. 이 상황에서 복제 지연 현상은 데이터를 관리하는 데이터쟁이 입장에서는 그다지 듣기좋은 이야기는 아닙니다.

자~ 그렇다면, 도대체 이 현상은 왜 발생했을까요? 다행히 이 이슈 분석을 위해 “인덱스 있는 VARCHAR 칼럼”이라는 중요한 재현 시나리오는 마련이 되어있군요. 만약 재현이 어려운 상황이라면.. 분석이 정말 난해한 상황이었을 것입니다. ㅋㅋ

Debugging with gdb

우선 멈춰있는 이 상황에서, MySQL이 내부적으로 어떤짓을 하고 있는지 디버깅을 해봅시다. 알수 없는 무언가를 하고 있는가를 확인해봐야겠죠. 위에서 MySQL이 무언가 알수 없는 것을 처리하는 상황에서 gdb로 아래와 같이 트레이스를 떠봅니다.

$ gdb -ex "set pagination 0" \
      -ex "thread apply all bt" \
      -batch -p `pidof mysqld` > result.out

이렇게하면 외계 문자들이 쭈르륵 떨어질 것인데.. 이중 OS의 쓰레드 아이디 기준으로 나온 결과를 보고 현재 이 쓰레드에서 무슨 짓을 하고 있는지 추적을 해봅니다. 참고로, mysql 프로세스 아이디 기준으로 OS 쓰레드 아이디는 아래와 같이 threads 테이블에서 알아낼 수 있어요.

mysql> select thread_id_os from performance_schema.threads
    -> where processlist_id = <프로세스아이디>;

만약 OS의 쓰레드 아이디가 8이었다면.. 이 쓰레드 번호로 검색을 해보면.. 지금 어떤일을 하고있는지 적나라하게 확인할 수 있겠죠. 참고로 여기서는 인덱스 리빌딩(row_merge_build_indexes) 관련하여 무슨 짓을 하고 있는 것으로 보여지네요. ㅎ (외계어기는 하지만.. 동일 버전의 소스를 다운받아서 위치를 따라가보면, 대략 무슨 짓을 하고 있는지 이해할 수 있을 듯..)

$ vi result.out
.. 중략 ..
Thread 8 (Thread 0x7ff8790c3700 (LWP 31032)):
#0  0x0104ed22 in row_merge_blocks .. storage/innobase/row/row0merge.cc:2784
#1  row_merge .. storage/innobase/row/row0merge.cc:2957
#2  row_merge_sort .. storage/innobase/row/row0merge.cc:3077
#3  0x01056454 in row_merge_build_indexes .. storage/innobase/row/row0merge.cc:4550
#4  0x00f902da in ha_innobase::inplace_alter_table .. innobase/handler/handler0alter.cc:6314
#5  0x007619ec in ha_inplace_alter_table .. sql/handler.h:3604
.. 중략 ..
#15 0xf9c07aa1 in start_thread () from /lib64/libpthread.so.0
#16 0xf98e8aad in clone () from /lib64/libc.so.6

Debugging with mysqld-debug

앞서 의심을 했었던, 인덱스 관련된 무슨 짓꺼리를 하고 있을 것이다라는 심증은 확신으로 바뀌면서.. 이제 조금더 상세하게 디버깅해보도록 하죠. 이번에는 mysqld-debug로 이 작업을 해보도록 하겠습니다.

$ mysqld-debug --debug=d,info,error,query,general,where:O,/tmp/mysqld.trace

참고: https://dev.mysql.com/doc/refman/5.7/en/making-trace-files.html

괘나 많은 로그가 떨어지겠지만.. 인덱스 있는 케이스와 없는 케이스 두 개로 나누어서 트레이스를 떠보면.. 대충 칼럼 사이즈 변경 시 fill_alter_inplace_info 함수 부분에서 리빌딩할 인덱스 리스트를 선별할 것 같은 우주의 기운이 팍팍 느껴지기 시작합니다. 왠지.. 동일 패딩 사이즈 경우 메타 정보를 수정해서 순식간에 ALTER 작업이 이루어지는 것처럼, 인덱스 리빌딩 단계에서 이 케이스를 선별해서 빼버린다면..?? 이런 병목은 사라질 것 같은 예감이 듭니다.

## 인덱스 없는 경우 ##
mysql_create_frm: info: wrote format section, length: 10
fill_alter_inplace_info: info: index count old: 0  new: 0

## 인덱스 있는 경우
fill_alter_inplace_info: info: index count old: 2  new: 2
fill_alter_inplace_info: info: index changed: 'ix_a_1'
fill_alter_inplace_info: info: index changed: 'ix_b_2'

다행히 이 문제는 MySQL 5.7.23 버전에서 픽스가 되었습니다. 소스를 살펴보고자, Oracle MySQL의 깃헙에 들어가서 살펴보던 중 왠지 우리와 비슷한 상황일 것 같은 커밋 코멘트(BUG#26848813: INDEXED COLUMN CAN’T BE CHANGED FROM VARCHAR(15))를 발견하였고, 대충 훑어보니 역시나 인덱스 리빌딩 케이스였더군요. (이렇게 절묘하게.. 손 안대고 코풀줄이야.. ㅋㅋ)
참고: https://github.com/…/913071c0b16cc03e703308250d795bc381627e37

5.7.23 버전에 online alter 플래그 중 “Alter_inplace_info::RENAME_INDEX” 비트 플래그를 하나 더 추가하였고, varchar 칼럼 확장이면서 패딩 사이즈가 동일하면 “Alter_inplace_info::RENAME_INDEX” 로 분기 분류함으로써 인덱스를 리빌딩하지 않도록 제어하더군요. (부분부분 발췌합니다.)

## fill_alter_inplace_info 함수 ###########
static bool fill_alter_inplace_info(THD *thd,
                                    TABLE *table,
                                    bool varchar,
                                    Alter_inplace_info *ha_alter_info)
{
  ....
  while ((rename_key= rename_key_it++))
  {
    ....
    if (! has_index_def_changed(ha_alter_info, table_key, new_key))
    {
      /* Key was not modified but still was renamed. */
      ha_alter_info->handler_flags|= Alter_inplace_info::RENAME_INDEX;
      ha_alter_info->add_renamed_key(table_key, new_key);
    }
    else
    {
      /* Key was modified. */
      ha_alter_info->add_modified_key(table_key, new_key); 
    }
    ....
  }
  ....
}

## has_index_def_changed 함수 ###########
static bool has_index_def_changed(Alter_inplace_info *ha_alter_info,
                                  const KEY *table_key,
                                  const KEY *new_key)
{
    ....
    if (key_part->length != new_part->length &&
        ha_alter_info->alter_info->flags == Alter_info::ALTER_CHANGE_COLUMN &&
        (key_part->field->is_equal((Create_field *)new_field) == IS_EQUAL_PACK_LENGTH))
    {
      ha_alter_info->handler_flags|=
          Alter_inplace_info::ALTER_COLUMN_INDEX_LENGTH;
    }
    else if (key_part->length != new_part->length)
      return true;
    ....
}

아무튼 5.7.23에서는 이 코드 적용 결과.. 인덱스가 있을지라도 VARCHAR 칼럼 확장은 순식간에 바로 종료가 되는 쾌거를 이룹니다. 아직 5.7.23 이전 버전을 쓰고 계신 분들이 버전을 업그레이드 해야하는 또하나의 이유가 생겼네요. ㅋㅋ

Conclusion

길고 긴.. 오늘 블로그 정리를 해보겠습니다. 그냥 이 세가지 이야기로 정리해보겠습니다.

  1. MySQL5.7.23 버전 이상으로 업그레이드 하는 것이 정신 건강에 좋다.
  2. MySQL5.7.23 이전 버전 사용자들은, 칼럼에 인덱스가 걸려있는지 확인을 우선 해보고 작업 계획을 세운다.
  3. 무엇보다 매뉴얼은 매뉴얼일뿐, 맹신하지 말자.

무엇보다 가장 알고 싶었던 부분은.. 매뉴얼과 전혀 다른 이 현상이 어디부터 시작된 이슈인지를 원인분석입니다. 이것을 알아야 이는 사이드이펙트를 최소화할 수 있는 우회방안도 고민해볼 수 있기 때문이죠. 피할 수 없으면 즐겨야할테니.. 맞더라도 이유는 알고 맞아야할 것이고.. 궁시렁궁시렁

저같은 C알못 평범남도 파보니 이해는 할 수 있겠드라고요. 엯촋들에게는 평범한 일상이겠지만.. 저같은 보통 사람들에게는 신성한 경험이었기에, 이를 공유 삼아 블로그 포스팅을 해봅니다.

아주 오랜만의 길고 긴 대하 소설.. 마칩니다. ㅋㅋ

PMM 이야기 1편 – INTRO

Overview

정말 오랜만에 글을 써봅니다. 은행이 오픈한지도 어언 8개월째를 훌쩍 접어들었네요. 여전히 MySQL 서버군에는 이렇다할 장애 없이, 무난(?)하게 하루하루를 지내고 있습니다.. (아.. 그렇다고 놀고만 있지는 않았어요!!)

사실 그동안의 경험과 삽질을 바탕으로, 필요성을 느꼈던 다양한 부분을 중앙 매니저에 최대한 녹여보았고, 그 집대성의 결과가 지금 뱅킹 MySQL시스템입니다. MHA 관리, 스키마 관리, 파티션 관리, 패스워드 관리, 백업/복구 관리..아.. 또 뭐있더라.. -_-;; 암튼, 귀찮은 모든 것들은 최대한 구현을 해놓았지요.

그러나, 예전부터 늘 부족하다고 생각해왔던 한가지 분야가 있는데.. 그것은 바로 모니터링입니다. 시스템에 대한 가장 정확한 최신 정보는 바로 모니터링 지표입니다. 만약, 제대로된 모니터링 시스템 환경 속에서, 실제 서비스의 영속성과 시스템의 매니지먼트를 “모니터링 지표”를 통해서 제대로된 “에코 시스템”을 구축할 수 있다면? 등골이 오싹할 정도의 선순환 작용으로 엄청 견고한 시스템을 구축할 수 있겠죠.

그래서, 한동안 관심가지는 분야가 바로 모니터링, 그중 Percona에서 오픈소스로 제공하는 PMM 요 녀석입니다. 우선은 서두인만큼.. 간단하게 PMM 소개를 해보고, 무엇이 부족한지 썰을 풀어보도록 하겠습니다.

PMM?

PMM(Percona Monitoring and Management)은 Prometheus 기반의 모니터링 솔루션입니다. 바로 몇년전 Percona 의 블로그에 이 관련해서 내용이 포스팅된 적이있었고, 한번 해보고 “와~” 하고 넘어갔었는데.. (은행 구축단계라..쿨럭)
(https://www.percona.com/blog/2016/02/29/graphing-mysql-performance-with-prometheus-and-grafana/)

Prometheus의 여러 프로젝트를 Percona 엮어 재구성 하고, 필요한 것들은 추가로 구현하요 Docker 이비지로 배포하고 있는 녀석이 바로 PMM입니다. ^^

pmm-diagram
pmm-diagram

뭔가 굉장히 복잡해 보입니다만.. 여기서 중요한 것 몇가지만 추려본다면..??

1. node_exporter, mysqld_expoter

모니터링 대상 노드(서버)에 서버에 데몬 형태로 구동되는 에이전트입니다. Prometheus에서 에이전트 포트로 매트릭 요청을 하면 즉각 서버의 현 상태를 보내는 역할을 합니다. Percona에서 Prometheus의 exporter를 (아마도) 포트떠서 구현해놓은 듯 하군요.

<Prometheus 프로젝트>
https://github.com/prometheus/node_exporter
https://github.com/prometheus/mysqld_exporter

<Percona 프로젝트>
https://github.com/percona/node_exporter
https://github.com/percona/mysqld_exporter

node_exporter쪽은 제대로 보지 않았지만, percona의 mysqld_expoter는 아래와 같이 크게 세 가지로 나눠서 매트릭을 제공한다는 점에서 기존 Prometheus와는 큰 차이가 있습니다. (참고로, Prometheus는 Parameter 전달 형식으로 매트릭을 선별하여 제공합니다.)

  • mysql-hr (High-res metrics, Default 1s)
    Global Status / InnoDB Metrics
  • mysql-mr (Medium-res metrics, Default 5s)
    Slave Status / Process List / InnoDB Engine Status.. etc
  • mysql-lr (Low-res metrics, Default 1m)
    Global Variables, Table Schema, Binlog Info.. etc

자세한 것은 하단 깃헙 142 라인부터 확인을 해보세요. ㅎㅎ
https://github.com/percona/mysqld_exporter/blob/master/mysqld_exporter.go

참고로, 저기 나열되어있다고, 모두 활성화해서 올라오지 않습니다. exporter의 구동 파라메터에 따라 선별되어서 수집이 됩니다.. (예를들어 Query Digest 정보는 기본 수집되지 않습니다.) /etc/init.d 하단에 위치한 에이전트 구동 스크립트를 보시면 될 듯..^^

2. pmm-admin

각종 exporter들을 구동 및 제어를 하는 녀석입니다. pmm-admin은 노드 관리를 효과적으로 하기 위해, PMM에 포함된 Consul 서버에 exporter 에이전트 접속 정보(아이피와 포트)를 등록합니다. Prometheus는 매 수집주기마다,  Consul 에 등록된 에이전트 리스트를 가져와서 모니터링 매트릭을 수집합니다.

3. pmm-mysql-queries

DB 혹은 OS의 매트릭 정보들은 Prometheus에서 수집/저장을 합니다. 오로지 매트릭만.. -_-;; Prometheus의 동작과는 별개로, DB 서버에서 pmm-server로 데이터를 던지는 녀석이 있는데, 이것은 유입 혹은 슬로우 쿼리에 대한 정보입니다. QAN API로 데이터를 던지는데.. pmm-server의 80포트 /qan-api에 던지면, nginx가 이 데이터를 받아서, 127.0.0.1:9001로 토~스 하여 최종적으로 쿼리를 수집하는 과정을 거칩니다.

한마디로, 쿼리 수집을 위해서는 “[ DB ] –80–> [ PMM ] 형태로 포트 접근이 가능해야한다” 합니다. ㅎㅎ

물론, Prometheus로만으로도 얼마든지 수집이 가능합니다만.. Percona에서 제공하는 QAN 플러그인을 활용할 수 없다는.. -_-;; 쿨럭 사실.. QAN 하나만으로도 블로그 한두개 나올 정도이니.. 나중에 얘기를 해볼께요.

4. Prometheus

타임시리즈 기반의 저장소로, 사실상 PMM의 핵심입니다. 데이터 수집을 비롯해서 저장 그리고 쿼리 질의, Alert까지.. 에이전트의 포트에 접근해서, 모니터링 시스템의 매트릭을 Pull 방식으로 끌어오는 역할을 하지요.
https://prometheus.io/docs/introduction/overview/

앞에서 간단하게 Consul에 대해서 얘기를 했었는데.. pmm-admin이 pmm의 consul에 모니터링이 될 대상들의 아이피와 포트 번호를 넣어주면, Prometheus는 이 정보를 바탕으로 등록된 scrape_interval마다 각 DB서버 에이전트에 접근하여 모니터링 지표를 수집합니다.

- job_name: mysql-hr
  scrape_interval: 1s
  scrape_timeout: 1s
  metrics_path: /metrics-hr
  scheme: http
  consul_sd_configs:
  - server: localhost:8500
    datacenter: dc1
    tag_separator: ','
    scheme: http
    services:
    - mysql:metrics

이것 외에도 Grafana에서 지표를 보여줄 데이터 저장소 역할도 하며, AlertManager 기능도 제공합니다.  Prometheus에 대한 조금더 자세한 설명은.. 훗날(?) 다시 얘기해보도록 하겠습니다. (이거 하나로 책 한권 사실 나옵니다 -_-;; 아놔)

5. Consul

pmm-admin이 consul에 모니터링 대상이 될 장비를 등록하면, 하단과 같이 consul에 저장되고, prometheus는 이 에이전트에 접속하여 매트릭을 수집합니다.

consul
consul

6. Grafana

데이터의 핵심이 Prometheus였다면, Visualization의 핵심은 바로 Grafana입니다. PMM이 강력한 이유가 바로 이미 만들어져 있는 다양한 Grafana 대시보드라고 생각합니다. 적어도 지표를 보여주는 부분에서는 타의 추종을 불허합니다. ^^ 특히나, 내가 원하는 그래프를 내 뜻대로 추가로 마음껏 만들 수 있다는 점에서는 더욱 매력적이죠.

grafana
grafana

Grafana 에서도 나름의 Alert을 설정하고 보낼 수 있습니다만.. Template Variable을 사용할 수 없기 때문에.. 각 지표마다, 각 서버마다 모두 수작업으로 한땀한땀 등록해야하는 노고가.. (향후 Template Variable을 사용할 수 있게 개선된다고는 하는데..흠..)
https://www.percona.com/blog/2017/02/02/pmm-alerting-with-grafana-working-with-templated-dashboards/

상단 링크를 읽어보면, 어떤 상황인지가 확 와닿을듯.^^

7. Orchestrator

MySQL의 Mater/Slave 토폴리지를 그려주는 녀석으로.. 아직까지 주의깊게 구성해보지는 않았습니다. 수십, 수백대 관리를 고려해봤을때.. 왜 해봐야할지 당위성을 가질 수 없더군요. 그래서 팻패스

Problem

PMM의 구성요소들을 쭉 열거해보았습니다만.. 사실.. 저것 요소 하나하나가 굉장히 큰 꼭지예요. ㅠㅠ 가장 만만한 exporter 하나하나도 해당 시스템 세세한 부분까지 수집하는 큰 프로젝트입니다. ㅠㅠ

아무튼, 지금까지 이것저것 해본 결과에 따라 현재 대단히 부족한 점 몇 꼭지를 콕 찝어서 얘기를 해보도록 하겠습니다.

1. 알람 발송

냉정하게 얘기하자면, 현재 PMM에서는 시스템 문제 시 알람을 효과적으로 발송할 수 없습니다. 나름 Grafana의 Alerting 기능을 통해서 흉내 정도 낸 것같은데.. 실무 적용에는 턱없이..아니 절대 불가합니다. 앞서 얘기한 것 처럼, 모든 지표와 인스턴스에 따른 임계치를 한땀한땀 등록하면 가능하겠지만.. 이건 인간이 할 일은 아니겠죠?

가장 좋은 방안은 Prometheus의  AlertManager를 활용하는 것인데.. ㅎㅎ

2. 수집 주기

수집 주기를 유연하게 변경할 수 있는 Docker 파라메터가 부족합니다. PMM에서 기본 수집 주기는 linux/mysql-hr은 1초, mysql-mr은 5초, mysql-lr은 1분입니다. 사실 10대 미만만 모니터링한다면, 이 수집 주기에 큰 이슈는 없습니다만.. PMM 서버 한대에 20대 이상의 모니터링 대상들을 붙이기 시작하면, 대 재앙이 펼쳐집니다. (로드 20,30,40 펑! ㅋㅋㅋ)

문제는 Docker 이미지로만 제공되는 PMM에서, Docker 컨테이너 초기화 시 옵션 제어가 제한적이라는 점에 있습니다. 실제 PMM Docker 컨테이너의 /opt/entrypoint.sh를 확인해보면.. 그나마 1초 수집주기 항목만 1~5초 사이로 조정할 수 있는 정도로만 구현해놓았습니다. -_-;; 뷁!

# Prometheus
if [[ ! "${METRICS_RESOLUTION:-1s}" =~ ^[1-5]s$ ]]; then
    echo "METRICS_RESOLUTION takes only values from 1s to 5s."
    exit 1
fi
sed -i "s/1s/${METRICS_RESOLUTION:-1s}/" /etc/prometheus.yml

3. 익명 로그인

이건 당췌 이유를 알 수 없습니다. PMM에 포함된 Grafana 기본은 익명 사용자도 로그인 없이 지표 확인이 가능하게 되어 있습니다.

https://github.com/percona/pmm-server/blob/master/playbook-install.yml 의 209 라인을 보면 분명 의도적으로 로그인 없이도 접근 가능하도록 설정합니다. (왜?)

- name: Grafana                    | Enable Anonymous access
  when: not grafana_db.stat.exists
  ini_file:
    dest: /etc/grafana/grafana.ini
    section: auth.anonymous
    option: enabled
    value: true

4. 진짜 대시보드

진정한 의미의 대시보드가 없습니다. 특히나, 장애 알람 기능이 현재 전무하기 때문에, 현 시점 이상이 있는 시스템을 재빠르게 캐치할 수 있는 방안이 없습니다. 즉, 지표 보기는 굉장히 최적화가 되어 있습니다만, 문제 발생 시 빠른 감지 및 정보 제공 측면에서는 굉장히 취약한 상태라고 봐야겠지요, ㅜㅜ

참고로, 제가 원하는 대시보드는 아래와 같은 모습이며, 특정 서버 문제 시 바로 현황 파악이 가능한 모습입니다.

dashboard
dashboard

5. Grafana Sqlite

PMM에 포함된 Grafana는 모든 메타 정보(대시보드를 포함한 지표 정보)를 SQLite에 저장합니다. 사실, 지표에 별다른 수정없이 기본값 그대로 사용한다면 큰 문제는 없지만.. 그렇지 않다면 생각보다 꽤나 많은 부분을 변경해야합니다. ㅠㅠ JSON 스트링 그대로 SQLite TEXT컬럼에 저장되어 있기 때문에.. 의외로 대량 편집에 상당 부분 노고가 필요합니다. 게다가.. 데이터 잘못 삭제 시.. 복구 방안은?? -_-;;

PMM안에도 mysql 이 기본 설치되기 때문에.. 이왕이면 이 녀석을 잘 활용했으면 하는 바램이;; 쿨럭 (무엇보다, 이럴꺼면, mysql5.7의  json 타입을 활용해보면 어떨까 생각도 합니다.)

Conclusion

Percona는 PMM을 Docker 이미지로 배포합니다. 사실 Docker로 배포되지 않는다고 한다면, 나름 커스터마이징이 용이할 것 같다는 생각은 듭니다만.. Percona에서 기능 구현 후 새로운 Docker를 배포한다고 생각해봤을 때, 자칫 잘못하면 그간의 노고가 한방에 날라갈 수 있겠습니다.

즉, 개인적인 생각이기는 하지만.. Docker 컨테이너를 임의로 수정하기 보다는, 최대한 PMM 의 흐름에 맞춰서 필요한 부분을 취하는 것이 현명할 것이라 생각합니다. 물론 필요하다면, Percona도 설득도 해봐야겠죠. ^^

우선, 당장은 PMM을 실무에 바로 적용하기에는 무리가 있습니다..만.. 이 부족한 부분을  기존 소스 흐름에서 크게 벗어나지 않는 수준으로 어떻게 대안을 찾아가고 있는지.. 다음 블로그부터 하나하나 정리를 해보도록 하겠습니다.
(참고로, 앞선 문제점들은 대나름의 해결 방안을 만들어놓았습니다. ㅎㅎ)

일단, PMM 서버 구성을 해봐야겠죠? ^^