Amazon RDS/Auroraをクローンするシステムを作った話

こんにちは、技術部SRグループの菅原です。

最近、Ninja650からNinja1000に乗り換えました。パワーがあるせいで3速発進・4速発進が平気でできてしまい、シフトワークがどんどん下手になっています。精進したいものです。

この記事では、Amazon RDS/Auroraをクローンするシステムを作った話を書きます。

Amazon RDS/Auroraをクローンするシステム

サービス開発を行っていると、調査や検証でプロダクション環境で使われているデータベースが必要になることがあります。開発環境やステージング環境にもデータベースは存在するのですが、プロダクション環境のデータでしか再現しないバグの調査や、プロダクション環境のデータ量でのスキーマ変更の負荷の検証など、開発環境やステージング環境のデータベースではできない作業も多いです。しかし、オペレーションミスや個人情報へのアクセスを考えると、プロダクション環境のデータベースで直接作業をすることは大きなリスクを伴います。

Amazon Auroraのクローン作成機能を使うと、プロダクション環境に影響を及ぼさないクローンを作成できるのですが、個人情報にアクセスできてしまう点は解決できません。また、クローンの作成や削除には強力なIAMの権限が必要なため、管理者がクローンを作成して利用者に渡すような手間が発生していました。

そこで、それらの問題を解決し、開発者が手軽にプロダクション環境のデータベースを触れるように、Amazon RDS/Auroraをクローンするシステムを作成しました。

クローン作成の手順は以下の通りです。

  1. SlackでRubotyに対して @ruboty rds clone db-cluster:my-cluster db.t3.small 4h というコマンドを送る
    • 普段からChatOpsでデプロイが行われていること、作成したクローンDBの情報を共有しやすいことなどからインターフェースとしてSlackを利用しました
  2. RubotyがBarbequeのジョブを起動する
  3. Barbequeのジョブがクローンを作成する
    • Auroraの場合はクローン作成機能、RDSの場合はスナップショットから復元
  4. クローンDBのマスターユーザーのパスワードを変更する
  5. クローンDBのデータをマスキングする
  6. セキュリティグループを変更して、社内ネットワークからクローンDBにアクセスできるようにする

f:id:winebarrel:20200819092303p:plain

f:id:winebarrel:20200819092422p:plain

データのマスキングには同僚の@mozamimyが作ったDumptruckという社内ツールを利用しており、以下のようなJsonnetの設定ファイルに従ってデータをマスキングします。

{
  database: 'db_name',
  except: ['secure_%'], // `secure_`プリフィックスのテーブルはクローンDBにコピーしない
  rules: [
    {
      table: 'users',
      transforms: [
        {
          column: 'tel',
          value: "lpad(id, 12, '0')", // SQLでデータをマスキング
          inline_sql: true,
        },
        {
          column: 'email',
          value: "concat(id, '@example.com')",
          inline_sql: true,
        },
      ],
    },
  ],
}

作成したクローンDBは、利用後に開発者が自分で削除するか、利用期限が切れるとバッチ処理が自動的にクローンDBを削除します。

f:id:winebarrel:20200819092447p:plain

開発者が自分でDBの削除やパラメータの変更を行えるようにするため、クラスタIDやインスタンスIDには rcc- というプレフィックスを付け、IAMの対象リソースをrcc-*とした権限を開発者に付与しています。

まとめ

Amazon RDS/Auroraのクローンが手軽にできるようになったことにより、プロダクション環境のデータの調査や、データベースのパフォーマンスの検証がはかどるようになりました。また、データのマスキングを設定ファイルで管理することにより、どのカラムに秘匿情報が入っているかもわかりやすくなったと思います。プロダクション環境のデータベースを使った作業は管理者やSREに作業が集中しがちなので、このような形でなるべく開発者に権限を委譲していきたいです。

大規模なiOSアプリの画面開発を効率化するために動作確認用ミニアプリを構築する

こんにちは、モバイル基盤部の大川(@aomathwift)です。

iOSアプリの開発途中で画面のレイアウトなど僅かな変更を確認したい場合、最も確実な方法はアプリをビルドして該当の画面まで手動で遷移して確認する方法です。

この方法は特別なセットアップが必要なく単純明快な確認方法ですが、効率の面で問題があります。例えば一番の問題として挙げられるのがビルド時間の長さという問題です。アプリ開発の規模が拡大していくと、ちょっとした変更でもビルド待ちの時間が無視できないものとなっていきます。

本稿では、クックパッドアプリの開発において、機能単体で動作するミニアプリを構築して、プレビューサイクルを改善した取り組みについてお話しします。 f:id:aomathwift:20200731184036p:plain

iOSアプリの動作確認における問題点

クックパッドアプリの開発は、開発規模の拡大によって、ビルド時間の改善が大きな課題になっていました。 そこで、最近はその問題を解決すべく、大きなアプリを複数のモジュールに分け、分割してビルドできるようなマルチモジュール化に取り組んできました。

詳しくは2019年のCookpad Tech Confでの講演、「 〜霞が関〜 クックパッドiOSアプリの破壊と創造、そして未来」をご覧ください。

f:id:aomathwift:20200731184155p:plain これは、マルチモジュール化が活発化した2019年9月から2020年6月まで、同一のマシンで計測した平均ビルド時間を集計したものです。 このマルチモジュール化の取り組みの結果、一回あたりのビルド時間が徐々に改善されてきているのがわかります。

しかしながら、小さな変更を確認したい場合やレイアウトを調整したい場合を考えると、まだまだストレスを感じる長さです。

また、新規のモジュールは、アプリを起動してからそのモジュールの機能や画面への接続が出来ていない状態から開発を始めます。 そのため、開発するモジュールの画面への遷移を先に実装することが必要です。

これをすべて同じ開発者が担当しているなら然程問題ではないかもしれませんが、この起動画面からの導線部分の実装とモジュールの開発を別の開発者が行っていた場合、モジュール開発を担当する人は仮の画面遷移を実装するなどの余計なコストが生じてしまいます。

Sandbox - 機能ごとに動作するミニアプリ

f:id:aomathwift:20200731184247p:plain
TechConf2019より引用

クックパッドのマルチモジュール化では、レシピの表示画面や、検索結果画面など、1機能に関連するいくつかの画面を1つのモジュールとして扱っています。この単位をFeature Moduleと呼んでいます。

Feature Moduleの導入により、アプリ全体をビルドせずとも、部分的にビルドすることができるようになりました。

これらのFeature Moduleはframeworkとしてクックパッドのメインターゲットでimportして利用しますが、先に述べたような動作確認における問題を解決するため、Feature Moduleを単体のアプリとして動作可能にしたのがSandboxアプリです。

以降、この部分的にビルドするSandboxアプリに対して、アプリ全体を結合してビルドするアプリは本体アプリと呼ぶことにします。

Sandboxアプリのメリット

本体アプリをビルドするより速くビルドできる

このSandboxアプリのわかりやすい恩恵は、本体アプリよりも極めて短い時間でビルドが終わる点です。

同じ少量の差分のビルドにかかる時間の計測結果を比較すると、本体アプリのビルドでは平均約20秒かかるのに対しSandboxアプリのビルドでは約5秒で済みます。 単純計算でビルド時間を1/4に抑えられるということになります。

実際のビルドの様子を見ても、Sandboxがものの一瞬で起動できることは一目瞭然です。

f:id:aomathwift:20200731184359g:plain
本体アプリのビルド
f:id:aomathwift:20200731184538g:plain
Sandboxアプリのビルド

Viewのレイアウトの僅かな値を変更して差分を確認したいときなどでも、ストレスなく開発することが可能になりました。

確認したい画面にすぐ辿り着ける

クックパッドのように機能の多いアプリでは、アプリトップから開発している画面にたどり着くまでがやや面倒な場合があります

また、決済終了後の画面など、表示する条件が複雑な画面も存在しています。

そこでSandboxアプリを利用すると、確認したい画面を一番最初に、好みの条件で起動することが出来ます。

繰り返し起動したい画面は一覧になっていて、ここから選択して表示することができます。

f:id:aomathwift:20200731184643g:plain

先に述べたように、まだアプリ起動時の画面からの導線が出来ていない画面のデバッグも容易に可能です。

Sandboxアプリの実現方法

上記のようなメリットを得られるSandboxアプリを実現するために、解決しなければならない問題がありました。

一つは、本体アプリでは多くの外部ライブラリに依存していますが、ビルド速度向上のためにできるだけこの依存ライブラリの利用を避けなければいけないということ、 もう一つは、Feature Module内の画面から、別のFeature Module内の画面に遷移するケースがありますが、このためには検証に必要ないモジュールのビルドも必要になるため、これもまた避けなければならないということです。

これらを考慮した上で、画面遷移やネットワークリクエストといった副作用を本体アプリに近い形で提供する工夫が必要になります。

そこで、クックパッドアプリでは Dependency Injection を利用した副作用を取り出すためのオブジェクトを用意し、これを経由して各画面から副作用を呼び出せるようにしています。 この仕組みをEnvironmentと呼んでいます。

これを利用し、Sandboxアプリではスタブ可能なダミーの実装を注入することで、本体アプリに影響を与えずに副作用を実現できるようにしました。

これによって、例えばネットワークリクエストも実際にリクエストを送るのではなく、予め用意したデータを注入して表示することができるようになっています。

f:id:aomathwift:20200731184817p:plain

Sandbox用のターゲットは各Feature Moduleごとに作成し、ターゲットごとにビルドすることでモジュール単位でのミニアプリの起動を実現しています。

Sandboxアプリを開発者に快適に利用してもらうための工夫

Sandbox用のコードはできるだけ自動生成する

Sandboxアプリを動かすためのコードは実際のプロダクションコードとは別に実装する必要があります。

画面の実装自体はプロダクションコードを参照するとはいえ一つのアプリとして立ち上げるわけですから、データのスタブやEnvironmentの実体の注入などそれなりにコードを記述する必要があります。

この手間が障壁となり、導入当初はSandboxアプリを利用せずアプリ全体をビルドするという開発者が多い状況でした。

そこで、Sandboxアプリのセットアップを簡単に行えるよう、コード生成の仕組みを用意しました。

コード生成にはGenesisというSwiftで実装されたOSSを利用しています。 これは、同じくSwiftで実装されたテンプレートエンジンであるStencilを利用し、簡単な設定とテンプレートを用意すれば、ソースコード生成の仕組みを実現できるツールです。

options:
  - name: sceneName
    question: Sandbox scene name?
    description: new Sandbox scene name to generate. (e.g. RecipeDetails).
    type: string
    required: true
  - name: moduleName
    question: Destination target?
    description: module name to generate new sandbox scene for. (e.g. RecipeDetails)
    type: string
    required: true
files:
  - template: AppDelegate.swift.stencil
    path: "{{ moduleName }}AppDelegate.swift"

例えばこのようなコード生成定義を書いて、オプションとして作成したいSandboxのモジュール名や画面の名前を与えると、以下のテンプレートファイルの中で展開されます。

@testable import {{ moduleName }}
import UIKit

@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate {
    private let environment = StubbableEnvironment()
    var window: UIWindow?

    func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
        window = UIWindow(frame: UIScreen.main.bounds)
        
        // Inject Scenes to RootTableViewController
        let rootViewController = {{ sceneName }}ViewBuilder.build(environment: environment)
        window?.rootViewController = rootViewController
        window?.makeKeyAndVisible()
        return true
    }
}

そして、この定義に基づくコード生成スクリプトを実行すると、自分であれこれコードを書かなくとも、Sandboxを生成したいモジュール・画面の情報が反映されビルドできるようになります。

$ ./scripts/generate-sandbox
[14:00:14]: Welcome to Sandbox Scene generator
[14:00:14]: What target do you want to make sandbox for
1. MyFeature
2. MyAwesomeFeature
?  1
[14:00:22]: Enter new Sandbox Scene name to generate. Upper camel case is recommended. (like RecipeDetails)
MyFeatureDetail
[14:00:40]: Generating MyFeature/MyFeatureDetail
[14:00:40]: $ /path/to/ios-cookpad/scripts/mint run Genesis genesis generate /path/to/ios-cookpad/templates/SandboxScene.yml --destination /path/to/ios-cookpad --option-path /var/folders/p7/g0t6l0zx00sbdxxrnm7wq8d80000gp/T/options20200714-98239-1lwms1g.yml
[14:00:40]: ▸ Generated files:
[14:00:40]: ▸   Sandbox/MyFeature/MyFeatureDetailSandboxScene.swift
[14:00:40]: ▸   Sandbox/MyFeature/AppDelegate.swift

できるだけ実際のアプリに近い挙動になるようにする

Sandboxアプリでは、マルチモジュール化での依存関係の問題により、他のモジュールにある画面に遷移することはできません。

基本的に一つの画面をプレビューすることを想定しているSandboxアプリでこの画面遷移を厳密にプレビューできるようにする必要はありませんが、スムーズな動作確認ができるように、簡易的なViewをモックとして表示できるようにしました。

これにより、本体アプリとほぼ同じ挙動を想定した動作確認をすることができるようになっています。

f:id:aomathwift:20200731190028g:plain

この機能は、先に述べたコード生成により自動で実装されるほか、自分で実装する場合も一つのイニシャライザメソッドを呼べばセットアップできるように整備されています。

今後の展望

先日のWWDC2020で、SwiftUIの新しいPreview機能についての発表がありました。

https://developer.apple.com/videos/play/wwdc2020/10149

昨年発表されたXcode Previewsは、SwiftUIで構築した画面を、Xcode上でリアルタイムに確認できるような仕組みです。

今回のアップデートでは、プレビュー中にサンプルデータを流し込んで利用したり、Xcode Previewsによって起動した画面を実機上でインタラクティブに操作しながら確認したりする機能が加わり、今まさにSandboxアプリで実現していることがXcode Previewsで実現できるようになります。

加えて、Dynamic TypeやダークモードのPreviewなどの機能とも併せることで、より効率的に開発を行うことが可能になるでしょう。

現在、クックパッドアプリの画面はほぼUIKitで実装されたものですが、新しい機能の実装にSwiftUIを利用できないか試しているところです。 来る新しいXcode Previewsが利用可能になる日に向けて、SwiftUIによるView実装への移行と共に、プレビュー機能全体をXcode Previewsを利用したものに移行していく必要があると考えています。

既存のUIKitによる実装に関してはXcode PreviewsとSandboxアプリの機能を併せて利用したものを試行しながら、プレビュー確認環境全体の改善を進めていく予定です。

Xcode PreviewsとUIKitの併用については、メルカリさんが下記の記事で自社の事例を紹介しています。

まとめ

この記事では、クックパッドアプリにおけるSandboxアプリを利用した動作確認の効率化について紹介しました。

開発効率を上げるために、スピーディーで快適な動作確認環境は必要不可欠です。

クックパッドでは、より便利なプレビュー機能への改善を一緒に行っていただけるエンジニアを募集しています。

RailsアプリケーションのCIにDynamoDB Localを導入した話

こんにちは、事業開発部 サーバーサイドエンジニアの堀江(kentarohorie)です。

今回はRailsアプリケーションのCIにDynamoDB Localを導入した事例をご紹介します。

広告入稿システムとCI

クックパッドでは自社製の広告入稿システム・配信サーバーを運用しています。また広告の一部はDynamoDBを利用したアーキテクチャで入稿・配信されています。詳細は以前の記事「広告配信サーバーにおける DynamoDB Accelerator (DAX) 活用事例の紹介」で紹介されています。この入稿・配信のうち、広告入稿システムのCIに対してDynamoDB Localの導入を行いました。

広告入稿システムのCIではブランチへのpush、またはmasterへの変更をトリガーにCIサーバー上でスクリプトが実行されていました。CIサーバーにはMySQLやPostgreSQLの環境が用意されており、スクリプトが実行されるとサーバー上のDBを初期化してrspecが実行されていました。多くのテストでそれらのDBを利用したテストが実行されていましたが、DynamoDBに関しては実際のDBを使用できていませんでした。

そのため、DynamoDBを利用している箇所ではAWS SDK DynamoDBClientのput_itemやdelete_itemなどのメソッドを一つ一つstubしたテストが書かれていました。これは例えばDynamoDBを利用したコードが増えたり、その箇所を間接的に利用する必要があるコードが生まれた場合に、DynamoDBの利用を気にしながら必要に応じて都度stubするといった作業が必要になるということです。
例えば以下のようなstubがit句毎に書かれていました。

it "..." do
  expect(dynamodb_client).to receive(:delete_item).with(
    hash_including(
      table_name: "table_name",
      key: { pk: "product_key" },
    )
  )

  expect { subject }.to change { ... }.to(false)
end

こうした状況の中でDynamoDBを利用している箇所で、stubせずともテストを書けるようにしようというモチベーションがありました。

DynamoDB Local導入に必要な環境を整備

DynamoDB Localの導入にあたっては執筆時点で3つの方法がAWSで紹介されています。

  • Apache Mavenリポジトリとして利用
  • Java環境を用意して実行
  • Dockerイメージを利用

これら方法のうち、Dockerイメージを利用してDynamoDB Localを導入しました。理由は全社的にCodeBuildの利用が推進されており、CodeBuild上でDockerを利用してCIを回すという事例が社内に既に多く存在していたためです。CodeBuildはAWSが提供するCI/CD用ビルドサービスであり、Androidアプリ CIをCodeBuildに切り替えた事例などクックパッドでは広く活用されています。

上記検討の後、まずは既存のビルド部分をCodeBuildに置き換え、Codebuild上のDockerでテストを実行できる環境を用意しました。ビルド部分の置き換えはJenkinsのCodeBuildプラグインを利用しました。次に社内で用意されているCodeBuild用Dockerイメージをベースに広告入稿システムのDockerイメージを作り、MySQLやPostgreSQLを利用する処理はスクリプトを用意してdocker-compose up時に実行されるようにしました。具体的にはDBの初期化やrspecの実行などです。

f:id:kentarohorie:20200721123459p:plain
Before

f:id:kentarohorie:20200721123513p:plain
After

この置き換え作業では、既存のCIと比べた場合に可能な限りCI時間が長くならないことを意識して進めました。CodeBuildに置き換える場合これまでになかったDockerイメージのビルドや立ち上げといった工程が増えるためにCI時間が長くならざるをえません。しかしCI時間は短ければ短いほうが良いので、許容できる程度までCodeBuildでのCI時間を縮める必要がありました。

具体的には以下の工夫を行いました。

  1. CodeBuild上でのDockerイメージビルドはキャッシュを利用する
  2. docker-composeでマウントするファイルを可能な限り減らす

広告入稿システムはRailsで動いており、ビルド時間でネックになっていたのはnode_modulesとgemのインストール工程でした。当初はCodeBuildのS3キャッシュを利用してnode_modulesとgemをキャッシュする方針で作業を行っていました。しかしその方法ではnode_modulesとgemファイル群をCodeBuildサーバー(コンテナの外)に持つ必要があり、docker-composeでマウントする必要のあるファイルが多くなり結果コマンド実行時間が遅くなるという問題が発生しました。

次にDocker Layer Cacheを利用する方法を試しました。はじめはCodeBuildで用意されている「ローカルキャッシュ」のDocker Layer Cacheモードを利用していましたが、ライフスパンが30分程度と短いため、CIの稼働頻度が30分に一度回るほどは高くない広告入稿システムではあまり恩恵を受けれませんでした。

そこで最終的に、ECRを利用してDocker Layer Cacheすることになりました。具体的にはCodeBuildのPOST_BUILDフェーズでECRへDockerイメージをpushし、次のBuild時にそのイメージをキャッシュとして利用する、というようにしました。

phases:
  pre_build:
    commands:
      - ....
      - docker pull "${REPO}:latest" || true
      - ...
  build:
    commands:
      - ...
      - docker build --tag "rspec" --tag "${REPO}:latest" -- cache-from "${REPO}:latest" -f Dockerfile .
      - ...
  post_build:
    commands:
      - ...
      - docker push "${REPO}:latest"
      - ...

DynamoDB Localをテストへ導入

CodeBuildへの置き換えが完了した後はdocker-compose.ymlにAmazonが公式に配布しているDynamoDB Localイメージを組み込み、テスト時にそれを読み込むように設定しました。具体的にはAWSのconfigをアップデートする処理をテスト実行前に読み込むようにしました。広告入稿システムのテストでは他にAWSリソースを使用していなかったため、DynamoDBリソースに絞った設定はしませんでした。

次にテスト実行時にDBが初期化されるようにしました。 広告システム関連で使われているDynamoDBにはdynaというgemを利用したDB初期化の仕組みがあります。 dynaはDynamoDBをDSLで管理できるものです。したがって、テスト実行時のDB初期化はdocker-compose up時に走らせるscript内にDB初期化を行うdynaコマンドを実行することで達成しました。

最後に、広告入稿システムのテストでDynamoDBに関するstubを外していく作業を行いました。これでDynamoDB LocalのCI導入が完了しました。

導入結果

DynamoDB LocalをCIに導入することで以下を達成できました。

  • DynamoDBに関する処理のstubを考えずにテストが書けるようになった
  • DynamoDBに関するテストコードを、各人の環境で実行できるようになった
  • DynamoDBを利用したコードの保守性を向上させることができた
  • DynamoDBやClientの仕様変更に耐えやすいテストになった

導入後、DynamoDBに関する最初の作業としてDynamoDBのクライアントgem aws-sdk-dynamodbのアップデート作業を行いました。specではstubせずにDynamoDB Localにアクセスしているのでテストが通った結果に安心感を持つことができ、導入によるメリットを実感しました。

最後に

以上、広告入稿システムのCIにDynamoDB Localを導入した事例をご紹介しました。

クックパッドにはユーザーが触る画面を改善しているサービス開発領域や、収益を支えている広告領域など、様々な領域でエンジニアが活躍しています。そしてそれらの領域ではエンジニアを随時募集しています。興味を持っていただいた方のご応募をお待ちしております。

新卒採用: https://info.cookpad.com/careers/new-graduates/

キャリア採用: https://info.cookpad.com/careers/jobs/

/* */ @import "/css/theme/report/report.css"; /* */ /* */ body{ background-image: url('https://cdn-ak.f.st-hatena.com/images/fotolife/c/cookpadtech/20140527/20140527163350.png'); background-repeat: repeat-x; background-color:transparent; background-attachment: scroll; background-position: left top;} /* */ body{ border-top: 3px solid orange; color: #3c3c3c; font-family: 'Helvetica Neue', Helvetica, 'ヒラギノ角ゴ Pro W3', 'Hiragino Kaku Gothic Pro', Meiryo, Osaka, 'MS Pゴシック', sans-serif; line-height: 1.8; font-size: 16px; } a { text-decoration: underline; color: #693e1c; } a:hover { color: #80400e; text-decoration: underline; } .entry-title a{ color: rgb(176, 108, 28); cursor: auto; display: inline; font-family: 'Helvetica Neue', Helvetica, 'ヒラギノ角ゴ Pro W3', 'Hiragino Kaku Gothic Pro', Meiryo, Osaka, 'MS Pゴシック', sans-serif; font-size: 30px; font-weight: bold; height: auto; line-height: 40.5px; text-decoration: underline solid rgb(176, 108, 28); width: auto; line-height: 1.35; } .date a { color: #9b8b6c; font-size: 14px; text-decoration: none; font-weight: normal; } .urllist-title-link { font-size: 14px; } /* Recent Entries */ .recent-entries a{ color: #693e1c; } .recent-entries a:visited { color: #4d2200; text-decoration: none; } .hatena-module-recent-entries li { padding-bottom: 8px; border-bottom-width: 0px; } /*Widget*/ .hatena-module-body li { list-style-type: circle; } .hatena-module-body a{ text-decoration: none; } .hatena-module-body a:hover{ text-decoration: underline; } /* Widget name */ .hatena-module-title, .hatena-module-title a{ color: #b06c1c; margin-top: 20px; margin-bottom: 7px; } /* work frame*/ #container { width: 970px; text-align: center; margin: 0 auto; background: transparent; padding: 0 30px; } #wrapper { float: left; overflow: hidden; width: 660px; } #box2 { width: 240px; float: right; font-size: 14px; word-wrap: break-word; } /*#blog-title-inner{*/ /*margin-top: 3px;*/ /*height: 125px;*/ /*background-position: left 0px;*/ /*}*/ /*.header-image-only #blog-title-inner {*/ /*background-repeat: no-repeat;*/ /*position: relative;*/ /*height: 200px;*/ /*display: none;*/ /*}*/ /*#blog-title {*/ /*margin-top: 3px;*/ /*height: 125px;*/ /*background-image: url('https://cdn-ak.f.st-hatena.com/images/fotolife/c/cookpadtech/20140527/20140527172848.png');*/ /*background-repeat: no-repeat;*/ /*background-position: left 0px;*/ /*}*/